diff --git a/.claude/agents/code-architect.md b/.claude/agents/code-architect.md index 624316d14..fd46bd41c 100644 --- a/.claude/agents/code-architect.md +++ b/.claude/agents/code-architect.md @@ -16,7 +16,7 @@ architectural principles: 3. **Thin adapters**: each adapter translates platform types to/from core types. Business logic never lives in adapters. The adapter file structure is: - `context.rs`, `request.rs`, `response.rs`, `proxy.rs`, `logger.rs`, `cli.rs`. + `context.rs`, `request.rs`, `response.rs`, `outbound.rs`, `logger.rs`, `cli.rs`. 4. **Contract testing**: every adapter has `tests/contract.rs` that validates request/response mapping. New adapters must follow this pattern. diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index e6ebfe251..8ac5a8322 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -37,7 +37,7 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up rust toolchain @@ -84,8 +84,8 @@ jobs: working-directory: examples/app-demo run: cargo clippy --workspace --all-targets --all-features -- -D warnings - adapter-wasm-clippy: - name: ${{ matrix.adapter }} wasm clippy + adapter-clippy: + name: ${{ matrix.check_name }} runs-on: ubuntu-latest strategy: fail-fast: false @@ -93,19 +93,32 @@ jobs: include: - adapter: cloudflare target: wasm32-unknown-unknown - features: cloudflare + check_name: cloudflare wasm clippy + feature_sets: |- + cloudflare + cloudflare,test-utils - adapter: fastly target: wasm32-wasip1 - features: fastly - # The `cli` feature is a valid wasip1 build combination (the CLI host - # code compiled for the guest target); gate it so a dead-code or - # portability regression there is caught, not only in the fastly-only build. - - adapter: fastly - target: wasm32-wasip1 - features: fastly cli + check_name: fastly wasm clippy + # The `cli` feature is valid on wasip1. Lint it independently so + # feature unification cannot mask target-specific cfg mistakes. + feature_sets: |- + fastly + fastly,cli + fastly,test-utils + fastly,cli,test-utils - adapter: spin target: wasm32-wasip2 - features: spin + check_name: spin wasm clippy + feature_sets: |- + spin + spin,test-utils + - adapter: axum + target: native + check_name: axum native clippy + feature_sets: |- + axum + axum,test-utils steps: - uses: actions/checkout@v6 with: @@ -126,7 +139,7 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust tool chain @@ -136,17 +149,46 @@ jobs: components: clippy - name: Add wasm target + if: matrix.target != 'native' run: rustup target add ${{ matrix.target }} - name: Fetch dependencies (locked) run: cargo fetch --locked - - name: Clippy ${{ matrix.adapter }} (${{ matrix.features }}) on ${{ matrix.target }} - run: cargo clippy -p edgezero-adapter-${{ matrix.adapter }} --features "${{ matrix.features }}" --target ${{ matrix.target }} --all-targets -- -D warnings + - name: Clippy ${{ matrix.adapter }} feature combinations on ${{ matrix.target }} + env: + ADAPTER: ${{ matrix.adapter }} + FEATURE_SETS: ${{ matrix.feature_sets }} + TARGET: ${{ matrix.target }} + run: | + target_args=() + if [[ "${TARGET}" != "native" ]]; then + target_args=(--target "${TARGET}") + fi + + while IFS= read -r features; do + echo "==> edgezero-adapter-${ADAPTER} ${TARGET} features=[${features}]" + cargo clippy \ + --package "edgezero-adapter-${ADAPTER}" \ + --no-default-features \ + --features "${features}" \ + "${target_args[@]}" \ + --all-targets \ + -- \ + -D warnings + done <<<"${FEATURE_SETS}" - name: Clippy ${{ matrix.adapter }} lifecycle fixture - if: matrix.features != 'fastly cli' - run: cargo clippy --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-${{ matrix.adapter }} --target ${{ matrix.target }} --all-targets -- -D warnings + env: + ADAPTER: ${{ matrix.adapter }} + TARGET: ${{ matrix.target }} + run: | + target_args=() + if [[ "${TARGET}" != "native" ]]; then + target_args=(--target "${TARGET}") + fi + cargo clippy --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml \ + -p "fixture-${ADAPTER}" "${target_args[@]}" --all-targets -- -D warnings format-docs: runs-on: ubuntu-latest @@ -162,7 +204,7 @@ jobs: - name: Retrieve Node.js version id: node-version working-directory: . - run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "node-version=$(grep '^nodejs ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Use Node.js @@ -180,3 +222,10 @@ jobs: - name: Run Prettier (check) run: npm run format + + - name: Check outbound documentation contract + working-directory: . + run: node scripts/check_outbound_docs_contract.mjs + + - name: Build documentation + run: npm run build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90fb74172..95729ede7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,14 +48,18 @@ jobs: - name: Set up Rust tool chain uses: actions-rust-lang/setup-rust-toolchain@v1 with: + components: "clippy, rustfmt" toolchain: ${{ steps.rust-version.outputs.rust-version }} - name: Install wasm targets for generated-project checks - if: matrix.suite == 'cli' + if: matrix.suite == 'cli' || matrix.suite == 'workspace' || matrix.suite == 'app-demo' run: rustup target add wasm32-wasip1 wasm32-wasip2 wasm32-unknown-unknown - name: Fetch dependencies (locked) - run: cargo fetch --locked + run: | + cargo fetch --locked + cargo fetch --locked --manifest-path examples/app-demo/Cargo.toml + cargo fetch --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml - name: No placeholder pins if: matrix.suite == 'workspace' @@ -65,6 +69,28 @@ jobs: if: matrix.suite == 'workspace' run: ./scripts/check_no_legacy_typed_reads.sh + # These roots are the active API surface. Historical design and plan + # documents under docs/superpowers intentionally remain immutable. + - name: No legacy outbound API + run: bash scripts/check_outbound_legacy_api.sh + + - name: Outbound documentation contract + run: node scripts/check_outbound_docs_contract.mjs + + - name: Build strict public API documentation + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --offline --locked --workspace --all-features --no-deps + + - name: Brotli dependency contract + run: node scripts/check_brotli_dependency_contract.mjs + + - name: Flate dependency contract + run: node scripts/check_flate_dependency_contract.mjs + + - name: Serde JSON map contract + run: node scripts/check_serde_json_map_contract.mjs + - name: Nested AppConfig audit if: matrix.suite == 'cli' run: cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates @@ -75,11 +101,9 @@ jobs: - name: Lifecycle fixture host tests if: matrix.suite == 'workspace' - run: >- - cargo test --locked - --manifest-path tests/fixtures/reusable-app/Cargo.toml - -p fixture-harness - -p fixture-core + run: | + cargo test --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-harness -p fixture-core --features fixture-core/qualification + node --test scripts/qualify_response_egress.test.mjs - name: Check Axum lifecycle fixture if: matrix.suite == 'axum' @@ -88,10 +112,38 @@ jobs: --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-axum + - name: Check opt-in lifecycle qualification fixtures + if: matrix.suite == 'workspace' + # These are compile-only identities, never deployed qualification evidence. + env: + EDGEZERO_QUALIFICATION_REVISION: "0000000000000000000000000000000000000000" + EDGEZERO_QUALIFICATION_ARTIFACT: "0000000000000000000000000000000000000000000000000000000000000000" + run: | + cargo check --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-axum --features qualification + cargo check --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-fastly --features qualification --bin fixture-fastly-qualification --target wasm32-wasip1 + cargo check --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-cloudflare --features qualification --target wasm32-unknown-unknown + cargo check --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-spin --features qualification --target wasm32-wasip2 + - name: Run workspace tests if: matrix.suite == 'workspace' run: cargo test --workspace --all-targets + - name: Bundled demo logging contract + if: matrix.suite == 'cli' + run: scripts/run_test_nonzero.sh demo_respects_off_without_hiding_terminal_errors cargo test --offline --locked -p edgezero-cli --features demo-example --test demo_logging + + - name: Run native outbound adapter contracts + if: matrix.suite == 'workspace' + run: | + scripts/run_test_nonzero.sh batch_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test contract + scripts/run_test_nonzero.sh batch_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils --test contract + scripts/run_test_nonzero.sh batch_preflight_rejects_streamed_slots_without_poisoning_siblings cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils --test contract + scripts/run_test_nonzero.sh batch_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features test-utils --test contract + + # The adapter CLI dispatch (build/deploy/stage/healthcheck/rollback) and + # its tests live behind the `cli` feature, which the unfeatured + # `cargo test --workspace` step above does not enable — so none of those + # tests compile or run there. Exercise them explicitly. - name: Run EdgeZero CLI tests if: matrix.suite == 'cli' run: cargo test -p edgezero-cli --all-targets @@ -108,13 +160,28 @@ jobs: --no-default-features --features cli + - name: Verify exact outbound capability matrices + if: matrix.suite == 'workspace' + run: | + scripts/run_test_nonzero.sh adapter_capability_matrix_matches_contracts cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features cli --lib adapter_capability_matrix_matches_contracts + scripts/run_test_nonzero.sh adapter_capability_matrix_matches_contracts cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cli --lib adapter_capability_matrix_matches_contracts + scripts/run_test_nonzero.sh adapter_capability_matrix_matches_contracts cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features cli --lib adapter_capability_matrix_matches_contracts + scripts/run_test_nonzero.sh adapter_capability_matrix_matches_contracts cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features cli --lib adapter_capability_matrix_matches_contracts + - name: Check feature compilation if: matrix.suite == 'workspace' run: cargo check --workspace --all-targets --features "fastly cloudflare spin" + - name: Check native adapter feature matrices + if: matrix.suite == 'workspace' + run: | + for adapter in axum cloudflare fastly spin; do + bash scripts/check_adapter_feature_matrix.sh "${adapter}" native + done + - name: Verify a generated project compiles if: matrix.suite == 'cli' - run: cargo test -p edgezero-cli --test generated_project_builds -- --ignored + run: scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test --offline --locked -p edgezero-cli --test generated_project_builds - name: Run Axum host tests if: matrix.suite == 'axum' @@ -125,6 +192,13 @@ jobs: working-directory: examples/app-demo run: cargo test --locked --workspace --all-targets + - name: Check app-demo wasm targets + if: matrix.suite == 'app-demo' + working-directory: examples/app-demo + run: | + cargo check --locked -p app-demo-adapter-cloudflare --target wasm32-unknown-unknown --no-default-features --features cloudflare + cargo check --locked -p app-demo-adapter-fastly --target wasm32-wasip1 --no-default-features --features fastly + cargo check --locked -p app-demo-adapter-spin --target wasm32-wasip2 --no-default-features --features spin cargo-test: name: cargo test needs: host-tests @@ -147,14 +221,17 @@ jobs: target: wasm32-unknown-unknown runner_env: CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER runner_value: wasm-bindgen-test-runner + sentinel: dispatch_runs_router_and_returns_response - adapter: fastly target: wasm32-wasip1 runner_env: CARGO_TARGET_WASM32_WASIP1_RUNNER runner_value: viceroy run + sentinel: dispatch_runs_router_and_returns_response - adapter: spin target: wasm32-wasip2 runner_env: CARGO_TARGET_WASM32_WASIP2_RUNNER - runner_value: wasmtime run + runner_value: wasmtime run -W component-model-async=y -S p3=y -S http=y + sentinel: router_dispatches_get_and_returns_response steps: - uses: actions/checkout@v6 with: @@ -257,8 +334,8 @@ jobs: echo "$install_dir" >> "$GITHUB_PATH" export PATH="$install_dir:$PATH" version="$WASMTIME_VERSION" - # Compare exact versions so a pin like `44.0.1` does not silently - # accept `wasmtime 44.0.10` from a stale cache or runner-provided + # Compare exact versions so a pin like `48.0.2` does not silently + # accept `wasmtime 48.0.20` from a stale cache or runner-provided # binary. actual="$(wasmtime --version 2>/dev/null | awk '{print $2}' || true)" if [ "$actual" != "$version" ]; then @@ -279,7 +356,25 @@ jobs: - name: Run ${{ matrix.adapter }} wasm tests env: ${{ matrix.runner_env }}: ${{ matrix.runner_value }} - run: cargo test -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} --test contract + run: scripts/run_test_nonzero.sh ${{ matrix.sentinel }} cargo test --offline --locked -p edgezero-adapter-${{ matrix.adapter }} --no-default-features --features ${{ matrix.adapter }},test-utils --target ${{ matrix.target }} --test contract + + - name: Run Fastly closed lifecycle wasm sentinel + if: matrix.adapter == 'fastly' + env: + ${{ matrix.runner_env }}: ${{ matrix.runner_value }} + run: scripts/run_test_nonzero.sh closed_lifecycle_orders_hooks_and_returns_state_after_delivery cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features fastly,test-utils --target wasm32-wasip1 --test contract + + - name: Run Spin WASI HTTP SDK resource tests + if: matrix.adapter == 'spin' + env: + ${{ matrix.runner_env }}: ${{ matrix.runner_value }} + run: scripts/run_test_nonzero.sh spin_error_code_table_is_exhaustive cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources + + - name: Run Cloudflare outbound runtime unit tests + if: matrix.adapter == 'cloudflare' + env: + ${{ matrix.runner_env }}: ${{ matrix.runner_value }} + run: scripts/run_test_nonzero.sh streamed_upload_eof_yields_before_final_precedence cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --lib # The colocated runtime unit tests (config-store resolver, chunk # remediation) need the `fastly` feature and only RUN under Viceroy — the @@ -289,8 +384,11 @@ jobs: if: matrix.adapter == 'fastly' env: ${{ matrix.runner_env }}: ${{ matrix.runner_value }} - run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib + working-directory: crates/edgezero-adapter-fastly + run: ../../scripts/run_test_nonzero.sh backend_creation_error_table_is_exhaustive cargo test --offline --locked --no-default-features --features fastly,test-utils --lib + - name: Check ${{ matrix.adapter }} wasm feature matrix + run: bash scripts/check_adapter_feature_matrix.sh ${{ matrix.adapter }} ${{ matrix.target }} - name: Fastly reusable HTTP smoke if: matrix.adapter == 'fastly' run: ./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smoke @@ -305,9 +403,6 @@ jobs: if-no-files-found: ignore retention-days: 14 - - name: Check ${{ matrix.adapter }} wasm target - run: cargo check -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} - - name: Check ${{ matrix.adapter }} lifecycle fixture run: >- cargo check --locked diff --git a/.tool-versions b/.tool-versions index 98c4f3e64..a74730ca5 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,12 +1,6 @@ fastly 15.1.0 nodejs 24.12.0 rust 1.95.0 -# spin-cli is not pinned by asdf-style version managers (no -# asdf-spin plugin in widespread use). spin-sdk ~6.0 in the -# workspace imports wasi:http/types@0.3.0-rc-2026-03-15, which -# the Spin 3.6.x runtime does not provide; Spin 3.7+ is required -# for the demo's Spin adapter to boot. Install matching CLI -# manually (https://spinframework.dev/install) before running -# smoke_test_config_key_override.sh; otherwise set SKIP_SPIN=1. -viceroy 0.17.0 -wasmtime 44.0.1 +spin 4.1.0 +viceroy 0.21.0 +wasmtime 48.0.2 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..92080776a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,438 @@ +# Changelog + +## Unreleased + +### Portable HTTP And Request Lifecycles (PR #275) + +The changes below cover [PR #275](https://github.com/stackpop/edgezero/pull/275), including its +implementation and review follow-ups. This is a hard API migration: retired APIs and enum variants +are removed rather than retained as compatibility aliases. Provider limitations are published +separately from the portable API and must be considered when declaring required capabilities. + +### Main Integration + +- Retain the fallible application assembly, pinned runtime targets and adapter-owned + ingress/egress while integrating immutable release deployment from main. Deployment + capability checks run before preflight or provider work; fallback dispatch retains + the selected target instead of rediscovering source from the working directory. + Remove the Fastly direct-deploy wrapper; callers supply `AdapterDeployContext`. +- Retire Fastly runtime selector-store setup. Applications open stable logical store + aliases; deployment selects and reconciles physical resource links. Logging policy + comes from baked `Hooks::logging_for("fastly")`, including custom entrypoints. +- Align bounded Axum secret reads with the prefixed uppercase environment mapping + used by unbounded reads, preserving independent byte caps and deadlines. + +### Bounded Local Config Snapshots + +- Hard-cut `ConfigStore::{get, get_bounded}` and matching handle APIs from owned `String` + to shared `ConfigValue` UTF-8 payloads across all adapters and fixtures. Borrow via + `as_str()`/`as_ref()`; ownership copies are explicit. Debug output is redacted. +- Load Axum immutable local snapshots before listener binding, with validated finite + `EDGEZERO__ADAPTER__CONFIG_STORE__*` file/entry/key/value/resident/startup budgets. + Enforce actual reads and decoder/index/payload staging charges, reject duplicate keys, + fail startup on allocation-limit violations, and retain typed failed declared bindings. +- Make Axum `from_path`, `from_local_file` and `from_map` require `ConfigStoreLimits`; + `from_map` is now fallible. Publish `resident_allocation_bytes`; push updates require + server restart. Shared reads preserve independent byte caps and the application clock. +- Pin the decoder allocation audit to `serde_json 1.0.150`; stream canonical hashing + without full-output staging and release the extractor's raw handle before secret reads. +- Add native TCP proofs for suppressed-body completion ownership, stalled producer and + non-reading client deadlines, disconnect cleanup, exactly-once release and capacity + recovery. These are local evidence, not hosted provider qualification; broad config + allocation remains Unsupported and response egress remains BestEffort. + +### Bounded Provider Config Reads + +- Replace Cloudflare bounded KV text materialization with capped BYOB byte-stream reads, + reusable 16 KiB buffers and acquired-reader cancellation on overflow, timeout or drop. + Require valid UTF-8 without lossy expansion; raw `get` remains explicitly unbounded. + Cancel late-arriving streams after an abandoned acquisition, consume rejected cancellation + promises, and yield cooperatively between bounded groups of clock-checked reads. + Require runtime GC cleanup support before KV dispatch; demo/generated Workers enable + `enable_weak_ref` explicitly. GC retention is still excluded from allocation certification. +- Replace Fastly SDK auto-growing config reads with one caller-sized host-ABI buffer. + Reject over-cap chunk declarations before fetching or allocating reassembly; borrow + discriminator tokens rather than building a JSON tree. Bounded storage resolution checks + transport integrity; typed envelope verification remains behind the extractor's structural scan. +- Hard-cut `FastlyConfigStore::try_open` to return `ConfigStoreError` rather than the SDK's + error type. Reject duplicate pointer discriminators and preserve future-format overwrite guards. +- Publish narrow guest allocation charges and the remaining Spin/hosted evidence gaps. + Broad config allocation stays Unsupported and config deadlines stay BestEffort. +- Add opt-in, revision-pinned lifecycle qualification fixtures for all four standard entrypoints, + a bounded HTTPS probe runner and a fail-closed correlated ownership-evidence validator. + Compilation and local tests are not hosted qualification; no capability is promoted. + +### Outbound HTTP And Batches + +- Replace the proxy-client/service API with `HttpClient`, `OutboundHttpClient`, `OutboundRequest`, + and `OutboundResponse`. Standard adapters inject the client into request extensions; handlers + access it through `RequestContext::http_client()`. Missing manual wiring remains explicit. +- Add buffered and streamed uploads/responses, absolute deadlines, request-body bounds, explicit + cache policy, and HTTP authority override independent of URI routing and TLS identity where + supported. Automatic redirects and implicit provider response decoding are disabled. +- Request construction is fallible and validates absolute HTTP(S) targets. Dispatch accepts GET, + HEAD, POST, PUT, PATCH, DELETE, and OPTIONS; GET/HEAD reject nonempty bodies. Ordinary `Host` + headers are normalized away; use the explicit authority override instead. +- Default to a 1 MiB buffered response, an 8 MiB upload cap, a 32 MiB decoder budget, a 24-bit Brotli + window limit, and a 30-second exchange budget when no timeout/deadline is supplied. Response-mode + setters are last-write-wins; `stream_response()` opts into streamed delivery. Encoded, decoded, + header, and chunk limits are separate opt-in controls. +- Accept streamed upload sources under the request cap and deadline, but collect them before + provider dispatch on Axum and Cloudflare. Cloudflare no longer forwards upload chunks directly + to Fetch as the old proxy implementation did; this API does not promise wire-streamed uploads. +- Expose `OutboundCachePolicy::{PlatformDefault, Bypass}`. Bypass disables provider caching on + Cloudflare/Fastly and is a no-op on Axum/Spin, which do not install an outbound response cache. +- Add completion-driven fanout through `start_batch_until`, indexed `next`, explicit `cancel`, + ordered `collect`, and `send_all_until`. Batch slots require buffered requests and responses; + streaming exchanges use `send`. +- Return per-slot elapsed time and outcomes, preserving input indices while yielding observed + completion order. Timing uses one batch-entry snapshot and includes preflight, provider setup, + complete body collection, and guest-observation delays rather than claiming pure transport RTT. +- Define `observation_cutoff` as the point where batch observation stops, even if individual slots + retain request budget. Earlier cutoffs are valid partial-result operations, not request timeouts. +- Distinguish `Completed`, `Cutoff`, and typed batch failure. Only cutoff permits unresolved slots + in successful collection; `OutboundBatchFailure` retains both the precise error and previously + collected, index-aligned slots. Cancellation releases the driver and returns unresolved indices. +- Require clean driver EOF before `Completed`, including zero-slot drivers. Trailing failures, + duplicate/out-of-range indices, premature EOF, and cutoff after all slots resolve are rejected; + poisoned batches cannot resume driver polling. External adapters have a documented, public + `OutboundBatchDriverEvent` / `OutboundBatch::from_driver` construction protocol. +- Axum, Cloudflare, and Spin drive complete eligible exchanges concurrently. Fastly dispatches + eligible inputs before harvesting ready pending handles, preserves handle-to-slot identity, and + uses deterministic dynamic-backend identities that include exact timer budgets. An early + `BatchCutoff`-attributed provider phase timeout terminates only its slot unless the actual + observation cutoff has expired. +- Share the future-based batch driver through `OutboundBatch::from_futures` on Axum, Cloudflare, + and Spin. Preserve recorded terminal timestamps, ready-result retention, index validation, + and clean-EOF completion; Fastly retains its provider-handle driver. +- Add `ContentTypeProvenance::{Ineligible(ContentTypeIneligibility), SingleTextUnchanged, Unknown}` + from original response-header multiplicity and UTF-8 evidence before filtering. Reasons are + `Missing`, `Modified`, `MultipleValues`, `NonText`, and `Removed`. Axum/Fastly/Spin retain + evidence on every response path; Cloudflare always reports `Unknown` because Fetch strings + lose original bytes and field boundaries. No MIME validation, trimming, comma splitting, + raw-header/body copies, or new capability is added. A known textual singleton containing a + comma can remain eligible; application MIME validation, acceptance, and redaction still apply. +- Expose `content_type_provenance()`. `headers_mut()` conservatively invalidates an originally + eligible result as `Ineligible(Modified)` without rewriting private original evidence; + original ineligible/unknown evidence never upgrades. `into_response()` carries the effective + enum as a response extension describing the historical outbound boundary only, not certifying + later ordinary `http::Response` edits. +- Hard-cut response construction to + `OutboundResponse::new(method, status, headers, body, clock, content_type_provenance)`, + decomposition to `(method, status, headers, body, provenance)`, and normalization to + `Result` containing body disposition and provenance. + Metadata is mandatory, never inferred from normalized headers; no old signatures remain. + +| Retired outbound API | Required replacement | +| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| four- or five-argument `OutboundResponse::new` | six-argument `new(method, status, headers, body, clock, content_type_provenance)` requiring the application clock and original evidence | +| four-element `OutboundResponse::into_parts()` | five-tuple `(method, status, headers, body, provenance)` with effective `ContentTypeProvenance` | +| `normalize_response_headers(..) -> Result` | `Result`; use `.body_disposition` and retain original `.content_type_provenance` | + +### Encoding, Limits, And Error Classification + +- Enforce independent encoded-transport, decoded-output, and final buffered-response caps. + Decoded caps apply to identity and EdgeZero-decoded bodies, not raw passthrough encodings. + Checks occur incrementally before appending excess output; response collection retains the + configured bounds and original absolute deadline. +- Share the response-body policy through `ResponseBodyPolicy` and `apply_response_body_policy` + across all four adapters. Declared lengths, encoded limits, decoding/header rewriting, decoded + limits, and rechunking retain their order; transport lifetimes remain adapter-owned. +- Add `into_bytes_bounded`, `into_bytes_bounded_until`, `json_bounded`, and `json_bounded_until` + for explicit response consumption. The `_until` helpers check the retained application clock + cooperatively; pending-stream cancellation still depends on the adapter's deadline wrapper. +- Add response-header byte/count caps with typed overflow origins. Accounting includes every + adapter-visible upstream field before normalization and the synthetic `x-edgezero-proxy` field. + Preserve repeated headers, including `Set-Cookie`, and strip hop-by-hop fields consistently. +- Decode single bare `gzip`, zlib-wrapped `deflate`, and `br` codings. Validate multi-member gzip + through transport EOF, reject trailing data after deflate/Brotli completion, and remove compressed + representation headers only when EdgeZero decodes. Request bodies retain their declared encoding + without implicit recompression. +- Replace bare `ContentEncoding::Passthrough` with `Passthrough(PassthroughReason)`, distinguishing + `Stacked`, `Unsupported(token)`, and `Malformed` while retaining encoded bodies and headers. +- Reject excessive Brotli window bits before decoder allocation. Publish pinned Brotli and flate + decoder memory-charge contracts and enforce the codec-independent `max_decoder_bytes` budget. + Add opt-in `max_chunk_bytes` rechunking as an emitted-item bound, not a receive-allocation bound. +- Add `Deadline`, `DispatchBudget`, the EdgeZero-owned `MonotonicInstant` alias, and injectable + `MonotonicClock`. Ingress, outbound clients, deferred response drains, config extraction, and + egress use one application clock domain. Equality expiry wins; far-future deadlines are clamped + and remaining dispatch budgets cannot grow beyond their entry duration. Backwards batch terminal + observations and Fastly dispatch samples fail closed. +- Add typed timeout provenance (`BudgetSource`, including `BatchCutoff`), gateway transport/decode + reasons, response-limit reasons, and store-extraction reasons. Distinguish initial unreachable + failures, midstream transport failures, protocol failures, and content-coding layers without + requiring message parsing where the provider exposes those distinctions. Opaque Cloudflare Fetch + failures retain `BadGatewayReason::Unspecified`. Preserve timeout causes through decoder error + conversion; upstream/response-limit failures use 502 and exhausted exchange budgets use 504. +- Use category-only public diagnostics for provider-backed failures so URLs, credentials, and + provider error details cannot escape in framework HTTP error bodies. + +### Ingress Admission And Routing + +- Resolve routes before admission and body polling on all four standard adapters. Publish stable + `RouteId`, `RouteMetadata`, and `RouteResolution::{Matched, MethodNotAllowed, NotFound}`, including + optional manifest-sourced route `class` metadata. +- Stamp request start with the application clock, expose absolute read deadlines, and retain an + opaque `IngressGrant` through admitted processing. Request contexts can consume the grant once. +- Replace eager unbounded inbound reads with bounded body consumption and deadline-aware wrappers. + Cloudflare and Spin race pending asynchronous reads with timers; Axum owns its connection-local + read future. Fastly retains its synchronous-hostcall limitation. +- Add cached, fallible `RequestContext::body_bytes(max)` and bounded JSON/form consumption. + Failed or cancelled drains poison the body cell so later readers cannot retry a partially consumed + source. Default extractors are bounded; `ValidatedJsonWithin` and + `ValidatedFormWithin` let handlers select their own compile-time caps. +- Add `ReadBodyBeforeFallback` for bounded discard before canonical 404/405. It carries the + application grant, byte cap, read deadline, and buffered `on_exceeded` / `on_timeout` responses. + Overflow or timeout takes precedence without invoking a route handler or middleware; refusal + remains the zero-read overload path. +- Add non-response `AdmissionDecision::Abort`. It starts no response lifecycle, invokes no handler, + middleware, or completion callback, and polls no body. Axum closes the owned HTTP/1 connection; + other adapters delegate the final transport effect to their providers. +- Add defensive normalized request-target/header/framing checks and typed accounting summaries. + These do not claim access to raw parser-boundary memory or ambiguous raw framing. +- Emit a sorted, deduplicated `Allow` header for canonical 405 responses, including successful + fallback drains. Add an application-controlled framework-error renderer while preserving status, + `Allow`, and the effective config `Retry-After` policy after custom rendering. Applications remain + responsible for bounding their renderer's output. + +### Bounded Axum HTTP/1 Ingress + +- Enforce finite pre-admission limits through the existing Axum server entrypoints: 256 live + connections, 64 KiB combined raw request heads, 100 raw fields, and a non-resetting 10-second + idle-plus-header deadline by default. Explicit invalid settings fail startup before application + configuration or listener binding. `AxumIngressConfig` publishes validated effective limits and + parser receive-allocation assumptions. +- Replace per-connection spawned tasks with one bounded connection-future owner. At capacity, + close the single transient accepted socket without a parser, HTTP refusal response, or waiter + queue. Keep-alive retains its slot; disconnect, failure, timeout, cancellation, and shutdown + release connection state. Preserve existing application body-read and response-write deadlines. +- Pin Hyper 1.12.0, bytes 1.12.1, and http 1.4.2 for the audited HTTP/1 resource boundary. Raw head and duplicate + field-count limits precede admission, but independent raw-target/header accounting and strict + ambiguous-framing rejection remain unsupported. HTTP/2 and socket upgrade ownership are not + enabled. Kernel buffers, allocator overhead, and application allocations are not RSS-certified. +- Bound and compact response headers before Hyper reuses their map for request parsing, preserving + duplicates and sensitive flags. Response fields share the configured head-byte budget and a + derived `max(100, request_field_limit)` count cap; overflow closes the connection. Custom Hyper + reason phrases are removed. Oversized spare capacity/backing buffers cannot persist in the + parser cache. +- **Breaking:** `AxumDevServerConfig` now requires its validated `ingress` field in complete struct + literals. Use `AxumIngressConfig::default()`, `from_env`, or `new`; generated entrypoints still + delegate to `run_app`. Axum CLI Cargo children receive selected manifest environment defaults, + respecting parent environment precedence and declaration order. The demo manifest and scaffold + document the ingress settings without duplicating runtime enforcement. +- Recognize case-insensitive `EDGEZERO__` prefixes on Windows, where child environment-key aliases + retain their first spelling. Unix key identity stays unchanged, and explicit invalid limits + cannot be bypassed through a Windows case alias. + +### Response Egress And Application Assembly + +- Carry every response-producing admission outcome through `ResponseEgressEnvelope` and one + `ResponseEgressAttempt`, retaining the application clock, policy, and response-scoped completion + resource until the exactly-once terminal transition. +- Add non-clone `ResponseEgressCompletion`, generic `late_bound` resource installation, and + left-to-right `join` composition. Completion runs before the global observer; abandonment and + competing terminal paths release resources without duplicate reports. +- Refusal/fallback responses retain their admission-decision-owned completion resources. Detached + normalized errors use `DetachedResponseEgressDecision::{Send { completion, deadline }, Abort}`; + the application factory can reserve resources or abort without creating a response. Raw parser + failures remain outside the application lifecycle. +- Support per-response absolute and egress-start-relative deadlines through + `ResponseEgressDeadline`. An absolute `at(deadline)` can include preceding application queue time; + `after(Duration)` resolves in `ResponseEgressEnvelope::begin` and excludes time before egress + starts. Preserve the selected deadline through policy, framing, conversion, and delivery. +- Replace response-object handoff and whole-stream buffering with adapter-owned response pumps: + Axum's Hyper connection, Cloudflare's writer/abort lifetime, Fastly's manual `stream_to_client`, + and Spin's owned WASI response/body/result writers. Account accepted payload prefixes and release + sources on timeout, disconnect, source failure, and terminal transport errors. +- Share response framing rules for HEAD, body-forbidden statuses, declared-length validation, + hop-by-hop stripping, unsupported upgrades, and bounded pre-commit error fallbacks. Fastly manual + framing is enabled only when a body is actually transmitted. +- Validate Axum exact-length stream EOF before handing Hyper the final frame; preceding frames + remain incremental. Validate transmitted zero-length streams before handoff under the original + deadline. Excess/late-error tails fail, pending/empty tails stay deadline-bounded, detached finite + fallbacks retain GET payloads, and HEAD remains bodyless. Registration expiry selects 504 rather + than a conversion 500. +- Make assembly core-owned and fallible through `App::build::(platform)` and + `Hooks::configure(&mut App) -> Result<(), EdgeError>`. Install target metadata before configuration, + propagate hook errors unchanged, and reject successful configuration that changes any platform + fact, provenance, or unknown reason. Add `app!(..., configure = ...)` with a fallible callback + whose errors propagate through `App::build`. Axum completes assembly before listener binding. +- Add Fastly's closed `run_app_with_hooks` lifecycle for raw-request preparation and routed-response + finalization while retaining adapter-owned delivery and returning routed hook state only after + terminal delivery. +- Integrate opt-in reusable application lifecycles with fallible assembly and canonical admission/ + egress. Cloudflare and Spin expose prebuilt `dispatch_app`; Fastly exposes `serve_app`, + `serve_app_with_hooks`, and successful-only `lifecycle::Sandbox` retention. Fastly custom serving + callbacks own delivery and return `Result<(), E>` without an SDK-generated second response. + Reuse no request handles, bodies, extensions, registries, or pending transport work. + +### Bounded Config And Secret Extraction + +- Add `ConfigExtractionLimits` and typed bounded config/secret reads with per-value, backend-byte, + and cumulative extraction caps under one extraction-start-relative timeout and application clock. + Fastly chunk reconstruction and secret hydration share the same cumulative accounting/deadline. +- Bound JSON depth and node count before materializing a config envelope as `serde_json::Value`, + so nested or oversized structural shapes are rejected before allocating the retained tree. +- Distinguish missing binding/value/secret, malformed envelope, unsupported envelope version, + integrity failure, deserialization, validation, overflow, deadline, and provider-read failures + through `StoreExtractionReason` and typed store errors rather than the previous coarse extraction + classifications. +- Require provider implementations of `ConfigStore::get_bounded` and + `SecretStore::get_bytes_bounded`; there is no default unbounded-read-then-check implementation. + Return `BoundedStoreRead` with explicit backend bytes and an optional value. +- Preserve declared Cloudflare/Fastly config bindings when opening fails by installing typed failing + handles. Optional missing-config policy cannot reinterpret a broken declared binding as absence. + Defer Spin config-store opening until bounded extraction, after admission, so unrelated routes + can still dispatch and opening participates in extraction timing. +- Race asynchronous Cloudflare/Spin reads with platform timers and recheck injected-clock expiry + around ready results. Synchronous Axum/Fastly provider calls remain non-preemptible; byte and + timeout controls do not certify provider allocation or finite cancellation latency. + +### Platform resource metadata + +- Add `Adapter::platform_metadata()` and application-visible `PlatformMetadata` with typed + `PlatformFact::{Known, Unknown}` values, provenance, and unknown reasons for memory ceilings, + concurrent live inbound population, and host ingress/framing memory accounting. +- Distinguish per-instance from per-execution ceilings and separate stack allowances. Publish + canonical Axum, Cloudflare, Fastly, generic Spin, and opt-in Akamai Functions profiles; generic + Spin remains runtime-configured rather than inheriting a hosted quota. +- Add checked, scope-aware `validate_memory_envelope` with `Fits`, `Exceeds`, and typed + `Indeterminate` results. Reject scope mismatch and arithmetic overflow; unknown platform facts + remain indeterminate rather than being replaced with guessed population or parser-memory bounds. + +Applications and adapter implementations must migrate to the current platform-fact and response +construction APIs. The migration record includes APIs from earlier PR revisions as well as the +pre-PR release surface, so consumers pinned to intermediate commits can update directly. No +compatibility aliases are retained. + +The focused platform-design table below preserves that earlier migration record. Its outbound +constructor row is historical and superseded by the six-argument outbound migration above. + +| Removed or changed API | Replacement | +| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `MemoryCeilingSource` | `PlatformResourceSource` inside `PlatformFact::Known` | +| `MemoryCeiling::new(total, scope, stack, source)` | `MemoryCeiling::new(total, scope, stack)` wrapped in `PlatformFact::known(value, source)` | +| `MemoryCeiling::source()` | Match or inspect the enclosing `PlatformFact` | +| `PlatformMetadata::new(Option)` | `PlatformMetadata::new(memory, inbound_population, host_ingress_accounting)` | +| `PlatformMetadata::memory_ceiling() -> Option<_>` | `PlatformMetadata::memory_ceiling() -> PlatformFact<_>` | +| `Adapter::memory_ceiling()` | `Adapter::platform_metadata()` | +| overridable `Hooks::build_app()` and infallible `configure` | `App::build::(platform)` plus fallible `Hooks::configure` | +| four-argument `OutboundResponse::new` | five-argument constructor requiring the application `MonotonicClock` | + +### Logging Ownership And Boot Diagnostics + +`owns_logging = true` now explicitly owns backend installation, backend filters, and the facade +maximum before entering an adapter runner. `resolve_logging_level(&EnvConfig)` shares runtime +level resolution. Managed Axum/Fastly loggers retain `edgezero::boot` warnings and errors at +`Warn` even when ordinary logging is `Error` or `Off`. Cloudflare/Spin initializers remain no-ops. + +Fastly `runtime_env_config` now returns `FastlyRuntimeConfig` with an `env` field and bounded +deferred diagnostics. Custom callers must initialize logging and call `emit_boot_diagnostics()`; +the former direct `EnvConfig` return is removed, with no compatibility alias. Managed runners +initialize logging before application configuration. Axum now returns logger-setup errors instead +of ignoring them; preinstalled backends must use `owns_logging = true`. + +Axum's `run_app_with_preflight` supports checks after logger setup and before configuration or +listener binding. The bundled demo uses it without first installing the CLI logger. Terminal CLI +errors remain visible on stderr regardless of runtime logging level. + +### CLI, Capabilities, Demo, And Generator + +- Add typed manifest capabilities and `Adapter::capability` with `Native`, `BoundedCooperative`, + `BestEffort`, and `Unsupported`. Runtime-producing CLI operations validate required support + before side effects; optional support emits boot diagnostics without blocking execution. + Unknown names, duplicates, overlap, and malformed baked contracts fail closed. +- Add canonical outbound-host declarations, with an omitted declaration defaulting to HTTPS only + and explicit `"*"` enabling HTTP and HTTPS. These configure platform plumbing, not destination + authorization. Spin's selected component must match the canonical host contract. +- Pin CLI execution to the resolved application root, platform manifest, and component through + `AdapterExecutionTarget` / `execute_target`, including manifest-defined shell commands. Reject + ambiguous or escaping manifest selection instead of checking one project and executing another. + Fastly deployment honors the selected manifest path. +- Migrate checked-in demos and generated applications to the new client, buffered fanout, + passthrough classification, response limits, clocks, ingress policy, and fallible assembly. + Include explicit missing-client 501 behavior and partial-result/failure examples. +- Keep runtime setup in reusable adapter runners rather than copying transport or logging code + into templates. The demo illustrates completion composition and late-bound ownership; generated + scaffolds retain finite admission/egress policies with core-owned empty lifecycle tokens. +- Gate `handlebars` behind the existing `edgezero-cli/cli` feature so runtime-only library consumers + do not pull scaffolding or implicitly enable its `serde_json/preserve_order` dependency feature. + +### Additional Hard Migrations + +| Removed or changed API | Replacement | +| ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `ProxyClient`, `ProxyHandle`, `ProxyRequest`, `ProxyResponse`, `ProxyService`, adapter `*ProxyClient` | `OutboundHttpClient`, `HttpClient`, `OutboundRequest`, `OutboundResponse`, client forwarding, adapter `*OutboundClient` | +| Infallible proxy request constructors and tuple `into_parts()` | Fallible `OutboundRequest` constructors / `from_parts` and structured `OutboundRequestParts` | +| Proxy request extension access and `body_mut()` | Application-owned state and outbound request builders / structured parts | +| `Body::from_stream` with arbitrary external errors | Typed `EdgeError` items, or `Body::from_external_stream` for external errors | +| Synchronous/unbounded request-context body access | Async `body_bytes(max)`, `json_within(max)`, `form_within(max)`; fallible `take_body()` / `into_request()` | +| `EdgeError::MethodNotAllowed.allowed: String` | Structured `Vec` used to render the mandatory `Allow` header | +| `config_out_of_date_from_serde` | `store_deserialization_from_serde` and `EdgeError::StoreExtraction { reason, .. }` | +| Store implementations with only unbounded reads | Required `ConfigStore::get_bounded` / `SecretStore::get_bytes_bounded` implementations | +| Decoder helpers without a memory policy | Codec helpers accepting decoder budgets and Brotli window policy | +| Response-returning Fastly entrypoints and `#[fastly::main]` | An undecorated `main` calling a send-owning adapter runner | +| Transport-blind response converters and stream-buffer fallback constants | Adapter-owned response-egress coordinators | +| Fastly `runtime_env_config(...) -> EnvConfig` | `FastlyRuntimeConfig`, using `.env` and `emit_boot_diagnostics()` after logger setup | + +Earlier PR revisions additionally require these migrations: + +| Superseded intermediate API | Current API | +| ---------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `send_all` returning bare positional outcomes | `start_batch_until` / `next` / `cancel` / `collect`, or fallible `send_all_until` | +| Batch `cutoff` parameter described as a batch deadline | Explicit `observation_cutoff` that stops observation regardless of slot budgets | +| Bare `ContentEncoding::Passthrough` | `ContentEncoding::Passthrough(PassthroughReason)` | +| Codec-specific `max_brotli_decoder_bytes` | Codec-independent `max_decoder_bytes` | +| Admission refusal/fallback without response completion ownership | Decision-owned `ResponseEgressCompletion` transferred through ingress and egress | +| Detached completion-only factory | `DetachedResponseEgressDecision::Send { completion, deadline }` or `Abort` | + +### Dependencies And Toolchain + +- Hard-cut platform SDKs to Fastly `0.13.1` (SDK/shared/sys/logger), Cloudflare `worker 0.8.5`, and + `spin-sdk 7.0.0`, adapting Spin to its WASI HTTP 0.3 response/body/result resource protocol. +- Pin native outbound dependencies (`reqwest 0.13.4`, `url 2.5.8`) and audited decoder graphs, + including `async-compression 0.4.43`, `compression-codecs 0.4.38`, `brotli-decompressor 5.0.1`, + and `flate2 1.1.9`. Decoder charge changes require explicit pin/audit updates, not lockfile-only + assumptions. Spin's SQLite config writer is reverified against Spin 4.1.0. +- Update runtime/tool pins to Spin CLI `4.1.0`, Viceroy `0.21.0`, and Wasmtime `48.0.2`; generated + projects select corresponding SDK generations and applicable platform CLI/Viceroy pins. Root + workspace dependencies use exact SDK pins. Rust `1.95.0`, Node `24.12.0`, and Fastly CLI `15.1.0` + remain the workspace toolchain. + +### Tests, Documentation, And Maintenance + +- Expand native and provider contract coverage for deadlines, decoding, limits, batch ordering, + driver failure/cancellation, admission/fallback precedence, clocks, framing, and resource release. + Add Hyper raw-socket, Spin SDK-resource, Fastly closed-lifecycle, and demo logging regressions. +- Gate native/WASM feature combinations independently, include `test-utils` runtime paths, and + require named nonzero test sentinels. Consolidate adapter clippy into one workflow matrix while + retaining distinct per-target check names, including native Axum. +- Verify generated all-adapter projects and the excluded demo workspace. Fetch both locked + dependency graphs before offline audits; enforce decoder dependency, JSON-map, documentation, + public-rustdoc, and retired-API contracts in CI. +- Update outbound, ingress, egress, and configuration specs/plans, capability matrices, migration + guides, and examples. Replace ordinary hand-built JSON values/fixtures with `serde_json::json!`, + retaining raw input only for syntax, exact-byte, static-input, and serialization-free fallback + requirements. + +### Capability And Evidence Boundaries + +- Axum alone reports native outbound and inbound-read deadline enforcement. Cloudflare outbound + and streamed-upload deadlines remain `BestEffort` pending deployed host-observed cancellation + evidence; Fastly and Spin retain their documented phase-timer/teardown limitations. +- Fastly fanout is dispatch-before-harvest, not guaranteed simultaneous overlap. Synchronous + registration/body drain and bounded handle-selection groups limit completion order, cancellation, + and slot isolation; dynamic backends require provider enablement. A 25 ms dispatch-slack guard can + reject a still-live slot before synchronous cold registration, including heterogeneous hosts and + budgets. Cloudflare header fidelity and authority override remain `BestEffort`; Spin authority + override is rejected because routing and authority cannot be separated through its provider API. +- Response-egress abort, backpressure, completion, and write deadlines remain `BestEffort` on all + adapters. An exactly-once guest lifecycle report does not prove end-client receipt or a finite + provider teardown interval. Configuration-read deadlines also remain `BestEffort` everywhere. +- Raw ingress head limits/framing guarantees, complete outbound resource accounting, and complete + config-read allocation bounds remain `Unsupported`. Provider parsing, materialization, SDK copies, + hidden informational fields/trailers, allocator overhead, and host buffering are not certified by + guest-visible byte, header, chunk, or decoder-policy caps. + +See [the capability guide](docs/guide/capabilities.md) for the exact per-target matrix, accounting +boundaries, canonical platform facts, and evidence required before promoting support. diff --git a/CLAUDE.md b/CLAUDE.md index eb6496510..e66f6a392 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ CI workflows under `.github/workflows/`. ``` crates/ - edgezero-core/ # Core: routing, extractors, middleware, proxy, body, errors + edgezero-core/ # Core: routing, extractors, middleware, outbound HTTP, body, errors edgezero-macros/ # Proc macros: #[action], #[app] edgezero-adapter/ # Adapter registry and traits edgezero-adapter-fastly/ # Fastly Compute bridge (wasm32-wasip1) @@ -134,11 +134,13 @@ impl Middleware for MyMiddleware { } ``` -### Proxy +### Outbound HTTP -Use `ProxyService` with adapter-specific clients (`FastlyProxyClient`, -`CloudflareProxyClient`, `SpinProxyClient`). Keep proxy logic provider-agnostic -in core. +Handlers obtain `HttpClient` from `RequestContext::http_client()`, construct +`OutboundRequest` values, and consume `OutboundResponse` or per-slot +`OutboundSlotResult` outcomes. Keep provider transport logic in adapter-specific +`*OutboundClient` implementations and use the capability matrix for behavioral +differences. ### Logging @@ -157,6 +159,9 @@ in core. - **Async tests** use `futures::executor::block_on` (not Tokio) for WASM compat. - **Minimal changes**: every change should impact as little code as possible. Avoid unnecessary refactoring, docstrings on untouched code, or premature abstractions. +- **JSON construction**: use `serde_json::json!` for ordinary JSON values and fixtures; + serialize typed structs directly. Keep raw JSON for syntax or exact-byte tests, + static-input APIs, and last-resort fallbacks that must avoid serialization. - **Feature gates**: platform-specific code goes behind `fastly`, `cloudflare`, `spin`, or `axum` features. Core stays `default-features = false` for WASM targets. - **No direct `http` crate imports** in application code — use `edgezero_core` re-exports. @@ -168,7 +173,7 @@ Each adapter follows the same structure: - `context.rs` — platform-specific request context - `request.rs` — platform request → core request conversion - `response.rs` — core response → platform response conversion -- `proxy.rs` — platform-specific proxy client +- `outbound.rs` — platform-specific outbound HTTP client - `logger.rs` — platform-specific logging init - `cli.rs` — adapter dispatch behind the `cli` feature: `build` / `deploy` / `serve` (legacy) plus `Adapter::execute` for `auth` (login/logout/status) and dedicated trait methods `provision` (Stage 6 — platform-resource creation) and `push_config_entries` (Stage 7 — `config push` writeback). Self-registers via `#[ctor]` into the `edgezero-adapter` registry. diff --git a/Cargo.lock b/Cargo.lock index 881f58a8c..3380f9292 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,9 +37,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -664,6 +664,7 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" name = "edgezero-adapter" version = "0.1.0" dependencies = [ + "edgezero-core", "serde", "serde_json", "sha2 0.10.9", @@ -677,15 +678,21 @@ name = "edgezero-adapter-axum" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "async-trait", "axum", "bytes", "ctor", "edgezero-adapter", "edgezero-core", + "flate2", "futures", "futures-util", "http", + "http-body", + "hyper", + "hyper-util", + "libc", "log", "redb", "reqwest", @@ -706,6 +713,7 @@ name = "edgezero-adapter-cloudflare" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "async-trait", "brotli", "bytes", @@ -721,6 +729,7 @@ dependencies = [ "toml_edit", "walkdir", "wasm-bindgen-test", + "wasm-streams", "web-sys", "worker", ] @@ -739,6 +748,8 @@ dependencies = [ "edgezero-adapter", "edgezero-core", "fastly", + "fastly-shared", + "fastly-sys", "fern", "flate2", "futures", @@ -760,13 +771,12 @@ name = "edgezero-adapter-spin" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "async-trait", - "brotli", "bytes", "ctor", "edgezero-adapter", "edgezero-core", - "flate2", "futures", "futures-util", "http-body-util", @@ -816,12 +826,19 @@ dependencies = [ name = "edgezero-core" version = "0.1.0" dependencies = [ + "adler2", + "alloc-no-stdlib", + "alloc-stdlib", "anyhow", "async-compression", "async-stream", "async-trait", "brotli", + "brotli-decompressor", "bytes", + "compression-codecs", + "compression-core", + "crc32fast", "edgezero-macros", "flate2", "futures", @@ -830,17 +847,20 @@ dependencies = [ "http-body", "log", "matchit 0.9.2", + "miniz_oxide", "ryu", "serde", "serde_json", "serde_path_to_error", "serde_urlencoded", "sha2 0.10.9", + "simd-adler32", "tempfile", "thiserror 2.0.20", "toml", "tower-service", "tracing", + "url", "validator", "web-time", ] @@ -910,9 +930,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastly" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531e4c3df48350d9f4fc95b4deaf87fd29820336b7926bb84bf460457c2a126b" +checksum = "3c5c07c0c67d0b5826d1cb2311e563a17860b50e2ae89e1c703d6400d904d1e2" dependencies = [ "anyhow", "bytes", @@ -923,7 +943,6 @@ dependencies = [ "fastly-sys", "http", "itertools", - "lazy_static", "mime", "serde", "serde_json", @@ -938,9 +957,9 @@ dependencies = [ [[package]] name = "fastly-macros" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2aef5f9690b04c8890f9a54ddb591b12b9779ec25ee0e572d207106e52e3d8" +checksum = "698025eab5612adab20df48963a5126650893014e8505af02a0af20939ee6a05" dependencies = [ "proc-macro2", "quote", @@ -949,9 +968,9 @@ dependencies = [ [[package]] name = "fastly-shared" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080ad138403159fd366d3e0b14bb49cb0c01dc18c25095bbbd1c85e3338f5413" +checksum = "191c2cf082f5d17f4ca0685cedaa0642ad4ade949fac8d1dbdd62ff756b13500" dependencies = [ "bitflags 1.3.2", "http", @@ -959,9 +978,9 @@ dependencies = [ [[package]] name = "fastly-sys" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de75ef193f6c29c43d667458bede648970715aedd5db2d42c2eba3ffa3ad738b" +checksum = "239c77706cf9110b423a89b6b03b8011729b144390d2a7f641d95ef66b2768b8" dependencies = [ "bitflags 1.3.2", "fastly-shared", @@ -1284,13 +1303,12 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.10.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", - "futures-channel", "futures-core", "http", "http-body", @@ -1580,22 +1598,15 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -1657,10 +1668,11 @@ checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "log-fastly" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51dae5def13a2d557fdb63862d642f8d4641ec3773c036bb14092697b6764013" +checksum = "433713b6191ad3e1d5248db9536f3e2d99a12d7ff998afd272a98d96e6dbc2ed" dependencies = [ + "anyhow", "fastly", "log", "regex", @@ -2522,9 +2534,9 @@ dependencies = [ [[package]] name = "spin-macro" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11e483b94d5bcfac493caf0427fa875063e3e8604d0466a4ab491ec200a42857" +checksum = "ac8a641da857d4e7cada31a853c40180eeb10485a0f2f0282d0a7e5a2a4105d4" dependencies = [ "proc-macro2", "quote", @@ -2533,9 +2545,9 @@ dependencies = [ [[package]] name = "spin-sdk" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd2abac3eb2ee249c2241ab87f7b1287f36172c8cc1ea815c19c85e41ede44d" +checksum = "2b7a3d9a6805ccdb4d404e91ccf7198ac5e486b6199dc9387aeebaa368272112" dependencies = [ "anyhow", "bytes", @@ -2545,7 +2557,7 @@ dependencies = [ "http-body-util", "spin-macro", "thiserror 2.0.20", - "wasip3 0.6.0+wasi-0.3.0-rc-2026-03-15", + "wasip3 0.7.1+wasi-0.3.0", ] [[package]] @@ -3062,9 +3074,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -3095,9 +3107,9 @@ dependencies = [ [[package]] name = "wasip3" -version = "0.6.0+wasi-0.3.0-rc-2026-03-15" +version = "0.7.1+wasi-0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed83456dd6a0b8581998c0365e4651fa2997e5093b49243b7f35391afaa7a3d9" +checksum = "75e9ed91ee5a995b6fe16c89d4511a8c12eb76967be20b57557618e02e781bcc" dependencies = [ "bytes", "http", @@ -3108,9 +3120,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -3121,9 +3133,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -3131,9 +3143,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3141,31 +3153,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.72" +version = "0.3.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74fde991ccdc895cb7fbaa14b137d62af74d9011be67b71c694bfc40edd3119c" +checksum = "45863ef0bef521c12124eb39d9a38513c47db75f22e503c06beacd40afeb35db" dependencies = [ "async-trait", "cast", @@ -3185,20 +3197,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.72" +version = "0.3.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e925354648d2a4d1bf205412e36d520a800280622eef4719678d268e5d40e978" +checksum = "8c89dcab8b516b6b603baca9d550b7282d68fcc7f367e3956cff7ebf406a3f12" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.122" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "684365b586a9a6256c1cc3544eee8680de48d6041142f581776ec7b139622ae9" +checksum = "f37b4f992cebe528ef34964ae69681ac0fe7080071e7298e46008f9d380302af" [[package]] name = "wasm-encoder" @@ -3246,9 +3258,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +checksum = "e7d3be8814f5ba5f074491a469eed3d73c273ffad955f25ed1635efac4b0d269" dependencies = [ "futures-util", "js-sys", @@ -3283,9 +3295,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -3725,9 +3737,9 @@ dependencies = [ [[package]] name = "worker" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d3c60a70414db58e1890f3675d02692adace736657cb66994f220ae3780c90d" +checksum = "2f8adbf6c9ae45b665dee995c5e3a342c2bd7d58a2e8ca5c75b50ce8b1b8bfd9" dependencies = [ "async-trait", "bytes", @@ -3756,9 +3768,9 @@ dependencies = [ [[package]] name = "worker-macros" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60bcb459a67977fcb79698a3123ae58a928b1b24cc3035eaec033dbdfc139438" +checksum = "6d908735d273dd7f9c325a842623f4e5a745e0686187ce465b34dc162ad348df" dependencies = [ "async-trait", "proc-macro2", @@ -3766,16 +3778,15 @@ dependencies = [ "strum", "syn 2.0.119", "wasm-bindgen", - "wasm-bindgen-futures", "wasm-bindgen-macro-support", "worker-sys", ] [[package]] name = "worker-sys" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0e59a8504685d87649b8fda877d95fcc48f8c8177dbd77a4dc8e67f8fc80240" +checksum = "33faa1a8fa6c7eec67b196e008859c44d468a5ad4f991855cdc856f119e0e98f" dependencies = [ "cfg-if", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index 48519d07f..0837012a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,18 +27,26 @@ publish = false version = "0.1.0" [workspace.dependencies] +alloc-no-stdlib = "=2.0.4" +alloc-stdlib = "=0.2.4" +adler2 = "=2.0.1" anyhow = "1" -async-compression = { version = "0.4", features = [ +async-compression = { version = "=0.4.43", default-features = false, features = [ "futures-io", "gzip", "brotli", + "zlib", ] } async-stream = "0.3" async-trait = "0.1" axum = { version = "0.8", default-features = true } -brotli = "8" -bytes = "1" +brotli = "=8.0.4" +brotli-decompressor = "=5.0.1" +bytes = "=1.12.1" chrono = "0.4" +compression-codecs = { version = "=0.4.38", default-features = false, features = ["brotli", "gzip", "zlib"] } +compression-core = "=0.4.32" +crc32fast = "=1.5.0" ctor = "1.0" edgezero-adapter = { path = "crates/edgezero-adapter" } edgezero-adapter-axum = { path = "crates/edgezero-adapter-axum", default-features = false } @@ -47,21 +55,28 @@ edgezero-adapter-fastly = { path = "crates/edgezero-adapter-fastly", default-fea edgezero-adapter-spin = { path = "crates/edgezero-adapter-spin", default-features = false } edgezero-core = { path = "crates/edgezero-core", default-features = false } edgezero-cli = { path = "crates/edgezero-cli", default-features = false } -fastly = "0.12" +fastly = "=0.13.1" +fastly-shared = "=0.13.1" +fastly-sys = "=0.13.1" fern = "0.7" -flate2 = { version = "1", features = ["rust_backend"] } +flate2 = { version = "=1.1.9", default-features = false, features = ["rust_backend"] } futures = { version = "0.3", features = ["std", "executor"] } futures-util = { version = "0.3", features = ["alloc", "io"] } handlebars = "6" -http = "1" +http = "=1.4.2" http-body = "1" http-body-util = "0.1" +hyper = { version = "=1.12.0", features = ["http1", "server"] } +hyper-util = { version = "0.1", features = ["tokio"] } log = "0.4" -log-fastly = "0.12" +libc = "0.2" +miniz_oxide = { version = "=0.8.9", default-features = false, features = ["simd", "with-alloc"] } +log-fastly = "=0.13.1" matchit = "0.9" once_cell = "1" redb = "4.1.0" -reqwest = { version = "0.13", default-features = false, features = ["rustls", "blocking", "json"] } +reqwest = { version = "=0.13.4", default-features = false, features = ["rustls", "blocking", "json"] } +simd-adler32 = { version = "=0.3.9", default-features = false } # `bundled` ships SQLite source so operators don't need a system # `libsqlite3-sys` install. Used by `edgezero-adapter-spin`'s CLI-only # `config push --adapter spin` writer to write into Spin's local KV @@ -70,7 +85,7 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls", "b rusqlite = { version = "0.32", default-features = false, features = ["bundled"] } ryu = "1" serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = "=1.0.150" serde_path_to_error = "0.1" serde_urlencoded = "0.7" sha2 = "0.10" @@ -80,25 +95,25 @@ proc-macro2 = { version = "1", features = ["span-locations"] } quote = "1" syn = { version = "3", features = ["full", "extra-traits", "visit"] } subtle = "2" -# Pinned to the `~6.0` range (allows 6.0.x, blocks 6.1+) so a minor -# bump that touches `key_value::Store::open`'s async signature or the -# wasi-http import surface fails at build time rather than at `spin -# up` (where a runtime mismatch surfaces as opaque WIT linker errors). -spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value", "variables"] } +# The Spin outbound classifier exhaustively matches this SDK's WASI HTTP +# snapshot, so both dependencies move only through an explicit review. +spin-sdk = { version = "=7.0.0", default-features = false, features = ["http", "key-value", "variables"] } tempfile = "3" toml_edit = "0.25" thiserror = "2" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } +tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } trybuild = "1" toml = { version = "1.1" } tower = { version = "0.5", features = ["util"] } tower-layer = "0.3" tower-service = "0.3" tracing = "0.1" +url = "=2.5.8" validator = { version = "0.20", features = ["derive"] } walkdir = { version = "2" } web-time = "1" -worker = { version = "0.8", features = ["http"] } +worker = { version = "=0.8.5", default-features = false, features = ["http"] } +wasm-streams = "=0.6.0" [workspace.lints.clippy] # Same strict gate as the demo workspace. Allow-list mirrors the demo's @@ -135,11 +150,10 @@ separated_literal_suffix = "allow" # decompress_body, and one extra in fastly/request.rs. pub_with_shorthand = "allow" # `module_name_repetitions` was attempted: 39 sites in edgezero-core, -# centred on three concrete blockers that surfaced during the rename: -# 1. `proxy::Request`/`proxy::Response` would collide with the -# `http::Request`/`http::Response` already imported by every -# consumer; the only viable alternative names (`OutboundRequest`, -# `Outbound`) are strictly more verbose than `ProxyRequest`. +# centred on three concrete blockers that surfaced during the API review: +# 1. Outbound request/response types intentionally retain the `Outbound*` +# prefix because they are re-exported beside inbound `http::Request` and +# `http::Response`; shorter names would collide in normal consumers. # 2. `manifest.rs` has 17 `Manifest*` types; consumers in adapters, # cli, demos, scaffold templates, and the macro-generated app # code use these names directly. Stripping the prefix would force @@ -175,4 +189,5 @@ std_instead_of_alloc = "allow" std_instead_of_core = "allow" [workspace.lints.rust] -unsafe_code = "deny" \ No newline at end of file +unsafe_code = "deny" +unexpected_cfgs = "deny" diff --git a/README.md b/README.md index aacee78a9..eafe908c1 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Full documentation is available at **[stackpop.github.io/edgezero](https://stack - [Getting Started](https://stackpop.github.io/edgezero/guide/getting-started) - Project setup and first steps - [Architecture](https://stackpop.github.io/edgezero/guide/architecture) - How EdgeZero works - [Configuration](https://stackpop.github.io/edgezero/guide/configuration) - `edgezero.toml` reference +- [Outbound HTTP](https://stackpop.github.io/edgezero/guide/proxying) - Portable requests, limits, deadlines, and batching +- [Capabilities](https://stackpop.github.io/edgezero/guide/capabilities) - Exact adapter support matrix and deployment caveats - [CLI Reference](https://stackpop.github.io/edgezero/guide/cli-reference) - All CLI commands - [Blob App-Config Migration](https://stackpop.github.io/edgezero/guide/blob-app-config-migration) - Typed `AppConfig` extractor + `config push` / `config diff` workflow diff --git a/TODO.md b/TODO.md index 19bf99e07..83c6cb327 100644 --- a/TODO.md +++ b/TODO.md @@ -7,9 +7,9 @@ High-level backlog and decisions to drive the next milestones. ### High Priority - [ ] Core: `Response::json` behind `serde` feature -- [ ] Fastly proxy: add tests for backend send + header/body mapping +- [x] Fastly outbound HTTP: add tests for backend send + header/body mapping - [ ] Cloudflare streaming: map `Response::with_chunks` to `ReadableStream` with backpressure -- [ ] Core proxy: add async fetch facade (feature `async-client`) and implement Cloudflare proxy async fetch facade (feature `async-client`) and implement Cloudflare proxy +- [x] Core outbound HTTP: add the portable async client and Cloudflare fetch implementation ### Medium Priority @@ -31,13 +31,13 @@ High-level backlog and decisions to drive the next milestones. ## Test Coverage Plan (2025-09-18) -- [ ] Adapters: introduce Fastly/Cloudflare mapping tests (headers, streaming, proxy failure) to catch glue regressions. +- [x] Adapters: introduce Fastly/Cloudflare outbound mapping tests (headers, streaming, transport failure) to catch glue regressions. - [ ] Adapters: assert error-path mapping for Fastly/Cloudflare request conversion and re-enable the ignored Cloudflare response header test. - [ ] CLI: add integration tests for `edgezero new` scaffolding, feature-flag builds, and `dev` fallback app. - [ ] CLI: cover `dev_server`, generator, and template scaffolding flows with tempdir-based integration tests to guard manual HTTP parsing and shell commands. - [ ] CI: verify feature combinations (without `demo-example`, `json`, `form`) compile and run basic smoke tests. - [ ] Macros: add trybuild coverage for `app!` manifest expansion (route/middleware generation and error surfacing). -- [x] Core: unit-test `App::build_app`/`Hooks` wiring and `PathParams::deserialize` edge cases beyond indirect coverage. _(Added targeted unit tests in `crates/edgezero-core/src/app.rs` and `crates/edgezero-core/src/params.rs`.)_ +- [x] Core: unit-test `App::build::`/`Hooks` wiring and `PathParams::deserialize` edge cases beyond indirect coverage. _(Added targeted unit tests in `crates/edgezero-core/src/app.rs` and `crates/edgezero-core/src/params.rs`.)_ - [x] Coverage hygiene: consolidate duplicate router/extractor request-parsing tests and share adapter contract fixtures to reduce redundant maintenance. _(Router duplicates trimmed; extractor suite now owns request parsing checks.)_ - [x] Router: add regression cases for overlapping routes, prefix/nest behaviour, and BodyMode error handling (`Streaming` vs `Buffered`). - [x] Controller: cover negative paths (`State` missing, `ValidatedJson` errors) and assert returned status/body. @@ -49,14 +49,14 @@ High-level backlog and decisions to drive the next milestones. - [x] Map `edgezero-core::Response` -> Fastly Response (headers/body) - [x] Handle binary/text bodies and content-length - [x] Example: runnable Fastly demo (`examples/app-demo/crates/app-demo-adapter-fastly`) with wasm32-wasip1 target and explicit logger init - - [x] Streaming: native streaming on Fastly via `stream_to_client` (wasm32-wasip1), buffered fallback elsewhere -- [ ] Cloudflare Workers adapter MVP + - [x] Streaming: adapter-owned lazy response delivery on Axum, Cloudflare, Fastly, and Spin +- [x] Cloudflare Workers adapter MVP - [x] Basic mapping: Workers Request -> EdgeZero Request - - [x] Basic mapping: EdgeZero Response -> Workers Response (buffered bodies) - - [ ] Streaming behavior (ReadableStream) and backpressure + - [x] Basic mapping: EdgeZero Response -> Workers Response + - [x] Adapter-owned streaming writer and backpressure propagation - [x] Example: deploy sample with `wrangler` (`examples/app-demo/crates/app-demo-adapter-cloudflare`) - [ ] CLI - - [x] `edgezero new `: scaffold an app (lib with `build_app()`) + - [x] `edgezero new `: scaffold an app (lib consumed by `App::build::`) - [x] `edgezero build --adapter fastly|cloudflare` - [x] `edgezero deploy --adapter fastly|cloudflare` - [ ] `edgezero dev` improvements: better HTTP parsing, hot-reload @@ -84,7 +84,7 @@ High-level backlog and decisions to drive the next milestones. ## Roadmap (2025-09-24) -- [x] Adapter stability: formalise the provider adapter contract (request/response mapping, streaming guarantees, proxy hooks) and capture it in shared docs + integration tests so new targets plug in safely. (`docs/adapter-contract.md`, Fastly contract tests under `crates/edgezero-adapter-fastly/tests/contract.rs`, Cloudflare contract tests under `crates/edgezero-adapter-cloudflare/tests/contract.rs`, manifest schema in `docs/manifest.md`) +- [x] Adapter stability: formalise the provider adapter contract (request/response mapping, streaming guarantees, outbound hooks) and capture it in shared docs + integration tests so new targets plug in safely. (`docs/adapter-contract.md`, Fastly contract tests under `crates/edgezero-adapter-fastly/tests/contract.rs`, Cloudflare contract tests under `crates/edgezero-adapter-cloudflare/tests/contract.rs`, manifest schema in `docs/manifest.md`) - [ ] Provider additions: prototype a third adapter (e.g. AWS Lambda@Edge or Vercel Edge Functions) using the stabilized adapter API to validate cross-provider abstractions. - [x] Manifest ergonomics: design an `edgezero.toml` schema that mirrors Spin’s manifest convenience (route triggers, env/secrets, build targets) while remaining provider-agnostic; update CLI scaffolding accordingly. (`crates/edgezero-cli/src/manifest.rs`, templates in `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs`, doc `docs/manifest.md`, app-demo manifest `examples/app-demo/edgezero.toml`) - [ ] Tooling parity: extend `edgezero-cli` with template/plugin style commands (similar to Spin templates) to streamline new app scaffolds and provider-specific wiring. @@ -112,9 +112,9 @@ High-level backlog and decisions to drive the next milestones. ### Familiarization Summary -- EdgeZero centres around `edgezero-core`, which provides provider-neutral HTTP primitives, routing, middleware, logging, and proxy abstractions; adapters reuse these types to stay DRY. +- EdgeZero centres around `edgezero-core`, which provides provider-neutral HTTP primitives, routing, middleware, logging, and outbound abstractions; adapters reuse these types to stay DRY. - Controller ergonomics live in `edgezero-controller` plus `edgezero-macros`, offering `#[action]` functions that extract typed inputs and return `Responder`s. -- Provider adapters (`edgezero-adapter-fastly`, `edgezero-adapter-cloudflare`) are feature-gated; each exposes `handle` plus logging/proxy helpers while delegating behaviour to the core crate. +- Provider adapters (`edgezero-adapter-fastly`, `edgezero-adapter-cloudflare`) are feature-gated; each exposes `handle` plus logging/outbound helpers while delegating behavior to the core crate. - Supporting crates include `edgezero-std` for stdout logging, `edgezero-cli` for dev server + scaffolding, and demo workspaces under `examples/app-demo` to validate provider flows. - Workspace `Cargo.toml` keeps default members lean (core only) to support offline builds; additional crates are opt-in via features when targeting specific adapters. @@ -267,7 +267,7 @@ High-level backlog and decisions to drive the next milestones. - [ ] Update the Fastly adapter to compile against `fastly` 0.11 APIs (request building, async streaming, response conversion). - [ ] Adjust logging helper to the new log-fastly builder API. -- [ ] Ensure proxy tests/builds pass for streaming + compression paths. +- [x] Ensure outbound tests/builds pass for streaming + compression paths. - [ ] Verify the app demos compile for `wasm32-wasip1` with the updated SDK. ## Review (2025-09-19 01:28 UTC) @@ -311,7 +311,7 @@ High-level backlog and decisions to drive the next milestones. ## Review (2025-09-19 02:35 UTC) - Temporary stopgap: adapter builds against Fastly 0.11 by buffering request/response bodies and wiring a new logging helper; wasm demo (`cargo build -p app-demo-adapter-fastly --target wasm32-wasip1`) and `cargo test` now succeed. -- Regression: streaming proxy behaviour (and streaming decompression) is currently disabled because bodies are buffered; follow-up work is required to restore async streaming under the new SDK. +- Superseded by the 2026 response-egress hard cut: every adapter now preserves lazy downstream response production without a 16 MiB conversion fallback. ## Review (2025-09-19 07:35 UTC) @@ -505,14 +505,14 @@ High-level backlog and decisions to drive the next milestones. ## Review (2026-01-27 00:50:51 UTC) -- Summary: Aligned guide content with current EdgeZero APIs (App::build_app, adapter entrypoints, middleware signature, proxy clients), corrected routing/streaming/handlers/CLI details, and refreshed manifest/logging docs; added the missing CLI/dev features list to the roadmap. +- Summary: Aligned guide content with the then-current EdgeZero APIs, corrected routing/streaming/handlers/CLI details, and refreshed manifest/logging docs; this was superseded by the outbound HTTP migration. - Assumptions: Docs now reflect current behavior for Fastly/Cloudflare/Axum adapters and the CLI; future features are captured explicitly in the roadmap rather than implied in guides. - Outstanding: None (docs-only updates). ## Review (2026-01-27 01:02:05 UTC) -- Summary: Condensed the proxying guide into a single end-to-end example that uses adapter proxy handles, and added short logging-status callouts to the Fastly, Cloudflare, and Axum adapter docs. -- Assumptions: The proxy handle approach is the preferred public pattern; adapter logging notes should stay concise and match current defaults. +- Summary: Condensed the upstream-forwarding guide into one example and added logging-status callouts; the client example was superseded by `HttpClient`. +- Assumptions: Adapter logging notes should stay concise and match current defaults. - Outstanding: None (docs-only updates). ## Review (2026-01-27 01:05:18 UTC) @@ -589,14 +589,14 @@ High-level backlog and decisions to drive the next milestones. - [x] Add the key doc/CLI gaps found during the review to the roadmap page. - [x] Add an explicit roadmap item for Spin support (define scope at the doc level). -## Codex Plan (2026-01-27 - Proxying Snippet + Adapter Logging Callout) +## Codex Plan (2026-01-27 - Upstream Snippet + Adapter Logging Callout) -- [x] Condense proxying guide into a single end-to-end example using adapter proxy handles. +- [x] Condense the upstream guide into a single end-to-end example (later migrated to `HttpClient`). - [x] Add a short logging status callout to the adapter docs (Axum/Cloudflare/Fastly). ## Codex Plan (2026-01-27 - Docs Alignment + Roadmap Additions) -- [x] Update guides to reflect current APIs (App::build_app, adapter entrypoints, middleware signature, proxy client usage). +- [x] Update guides to reflect current APIs (`App::build::`, adapter entrypoints, middleware signature, outbound client usage). - [x] Correct routing, streaming, handlers, and CLI reference docs to match current behavior. - [x] Refresh configuration docs to align with manifest schema and loader APIs. - [x] Add missing-feature backlog (list-adapters, exit codes, manifest search-up, RUST_LOG, hot reload) to the roadmap section. diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml index 9979e39a5..ea2f57bdd 100644 --- a/crates/edgezero-adapter-axum/Cargo.toml +++ b/crates/edgezero-adapter-axum/Cargo.toml @@ -13,10 +13,14 @@ workspace = true [features] default = ["axum"] axum = [ + "dep:async-stream", "dep:axum", "dep:tokio", "dep:tower", "dep:futures-util", + "dep:http-body", + "dep:hyper", + "dep:hyper-util", "dep:reqwest", "dep:redb", ] @@ -27,6 +31,7 @@ cli = [ "dep:toml", "dep:walkdir", ] +test-utils = [] [dependencies] edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = [ @@ -35,15 +40,20 @@ edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = [ edgezero-core = { path = "../edgezero-core" } anyhow = { workspace = true } async-trait = { workspace = true } +async-stream = { workspace = true, optional = true } axum = { workspace = true, optional = true } bytes = { workspace = true } ctor = { workspace = true, optional = true } futures = { workspace = true } futures-util = { workspace = true, optional = true } http = { workspace = true } +http-body = { workspace = true, optional = true } +hyper = { workspace = true, optional = true } +hyper-util = { workspace = true, optional = true } log = { workspace = true } redb = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } +serde = { workspace = true } serde_json = { workspace = true } simple_logger = { workspace = true } thiserror = { workspace = true } @@ -53,10 +63,17 @@ tower = { workspace = true, optional = true } tracing = { workspace = true } walkdir = { workspace = true, optional = true } +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } + [dev-dependencies] async-trait = { workspace = true } axum = { workspace = true, features = ["macros"] } edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } +flate2 = { workspace = true } +http-body = { workspace = true } +hyper = { workspace = true } +hyper-util = { workspace = true } serde = { workspace = true } tempfile = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } +tokio = { workspace = true, features = ["io-util", "macros", "net", "rt", "rt-multi-thread", "test-util"] } diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index af669cce9..da9d14ce1 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -11,8 +11,8 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, }; use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, - TypedSecretEntry, register_adapter, + Adapter, AdapterAction, AdapterExecutionTarget, AdapterPushContext, ProvisionStores, + ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, register_adapter, }; use edgezero_adapter::scaffold::{ AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, @@ -20,6 +20,7 @@ use edgezero_adapter::scaffold::{ }; use edgezero_core::addr; use edgezero_core::manifest::ManifestLoader; +use edgezero_core::{Capability, CapabilitySupport, PlatformMetadata}; use toml::Value; use walkdir::WalkDir; @@ -135,6 +136,35 @@ struct EdgezeroAxumConfig { reason = "axum has no validate_app_config_keys / validate_adapter_manifest requirements; those two trait defaults are intentionally inherited. `validate_typed_secrets` IS overridden below (each `#[secret]` value must map to one `EDGEZERO__SECRETS__` env var). `read_config_entry` delegates to `read_config_entry_local` (axum is local-only). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." )] impl Adapter for AxumCliAdapter { + fn capability(&self, capability: Capability) -> CapabilitySupport { + match capability { + Capability::ConfigReadDeadlines + | Capability::ResponseEgressAbort + | Capability::ResponseEgressBackpressure + | Capability::ResponseEgressCompletion + | Capability::ResponseWriteDeadlines => CapabilitySupport::BestEffort, + Capability::InboundReadDeadlines + | Capability::IngressAdmission + | Capability::IngressAdmissionAbort + | Capability::OutboundAuthorityOverride + | Capability::OutboundBatchCancellation + | Capability::OutboundBatchCompletionOrder + | Capability::OutboundBatchSlotIsolation + | Capability::OutboundCacheBypass + | Capability::OutboundDeadlines + | Capability::OutboundFlexiblePhaseBudget + | Capability::OutboundHeaderFidelity + | Capability::OutboundHttp + | Capability::LazyStreamedResponsePassthrough + | Capability::StreamedUploadDeadlines => CapabilitySupport::Native, + Capability::ConfigReadAllocationBounds + | Capability::OutboundCompleteResourceAccounting + | Capability::RawIngressFramingValidation + | Capability::RawIngressHeadLimits + | _ => CapabilitySupport::Unsupported, + } + } + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { // The axum adapter is the in-process native dev server — @@ -160,10 +190,37 @@ impl Adapter for AxumCliAdapter { } } + fn execute_target( + &self, + action: AdapterAction, + target: &AdapterExecutionTarget, + args: &[String], + ) -> Result<(), String> { + match action { + AdapterAction::Build => build_target(target, args), + AdapterAction::Deploy => deploy(args), + AdapterAction::Serve => serve_target(target, args), + AdapterAction::AuthLogin + | AdapterAction::AuthLogout + | AdapterAction::AuthStatus + | AdapterAction::DeployStaging + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback + | _ => Err(format!( + "axum adapter does not support pinned target action {action:?}" + )), + } + } + fn name(&self) -> &'static str { "axum" } + fn platform_metadata(&self) -> PlatformMetadata { + crate::AXUM_PLATFORM + } + fn provision( &self, _manifest_root: &Path, @@ -395,12 +452,35 @@ fn register_ctor() { fn build(extra_args: &[String]) -> Result<(), String> { let project = locate_project()?; - run_cargo(&project, "build", extra_args) + run_cargo(&project, "build", extra_args, &[]) +} + +fn build_target(target: &AdapterExecutionTarget, extra_args: &[String]) -> Result<(), String> { + let project = read_axum_project(&target_manifest(target, "axum.toml")?)?; + run_cargo(&project, "build", extra_args, target.environment_defaults()) } fn serve(extra_args: &[String]) -> Result<(), String> { let project = locate_project()?; - run_cargo(&project, "run", extra_args) + run_cargo(&project, "run", extra_args, &[]) +} + +fn serve_target(target: &AdapterExecutionTarget, extra_args: &[String]) -> Result<(), String> { + let project = read_axum_project(&target_manifest(target, "axum.toml")?)?; + run_cargo(&project, "run", extra_args, target.environment_defaults()) +} + +fn target_manifest(target: &AdapterExecutionTarget, name: &str) -> Result { + let manifest = target + .platform_manifest() + .map_or_else(|| target.app_root().join(name), Path::to_path_buf); + if !manifest.is_file() { + return Err(format!( + "pinned axum manifest {} is not a regular file", + manifest.display() + )); + } + Ok(manifest) } fn deploy(_extra_args: &[String]) -> Result<(), String> { @@ -413,7 +493,12 @@ fn locate_project() -> Result { read_axum_project(&manifest) } -fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> Result<(), String> { +fn cargo_command( + project: &AxumProject, + subcommand: &str, + extra_args: &[String], + defaults: &[(String, String)], +) -> Result { let resolution = resolve_subprocess_addr(project)?; for warning in &resolution.warnings { log::warn!("[edgezero] {warning}"); @@ -442,6 +527,17 @@ fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> // no-op for the child process. command.env("EDGEZERO__ADAPTER__HOST", bind_addr.ip().to_string()); command.env("EDGEZERO__ADAPTER__PORT", bind_addr.port().to_string()); + command.envs(defaults.iter().map(|(key, value)| (key, value))); + Ok(command) +} + +fn run_cargo( + project: &AxumProject, + subcommand: &str, + extra_args: &[String], + defaults: &[(String, String)], +) -> Result<(), String> { + let mut command = cargo_command(project, subcommand, extra_args, defaults)?; let status = command .status() .map_err(|err| format!("failed to run cargo {subcommand}: {err}"))?; @@ -719,6 +815,154 @@ mod tests { use std::net::Ipv6Addr; use tempfile::tempdir; + #[test] + fn cargo_commands_preserve_ingress_defaults_and_bind_precedence() { + let dir = tempdir().unwrap(); + let project = AxumProject { + addr: SocketAddr::from(([127, 0, 0, 1], 8787)), + axum_host: None, + axum_manifest: dir.path().join("axum.toml"), + axum_port: None, + cargo_manifest: dir.path().join("Cargo.toml"), + crate_dir: dir.path().to_path_buf(), + crate_name: "fixture".to_owned(), + env_host: None, + env_port: None, + }; + let defaults = vec![ + ( + "EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS".to_owned(), + "3".to_owned(), + ), + ("EDGEZERO__ADAPTER__PORT".to_owned(), "9090".to_owned()), + ]; + for subcommand in ["build", "run"] { + let command = cargo_command(&project, subcommand, &[], &defaults).unwrap(); + assert!(command.get_envs().any(|(key, value)| key + == "EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS" + && value.is_some_and(|configured| configured == "3"))); + assert!( + command + .get_envs() + .any(|(key, value)| key == "EDGEZERO__ADAPTER__PORT" + && value.is_some_and(|configured| configured == "9090")) + ); + } + } + + #[test] + fn adapter_capability_matrix_matches_contracts() { + let expected = [ + ( + Capability::ConfigReadAllocationBounds, + CapabilitySupport::Unsupported, + ), + ( + Capability::ConfigReadDeadlines, + CapabilitySupport::BestEffort, + ), + (Capability::InboundReadDeadlines, CapabilitySupport::Native), + (Capability::IngressAdmission, CapabilitySupport::Native), + (Capability::IngressAdmissionAbort, CapabilitySupport::Native), + ( + Capability::RawIngressFramingValidation, + CapabilitySupport::Unsupported, + ), + ( + Capability::RawIngressHeadLimits, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressAbort, + CapabilitySupport::BestEffort, + ), + ( + Capability::ResponseEgressBackpressure, + CapabilitySupport::BestEffort, + ), + ( + Capability::ResponseEgressCompletion, + CapabilitySupport::BestEffort, + ), + ( + Capability::ResponseWriteDeadlines, + CapabilitySupport::BestEffort, + ), + (Capability::OutboundHttp, CapabilitySupport::Native), + ( + Capability::OutboundCompleteResourceAccounting, + CapabilitySupport::Unsupported, + ), + ( + Capability::OutboundHeaderFidelity, + CapabilitySupport::Native, + ), + (Capability::OutboundDeadlines, CapabilitySupport::Native), + ( + Capability::OutboundFlexiblePhaseBudget, + CapabilitySupport::Native, + ), + ( + Capability::OutboundAuthorityOverride, + CapabilitySupport::Native, + ), + ( + Capability::OutboundBatchCancellation, + CapabilitySupport::Native, + ), + ( + Capability::OutboundBatchCompletionOrder, + CapabilitySupport::Native, + ), + ( + Capability::OutboundBatchSlotIsolation, + CapabilitySupport::Native, + ), + (Capability::OutboundCacheBypass, CapabilitySupport::Native), + ( + Capability::StreamedUploadDeadlines, + CapabilitySupport::Native, + ), + ( + Capability::LazyStreamedResponsePassthrough, + CapabilitySupport::Native, + ), + ]; + + for (capability, support) in expected { + assert_eq!( + AXUM_ADAPTER.capability(capability), + support, + "{capability:?}" + ); + } + } + + #[test] + fn adapter_platform_metadata_matches_runtime_metadata() { + let metadata = AXUM_ADAPTER.platform_metadata(); + + assert_eq!(metadata, crate::AXUM_PLATFORM); + assert_eq!( + metadata.memory_ceiling(), + edgezero_core::PlatformFact::unknown( + edgezero_core::PlatformUnknownReason::OperatorConfigured + ) + ); + assert_eq!( + metadata.inbound_request_population_bound(), + edgezero_core::PlatformFact::unknown( + edgezero_core::PlatformUnknownReason::OperatorConfigured + ) + ); + assert_eq!( + metadata.host_ingress_memory_accounting(), + edgezero_core::PlatformFact::unknown( + edgezero_core::PlatformUnknownReason::OperatorConfigured + ) + ); + } + #[test] fn read_axum_project_loads_defaults() { let dir = tempdir().unwrap(); @@ -1338,7 +1582,7 @@ mod tests { fs::create_dir_all(&local_dir).expect("create dir"); fs::write( local_dir.join("local-config-app_config.json"), - r#"{"other_key": "value"}"#, + serde_json::json!({"other_key": "value"}).to_string(), ) .expect("write"); let result = AxumCliAdapter @@ -1364,7 +1608,7 @@ mod tests { fs::create_dir_all(&local_dir).expect("create dir"); fs::write( local_dir.join("local-config-app_config.json"), - r#"{"greeting": "hello-axum"}"#, + serde_json::json!({"greeting": "hello-axum"}).to_string(), ) .expect("write"); let result = AxumCliAdapter @@ -1392,7 +1636,7 @@ mod tests { fs::create_dir_all(&local_dir).expect("create dir"); fs::write( local_dir.join("local-config-app_config.json"), - r#"{"greeting": "hello-axum"}"#, + serde_json::json!({"greeting": "hello-axum"}).to_string(), ) .expect("write"); let store = ResolvedStoreId::from_logical("app_config"); diff --git a/crates/edgezero-adapter-axum/src/config_snapshot.rs b/crates/edgezero-adapter-axum/src/config_snapshot.rs new file mode 100644 index 000000000..7270fae5a --- /dev/null +++ b/crates/edgezero-adapter-axum/src/config_snapshot.rs @@ -0,0 +1,351 @@ +//! Flat-string snapshots: no intermediate JSON tree or per-read payload copies. + +use std::fmt; +use std::io::Read; +use std::mem::size_of; + +use edgezero_core::ConfigValue; +use edgezero_core::config_store::ConfigStoreError; +use serde::Deserializer; +use serde::de::{self, DeserializeSeed, Error as _, MapAccess, Visitor}; + +use crate::config_store_limits::ConfigStoreLimits; + +pub(crate) struct Snapshot { + entries: Vec<(String, ConfigValue)>, + resident_bytes: usize, +} + +struct SnapshotSeed<'snapshot> { + existing: usize, + failure: &'snapshot mut Option, + limits: ConfigStoreLimits, + snapshot: &'snapshot mut Snapshot, +} + +struct StringSeed<'failure> { + failure: &'failure mut Option, + max_bytes: usize, +} + +impl Snapshot { + pub(crate) fn empty( + limits: ConfigStoreLimits, + existing: usize, + ) -> Result { + let index_bytes = limits + .max_entries() + .checked_mul(size_of::<(String, ConfigValue)>()) + .ok_or(ConfigStoreError::ValueTooLarge)?; + check_charge(limits, existing, index_bytes)?; + let mut entries = Vec::new(); + entries + .try_reserve_exact(limits.max_entries()) + .map_err(|_allocation| { + ConfigStoreError::internal(anyhow::anyhow!("config snapshot allocation failed")) + })?; + Ok(Self { + resident_bytes: entries + .capacity() + .checked_mul(size_of::<(String, ConfigValue)>()) + .ok_or(ConfigStoreError::ValueTooLarge)?, + entries, + }) + } + + fn finish(&mut self) -> Result<(), ConfigStoreError> { + self.entries + .sort_unstable_by(|(left, _value), (right, _other)| left.cmp(right)); + if self.entries.windows(2).any(|pair| { + pair.first() + .zip(pair.get(1)) + .is_some_and(|(left, right)| left.0 == right.0) + }) { + return Err(ConfigStoreError::unavailable( + "duplicate config snapshot key", + )); + } + Ok(()) + } + + pub(crate) fn from_entries( + entries: E, + limits: ConfigStoreLimits, + ) -> Result + where + E: IntoIterator, + { + let mut snapshot = Self::empty(limits, 0)?; + for (key, value) in entries { + snapshot.insert(&key, &value, limits, 0)?; + } + snapshot.finish()?; + Ok(snapshot) + } + + pub(crate) fn get(&self, key: &str) -> Option<&ConfigValue> { + self.entries + .binary_search_by(|(stored, _value)| stored.as_str().cmp(key)) + .ok() + .and_then(|index| self.entries.get(index)) + .map(|(_key, value)| value) + } + + fn insert( + &mut self, + key: &str, + value: &str, + limits: ConfigStoreLimits, + existing: usize, + ) -> Result<(), ConfigStoreError> { + if self.entries.len() >= limits.max_entries() + || key.len() > limits.max_key_bytes() + || value.len() > limits.max_value_bytes() + { + return Err(ConfigStoreError::ValueTooLarge); + } + // The Vec index was reserved already. Strings have exact requested size; + // Arc has two reference counts in addition to the UTF-8 payload. + let charge = self + .resident_bytes + .checked_add(key.len()) + .and_then(|bytes| bytes.checked_add(value.len())) + .and_then(|bytes| { + bytes.checked_add(size_of::().saturating_mul(3).saturating_sub(1)) + }) + .ok_or(ConfigStoreError::ValueTooLarge)?; + check_charge(limits, existing, charge)?; + self.entries + .push((key.to_owned(), ConfigValue::from(value))); + self.resident_bytes = charge; + Ok(()) + } + + pub(crate) fn load( + reader: &mut impl Read, + limits: ConfigStoreLimits, + existing: usize, + ) -> Result { + let mut snapshot = Self::empty(limits, existing)?; + let raw = read_capped(reader, limits.max_file_bytes())?; + // Reject non-object roots before Serde can format their contents into + // an expanded type-error diagnostic outside the decoder scratch budget. + if raw + .iter() + .copied() + .find(|byte| !matches!(byte, b' ' | b'\n' | b'\r' | b'\t')) + != Some(b'{') + { + return Err(ConfigStoreError::unavailable( + "config snapshot requires an object", + )); + } + let mut decoder = serde_json::Deserializer::from_slice(&raw); + let mut failure = None; + let result = SnapshotSeed { + snapshot: &mut snapshot, + limits, + existing, + failure: &mut failure, + } + .deserialize(&mut decoder) + .and_then(|()| decoder.end()); + if result.is_err() { + return Err( + failure.unwrap_or_else(|| ConfigStoreError::unavailable("invalid config snapshot")) + ); + } + snapshot.finish()?; + Ok(snapshot) + } + + pub(crate) const fn resident_bytes(&self) -> usize { + self.resident_bytes + } +} + +impl<'de> DeserializeSeed<'de> for SnapshotSeed<'_> { + type Value = (); + fn deserialize>(self, deserializer: D) -> Result<(), D::Error> { + deserializer.deserialize_map(self) + } +} + +#[expect( + clippy::missing_trait_methods, + reason = "all non-map visitor methods intentionally retain Serde's type rejection" +)] +impl<'de> Visitor<'de> for SnapshotSeed<'_> { + type Value = (); + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a flat string map") + } + fn visit_map>(self, mut map: A) -> Result<(), A::Error> { + while let Some(key) = map.next_key_seed(StringSeed { + max_bytes: self.limits.max_key_bytes(), + failure: self.failure, + })? { + if self.snapshot.entries.len() >= self.limits.max_entries() { + *self.failure = Some(ConfigStoreError::ValueTooLarge); + return Err(A::Error::custom("snapshot limit")); + } + let value = map.next_value_seed(StringSeed { + max_bytes: self.limits.max_value_bytes(), + failure: self.failure, + })?; + if let Err(error) = self + .snapshot + .insert(&key, &value, self.limits, self.existing) + { + *self.failure = Some(error); + return Err(A::Error::custom("snapshot limit")); + } + } + Ok(()) + } +} + +impl<'de> DeserializeSeed<'de> for StringSeed<'_> { + type Value = String; + fn deserialize>(self, deserializer: D) -> Result { + deserializer.deserialize_str(self) + } +} + +#[expect( + clippy::missing_trait_methods, + reason = "Serde's borrowed string method forwards to the bounded visit_str; non-strings are rejected by default" +)] +impl Visitor<'_> for StringSeed<'_> { + type Value = String; + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a bounded UTF-8 string") + } + fn visit_str(self, v: &str) -> Result { + if v.len() > self.max_bytes { + *self.failure = Some(ConfigStoreError::ValueTooLarge); + return Err(E::custom("snapshot string limit")); + } + Ok(v.to_owned()) + } +} + +fn check_charge( + limits: ConfigStoreLimits, + existing: usize, + additional: usize, +) -> Result<(), ConfigStoreError> { + let resident = existing + .checked_add(additional) + .ok_or(ConfigStoreError::ValueTooLarge)?; + if resident > limits.max_resident_bytes() + || resident.saturating_add(limits.staging_bytes()) > limits.max_startup_bytes() + { + return Err(ConfigStoreError::ValueTooLarge); + } + Ok(()) +} + +fn read_capped(reader: &mut impl Read, max_bytes: usize) -> Result, ConfigStoreError> { + let mut raw = Vec::new(); + let mut chunk = [0_u8; 4096]; + loop { + let remaining = max_bytes.saturating_sub(raw.len()); + let read_len = chunk.len().min(remaining.saturating_add(1)); + let read = reader + .read( + chunk + .get_mut(..read_len) + .ok_or(ConfigStoreError::ValueTooLarge)?, + ) + .map_err(|_io| ConfigStoreError::unavailable("config snapshot read failed"))?; + if read == 0 { + return Ok(raw); + } + if read > remaining { + return Err(ConfigStoreError::ValueTooLarge); + } + raw.try_reserve_exact(read).map_err(|_allocation| { + ConfigStoreError::internal(anyhow::anyhow!("config snapshot allocation failed")) + })?; + raw.extend_from_slice(chunk.get(..read).ok_or(ConfigStoreError::ValueTooLarge)?); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn limits(file: usize, entries: usize, key: usize, value: usize) -> ConfigStoreLimits { + ConfigStoreLimits::new(file, entries, key, value, 8192, 0x8000).expect("limits") + } + + #[test] + fn file_growth_is_capped_by_reads_not_metadata() { + let mut reader = Cursor::new(vec![b' '; 9]); + assert!(matches!( + read_capped(&mut reader, 8), + Err(ConfigStoreError::ValueTooLarge) + )); + assert_eq!(reader.position(), 9); + assert_eq!( + read_capped(&mut Cursor::new(vec![b' '; 8]), 8).expect("exact"), + vec![b' '; 8] + ); + } + + #[test] + fn malformed_root_strings_are_rejected_before_diagnostic_expansion() { + let raw = serde_json::to_vec(&serde_json::json!("\u{0001}".repeat(8192))).expect("fixture"); + let policy = ConfigStoreLimits::new( + raw.len(), + 1, + 1, + 1, + 4096, + raw.len().saturating_mul(5).saturating_add(4096), + ) + .expect("policy"); + let error = Snapshot::load(&mut raw.as_slice(), policy, 0) + .err() + .expect("non-object"); + assert_eq!( + error.to_string(), + ConfigStoreError::unavailable("config snapshot requires an object").to_string() + ); + } + + #[test] + fn escaped_values_and_entry_limits_are_enforced() { + let raw = serde_json::to_vec(&serde_json::json!({"a": "\n\n\n"})).expect("fixture"); + let _snapshot = Snapshot::load(&mut raw.as_slice(), limits(128, 1, 1, 3), 0) + .expect("exact decoded cap"); + assert!(matches!( + Snapshot::load(&mut raw.as_slice(), limits(128, 1, 1, 2), 0), + Err(ConfigStoreError::ValueTooLarge) + )); + let many = serde_json::to_vec(&serde_json::json!({"a": "a", "b": "b"})).expect("fixture"); + assert!(matches!( + Snapshot::load(&mut many.as_slice(), limits(128, 1, 1, 3), 0), + Err(ConfigStoreError::ValueTooLarge) + )); + } + + #[test] + fn all_store_residency_is_reserved_before_loading() { + let limits = limits(128, 1, 1, 3); + let raw = serde_json::to_vec(&serde_json::json!({"a": "abc"})).expect("fixture"); + let first = Snapshot::load(&mut raw.as_slice(), limits, 0).expect("snapshot"); + assert!(matches!( + Snapshot::load( + &mut raw.as_slice(), + limits, + limits.max_resident_bytes() - first.resident_bytes() + 1 + ), + Err(ConfigStoreError::ValueTooLarge) + )); + let _recovered = Snapshot::load(&mut raw.as_slice(), limits, 0) + .expect("failed load has no retained charge"); + } +} diff --git a/crates/edgezero-adapter-axum/src/config_store.rs b/crates/edgezero-adapter-axum/src/config_store.rs index 19b7ddfd7..41e7e9d08 100644 --- a/crates/edgezero-adapter-axum/src/config_store.rs +++ b/crates/edgezero-adapter-axum/src/config_store.rs @@ -12,31 +12,33 @@ //! every key). This keeps `edgezero serve --adapter axum` permissive when //! the project hasn't seeded any local config yet. -use std::collections::HashMap; use std::env; use std::fs; use std::io::ErrorKind; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt as _; use std::path::{Path, PathBuf}; +#[cfg(test)] +use std::sync::atomic::{AtomicUsize, Ordering}; use async_trait::async_trait; use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; +use edgezero_core::{BoundedStoreRead, Deadline, MonotonicClock}; + +use crate::config_snapshot::Snapshot; +use crate::config_store_limits::ConfigStoreLimits; /// Local-file config store used by the Axum dev server. /// -/// Construction is fallible only when the backing file is present but -/// malformed JSON — a missing file is a documented "no values seeded yet" -/// state, not an error. +/// Construction validates finite allocation budgets and the flat-string shape. +/// A missing file is an empty declared snapshot, not an absent binding. pub struct AxumConfigStore { - data: HashMap, + data: Snapshot, + #[cfg(test)] + unbounded_get_calls: AtomicUsize, } impl AxumConfigStore { - fn empty() -> Self { - Self { - data: HashMap::new(), - } - } - /// Open the local-file config store for a given logical id. /// /// Reads `.edgezero/local-config-.json` if present and parses it @@ -47,22 +49,29 @@ impl AxumConfigStore { /// /// # Errors /// Returns [`ConfigStoreError::Unavailable`] when the backing file - /// exists but cannot be read or parsed. + /// exists but cannot be read or parsed, and [`ConfigStoreError::ValueTooLarge`] + /// for file, entry, string or allocation-budget violations. #[inline] - pub fn from_local_file(id: &str) -> Result { - Self::from_path(&Self::local_path(id)) + pub fn from_local_file(id: &str, limits: ConfigStoreLimits) -> Result { + Self::from_path(&Self::local_path(id), limits) } /// Build a store from an explicit `{key -> value}` map. Intended for /// tests and for callers that already have parsed config in memory. + /// Caller-owned input allocations are outside the snapshot's allocation charge. + /// + /// # Errors + /// Returns a typed size error for allocation limits, or unavailable for duplicates. #[inline] - pub fn from_map(entries: E) -> Self + pub fn from_map(entries: E, limits: ConfigStoreLimits) -> Result where E: IntoIterator, { - Self { - data: entries.into_iter().collect(), - } + Ok(Self { + data: Snapshot::from_entries(entries, limits)?, + #[cfg(test)] + unbounded_get_calls: AtomicUsize::new(0), + }) } /// Open the local-file config store at an explicit path @@ -94,28 +103,50 @@ impl AxumConfigStore { /// /// # Errors /// Returns [`ConfigStoreError::Unavailable`] when the file - /// exists but cannot be read or parsed. + /// exists but cannot be read or parsed, and [`ConfigStoreError::ValueTooLarge`] + /// for file, entry, string or allocation-budget violations. #[inline] - pub fn from_path(path: &Path) -> Result { - let raw = match fs::read_to_string(path) { - Ok(raw) => raw, - Err(err) if err.kind() == ErrorKind::NotFound => { - return Ok(Self::empty()); + pub fn from_path(path: &Path, limits: ConfigStoreLimits) -> Result { + Self::load_at_startup(path, limits, 0) + } + + pub(crate) fn load_at_startup( + path: &Path, + limits: ConfigStoreLimits, + existing: usize, + ) -> Result { + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + options.custom_flags(libc::O_NONBLOCK); + let data = match options.open(path) { + Ok(mut file) => { + let metadata = file.metadata().map_err(|_io| { + ConfigStoreError::unavailable("config snapshot metadata unavailable") + })?; + if !metadata.is_file() { + return Err(ConfigStoreError::unavailable( + "config snapshot requires a regular file", + )); + } + if metadata.len() + > u64::try_from(limits.max_file_bytes()) + .map_err(|_size| ConfigStoreError::ValueTooLarge)? + { + return Err(ConfigStoreError::ValueTooLarge); + } + Snapshot::load(&mut file, limits, existing)? } - Err(err) => { - return Err(ConfigStoreError::unavailable(format!( - "failed to read {}: {err}", - path.display() - ))); + Err(err) if err.kind() == ErrorKind::NotFound => Snapshot::empty(limits, existing)?, + Err(_io) => { + return Err(ConfigStoreError::unavailable("config snapshot open failed")); } }; - let data: HashMap = serde_json::from_str(&raw).map_err(|err| { - ConfigStoreError::unavailable(format!( - "{} is not a flat string -> string JSON object: {err}", - path.display() - )) - })?; - Ok(Self { data }) + Ok(Self { + data, + #[cfg(test)] + unbounded_get_calls: AtomicUsize::new(0), + }) } /// Resolve the on-disk path for the given logical config id. @@ -152,14 +183,60 @@ impl AxumConfigStore { } suffix } + + /// Charged snapshot index, key and shared-payload requested allocation bytes. + #[inline] + #[must_use] + pub const fn resident_allocation_bytes(&self) -> usize { + self.data.resident_bytes() + } } #[async_trait(?Send)] impl ConfigStore for AxumConfigStore { #[inline] - async fn get(&self, key: &str) -> Result, ConfigStoreError> { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + #[cfg(test)] + self.unbounded_get_calls.fetch_add(1, Ordering::Relaxed); Ok(self.data.get(key).cloned()) } + + #[inline] + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + + // Payloads are already bounded resident snapshots; reads clone only Arc. + let stored_value = self.data.get(key); + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + + let backend_bytes = stored_value.map_or(Ok(0_u64), |value| { + u64::try_from(value.len()).map_err(|_length_error| ConfigStoreError::ValueTooLarge) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + return Err(ConfigStoreError::ValueTooLarge); + } + + let value = stored_value.cloned(); + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + + Ok(BoundedStoreRead { + backend_bytes, + value, + }) + } } /// Walk up from the process cwd looking for an ancestor that @@ -192,22 +269,206 @@ fn find_project_root_dir_from(start: &Path) -> Option { mod tests { // Run the shared contract tests against AxumConfigStore. edgezero_core::config_store_contract_tests!(axum_config_store_contract, { - AxumConfigStore::from_map([ - ("contract.key.a".to_owned(), "value_a".to_owned()), - ("contract.key.b".to_owned(), "value_b".to_owned()), - ]) + AxumConfigStore::from_map( + [ + ("contract.key.a".to_owned(), "value_a".to_owned()), + ("contract.key.b".to_owned(), "value_b".to_owned()), + ], + ConfigStoreLimits::default(), + ) + .expect("snapshot") }); use super::*; + use edgezero_core::{Deadline, MonotonicClock, MonotonicInstant}; use futures::executor::block_on; + use std::sync::atomic::Ordering; + use std::time::Duration; use tempfile::tempdir; + #[test] + fn bounded_get_shares_payload_across_reads() { + let store = AxumConfigStore::from_map( + [("key".to_owned(), "payload".to_owned())], + ConfigStoreLimits::default(), + ) + .expect("snapshot"); + let clock = MonotonicClock::default(); + let deadline = Deadline::after(Duration::from_secs(1)); + let first = block_on(store.get_bounded("key", &clock, deadline, 7, 7)) + .expect("first read") + .value + .expect("first value"); + let second = block_on(store.get_bounded("key", &clock, deadline, 7, 7)) + .expect("second read") + .value + .expect("second value"); + assert_eq!( + first.as_ptr(), + second.as_ptr(), + "reads must share the snapshot payload" + ); + } + + #[test] + fn startup_rejects_keys_over_the_default_allocation_limit() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("config.json"); + let key = "k".repeat(1025); + fs::write(&path, serde_json::json!({key: "value"}).to_string()).expect("write"); + assert!(matches!( + AxumConfigStore::from_path(&path, ConfigStoreLimits::default()), + Err(ConfigStoreError::ValueTooLarge) + )); + } + + #[test] + fn startup_rejects_duplicate_keys_instead_of_overwriting() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("config.json"); + fs::write(&path, r#"{"key":"first","key":"second"}"#).expect("write duplicate-key fixture"); + assert!(matches!( + AxumConfigStore::from_path(&path, ConfigStoreLimits::default()), + Err(ConfigStoreError::Unavailable { .. }) + )); + } + + #[cfg(unix)] + #[test] + fn named_pipe_is_rejected_without_waiting_for_a_writer() { + use std::process::Command; + use std::sync::mpsc::{self, RecvTimeoutError}; + use std::thread; + let directory = tempdir().expect("directory"); + let path = directory.path().join("config.fifo"); + assert!( + Command::new("mkfifo") + .arg(&path) + .status() + .expect("mkfifo") + .success() + ); + let reader_path = path.clone(); + let (sender, receiver) = mpsc::channel(); + let loader = thread::spawn(move || { + sender + .send(AxumConfigStore::from_path(&reader_path, ConfigStoreLimits::default()).err()) + .expect("send"); + }); + let result = receiver.recv_timeout(Duration::from_secs(2)); + if matches!(result, Err(RecvTimeoutError::Timeout)) { + // Unblock a regressed blocking open before failing, so the test leaks no thread. + drop( + fs::OpenOptions::new() + .write(true) + .open(&path) + .expect("unblock reader"), + ); + } + loader.join().expect("loader"); + assert!(matches!( + result.expect("nonblocking rejection"), + Some(ConfigStoreError::Unavailable { .. }) + )); + } + + #[test] + fn bounded_get_accepts_exact_caps_and_reports_exact_backend_bytes() { + let cs = AxumConfigStore::from_map( + [("greeting".to_owned(), "hello".to_owned())], + ConfigStoreLimits::default(), + ) + .expect("snapshot"); + let clock = MonotonicClock::default(); + + let read = block_on(cs.get_bounded( + "greeting", + &clock, + Deadline::after(Duration::from_secs(1)), + 5, + 5, + )) + .expect("exact cap must succeed"); + + assert_eq!(read.backend_bytes, 5); + assert_eq!(read.value.as_deref(), Some("hello")); + } + + #[test] + fn bounded_get_rejects_either_cap_without_using_cloning_get() { + for (max_backend_bytes, max_value_bytes) in [(4, 5), (5, 4)] { + let cs = AxumConfigStore::from_map( + [("greeting".to_owned(), "hello".to_owned())], + ConfigStoreLimits::default(), + ) + .expect("snapshot"); + let clock = MonotonicClock::default(); + + let error = block_on(cs.get_bounded( + "greeting", + &clock, + Deadline::after(Duration::from_secs(1)), + max_backend_bytes, + max_value_bytes, + )) + .expect_err("over-cap value must fail"); + + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + assert_eq!(cs.unbounded_get_calls.load(Ordering::Relaxed), 0); + } + } + + #[test] + fn bounded_get_rejects_an_expired_deadline_before_reading() { + let cs = AxumConfigStore::from_map( + [("greeting".to_owned(), "hello".to_owned())], + ConfigStoreLimits::default(), + ) + .expect("snapshot"); + let expired = Deadline::at_instant(MonotonicInstant::now()); + let clock = MonotonicClock::default(); + + let error = block_on(cs.get_bounded("greeting", &clock, expired, 5, 5)) + .expect_err("expired deadline must fail"); + + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + assert_eq!(cs.unbounded_get_calls.load(Ordering::Relaxed), 0); + } + + #[test] + fn bounded_config_read_uses_injected_clock() { + let cs = AxumConfigStore::from_map( + [("greeting".to_owned(), "hello".to_owned())], + ConfigStoreLimits::default(), + ) + .expect("snapshot"); + let process_now = MonotonicInstant::now(); + let injected_now = process_now + .checked_sub(Duration::from_mins(1)) + .expect("injected instant"); + let clock = MonotonicClock::new(move || injected_now); + let deadline = Deadline::at_instant( + injected_now + .checked_add(Duration::from_secs(1)) + .expect("deadline instant"), + ); + + let read = block_on(cs.get_bounded("greeting", &clock, deadline, 5, 5)) + .expect("the application clock is still before its deadline"); + + assert_eq!(read.value.as_deref(), Some("hello")); + } + #[test] fn axum_config_store_from_map_returns_values() { - let cs = AxumConfigStore::from_map([("greeting".to_owned(), "hello".to_owned())]); + let cs = AxumConfigStore::from_map( + [("greeting".to_owned(), "hello".to_owned())], + ConfigStoreLimits::default(), + ) + .expect("snapshot"); assert_eq!( block_on(cs.get("greeting")).expect("config value"), - Some("hello".to_owned()) + Some(edgezero_core::ConfigValue::from("hello")) ); assert_eq!(block_on(cs.get("missing")).expect("missing config"), None); } @@ -274,8 +535,11 @@ mod tests { #[test] fn axum_config_store_from_path_returns_empty_for_missing_file() { let temp = tempdir().expect("tempdir"); - let cs = AxumConfigStore::from_path(&temp.path().join("nope.json")) - .expect("missing file is permissive"); + let cs = AxumConfigStore::from_path( + &temp.path().join("nope.json"), + ConfigStoreLimits::default(), + ) + .expect("missing file is permissive"); assert_eq!(block_on(cs.get("anything")).expect("empty store"), None); } @@ -285,18 +549,20 @@ mod tests { let path = temp.path().join("local-config-app_config.json"); fs::write( &path, - r#"{"greeting":"hello from file","feature.new_checkout":"false"}"#, + serde_json::json!({"greeting": "hello from file", "feature.new_checkout": "false"}) + .to_string(), ) .expect("write json"); - let cs = AxumConfigStore::from_path(&path).expect("parse json"); + let cs = + AxumConfigStore::from_path(&path, ConfigStoreLimits::default()).expect("parse json"); assert_eq!( block_on(cs.get("greeting")).expect("value"), - Some("hello from file".to_owned()) + Some(edgezero_core::ConfigValue::from("hello from file")) ); assert_eq!( block_on(cs.get("feature.new_checkout")).expect("dotted value"), - Some("false".to_owned()) + Some(edgezero_core::ConfigValue::from("false")) ); assert_eq!(block_on(cs.get("missing")).expect("missing"), None); } @@ -307,7 +573,7 @@ mod tests { let path = temp.path().join("local-config-bad.json"); fs::write(&path, "{not json}").expect("write"); - match AxumConfigStore::from_path(&path) { + match AxumConfigStore::from_path(&path, ConfigStoreLimits::default()) { Err(ConfigStoreError::Unavailable { .. }) => {} Err(other) => panic!("expected Unavailable, got {other:?}"), Ok(_) => panic!("malformed JSON must surface as error"), @@ -318,9 +584,9 @@ mod tests { fn axum_config_store_from_path_rejects_non_string_values() { let temp = tempdir().expect("tempdir"); let path = temp.path().join("local-config-numeric.json"); - fs::write(&path, r#"{"greeting":42}"#).expect("write"); + fs::write(&path, serde_json::json!({"greeting": 42_u32}).to_string()).expect("write"); - match AxumConfigStore::from_path(&path) { + match AxumConfigStore::from_path(&path, ConfigStoreLimits::default()) { Err(ConfigStoreError::Unavailable { .. }) => {} Err(other) => panic!("expected Unavailable, got {other:?}"), Ok(_) => panic!("non-string values must surface as error"), diff --git a/crates/edgezero-adapter-axum/src/config_store_limits.rs b/crates/edgezero-adapter-axum/src/config_store_limits.rs new file mode 100644 index 000000000..4aa1840f7 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/config_store_limits.rs @@ -0,0 +1,230 @@ +//! Validated allocation budgets for immutable local configuration snapshots. + +use edgezero_core::env_config::EnvConfig; + +/// Limits shared by every local config store loaded during one server startup. +/// +/// Resident and startup allowances are requested allocation charges, not RSS. +/// Startup reserves five file budgets for input, decoder growth and temporary strings. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ConfigStoreLimits { + entries: usize, + file_bytes: usize, + key_bytes: usize, + resident_bytes: usize, + startup_bytes: usize, + value_bytes: usize, +} + +/// A rejected setting. The supplied value is never included in diagnostics. +#[derive(Debug, thiserror::Error)] +#[error("invalid Axum config-store setting `{field}`")] +pub struct ConfigStoreLimitsError { + field: &'static str, +} + +impl Default for ConfigStoreLimits { + #[inline] + fn default() -> Self { + Self { + entries: 1024, + file_bytes: 0x0100_0000, + key_bytes: 1024, + resident_bytes: 0x0200_0000, + startup_bytes: 0x0700_0000, + value_bytes: 0x0080_0000, + } + } +} + +impl ConfigStoreLimits { + /// Resolve `EDGEZERO__ADAPTER__CONFIG_STORE__*`. Missing settings alone use defaults. + /// + /// # Errors + /// Rejects malformed, zero, out-of-range or inconsistent explicit settings. + #[inline] + pub fn from_env(env: &EnvConfig) -> Result { + let defaults = Self::default(); + Self::new( + setting(env, "max_file_bytes", defaults.file_bytes)?, + setting(env, "max_entries", defaults.entries)?, + setting(env, "max_key_bytes", defaults.key_bytes)?, + setting(env, "max_value_bytes", defaults.value_bytes)?, + setting(env, "max_resident_bytes", defaults.resident_bytes)?, + setting(env, "max_startup_bytes", defaults.startup_bytes)?, + ) + } + + /// Maximum entries in any one snapshot, including duplicate-key attempts. + #[inline] + #[must_use] + pub const fn max_entries(self) -> usize { + self.entries + } + + /// Maximum wire bytes read from a single opened local file. + #[inline] + #[must_use] + pub const fn max_file_bytes(self) -> usize { + self.file_bytes + } + + /// Maximum decoded UTF-8 bytes per key. + #[inline] + #[must_use] + pub const fn max_key_bytes(self) -> usize { + self.key_bytes + } + + /// Maximum charged residency across the entire startup registry. + #[inline] + #[must_use] + pub const fn max_resident_bytes(self) -> usize { + self.resident_bytes + } + + /// Maximum charged residency plus staging during sequential startup loading. + #[inline] + #[must_use] + pub const fn max_startup_bytes(self) -> usize { + self.startup_bytes + } + + /// Maximum decoded UTF-8 bytes per value, before creating its shared allocation. + #[inline] + #[must_use] + pub const fn max_value_bytes(self) -> usize { + self.value_bytes + } + + /// Construct finite limits. File/value/resident limits support up to 256 MiB; + /// startup up to 1 GiB, keys up to 64 KiB and entries up to 65536. + /// + /// # Errors + /// Rejects invalid ranges, keys/values larger than the wire budget, or a startup + /// budget smaller than the worst-case input/decoder staging reservation. + #[inline] + pub fn new( + max_file_bytes: usize, + max_entries: usize, + max_key_bytes: usize, + max_value_bytes: usize, + max_resident_bytes: usize, + max_startup_bytes: usize, + ) -> Result { + for (valid, field) in [ + ( + (1..=0x1000_0000).contains(&max_file_bytes), + "max_file_bytes", + ), + ((1..=0x0001_0000).contains(&max_entries), "max_entries"), + ( + (1..=0x0001_0000).contains(&max_key_bytes) && max_key_bytes <= max_file_bytes, + "max_key_bytes", + ), + ( + (1..=0x1000_0000).contains(&max_value_bytes) && max_value_bytes <= max_file_bytes, + "max_value_bytes", + ), + ( + (1..=0x1000_0000).contains(&max_resident_bytes), + "max_resident_bytes", + ), + ( + (1..=0x4000_0000).contains(&max_startup_bytes) + && max_startup_bytes >= max_file_bytes.max(32).saturating_mul(5), + "max_startup_bytes", + ), + ] { + if !valid { + return Err(ConfigStoreLimitsError { field }); + } + } + Ok(Self { + entries: max_entries, + file_bytes: max_file_bytes, + key_bytes: max_key_bytes, + resident_bytes: max_resident_bytes, + startup_bytes: max_startup_bytes, + value_bytes: max_value_bytes, + }) + } + + /// Reserved requested input/decoder capacity while one file is loaded. + #[inline] + #[must_use] + pub const fn staging_bytes(self) -> usize { + let base = if self.file_bytes < 32 { + 32 + } else { + self.file_bytes + }; + base.saturating_mul(5) + } +} + +fn setting( + env: &EnvConfig, + field: &'static str, + default: usize, +) -> Result { + let Some(value) = env.get(&["adapter", "config_store", field]) else { + return Ok(default); + }; + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(ConfigStoreLimitsError { field }); + } + value + .parse() + .map_err(|_invalid_integer| ConfigStoreLimitsError { field }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_and_explicit_limits_are_validated() { + let defaults = ConfigStoreLimits::from_env(&EnvConfig::default()).expect("defaults"); + assert_eq!(defaults, ConfigStoreLimits::default()); + assert_eq!(defaults.staging_bytes(), 5 * defaults.max_file_bytes()); + let env = EnvConfig::from_vars([("EDGEZERO__ADAPTER__CONFIG_STORE__MAX_ENTRIES", "2")]); + assert_eq!( + ConfigStoreLimits::from_env(&env) + .expect("explicit") + .max_entries(), + 2 + ); + } + + #[test] + fn invalid_explicit_limits_never_fall_back_or_echo_values() { + for field in [ + "MAX_FILE_BYTES", + "MAX_ENTRIES", + "MAX_KEY_BYTES", + "MAX_VALUE_BYTES", + "MAX_RESIDENT_BYTES", + "MAX_STARTUP_BYTES", + ] { + for value in [ + "", + "0", + "-1", + "+2", + " 2", + "credential=private", + "18446744073709551616", + ] { + let key = format!("EDGEZERO__ADAPTER__CONFIG_STORE__{field}"); + let env = EnvConfig::from_vars([(key.as_str(), value)]); + let error = + ConfigStoreLimits::from_env(&env).expect_err("invalid explicit setting"); + assert!(!error.to_string().contains("credential=private")); + } + } + ConfigStoreLimits::new(10, 1, 1, 1, 10, 29).expect_err("invalid policy"); + ConfigStoreLimits::new(10, 1, 11, 1, 10, 30).expect_err("invalid policy"); + ConfigStoreLimits::new(10, 1, 1, 11, 10, 30).expect_err("invalid policy"); + } +} diff --git a/crates/edgezero-adapter-axum/src/connection.rs b/crates/edgezero-adapter-axum/src/connection.rs new file mode 100644 index 000000000..e99c2a7f2 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/connection.rs @@ -0,0 +1,958 @@ +use std::error::Error as StdError; +use std::future::{self, Future}; +use std::io; + +use http_body::Body as HttpBody; +use hyper::Request; +use hyper::body::Incoming; +use hyper::server::conn::http1::Builder as Http1Builder; +use hyper::service::Service; +use hyper_util::rt::TokioIo; +use tokio::net::TcpStream; +use tokio::time::{Instant as TokioInstant, sleep_until}; + +use crate::header_deadline::{HeaderDeadline, HeaderDeadlineExceeded, HeaderIo, HeaderService}; +use crate::ingress_config::AxumIngressConfig; +use crate::response::AxumBodyError; +use crate::response::EgressConnection; +use crate::service::AxumIngressAbort; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ConnectionExit { + AdmissionAborted, + Completed, + DeadlineExceeded, +} + +/// Owns HTTP/1 parsing, idle/header expiry and the existing response deadline race. +#[expect( + clippy::integer_division_remainder_used, + reason = "tokio::select! expands to internal randomized branch selection arithmetic" +)] +pub(crate) fn serve_http1( + stream: TcpStream, + service: ServiceType, + responses: EgressConnection, + limits: AxumIngressConfig, +) -> impl Future> +where + ServiceType: Service, Response = hyper::Response>, + ServiceType::Error: Into>, + ServiceType::Future: 'static, + ResponseBody: HttpBody + 'static, + ResponseBody::Error: Into>, +{ + // Stamp before scheduling the connection future, not on its first poll. + let head = HeaderDeadline::new(limits.head_read_timeout()); + async move { + let connection_future = Http1Builder::new() + .keep_alive(true) + .max_headers(limits.max_header_count()) + .max_header_size(limits.max_raw_head_bytes()) + .max_buf_size(limits.parser_buffer_bytes()) + .header_read_timeout(limits.head_read_timeout()) + .timer(head.clone()) + .serve_connection( + TokioIo::new(HeaderIo::new(stream, head.clone())), + HeaderService::new(service, head.clone(), limits, responses.clone()), + ); + let mut connection = Box::pin(connection_future); + + loop { + if head + .deadline() + .is_some_and(|deadline| TokioInstant::now() >= deadline) + { + responses.signal_elapsed_deadline(TokioInstant::now()); + responses.signal_transport_error(); + drop(connection); + return Ok(ConnectionExit::DeadlineExceeded); + } + let next_deadline = head + .deadline() + .into_iter() + .chain(responses.next_deadline()) + .min(); + let wait = async move { + if let Some(deadline) = next_deadline { + sleep_until(deadline).await; + } else { + future::pending::<()>().await; + } + }; + tokio::select! { + biased; + () = wait => { + if responses.signal_elapsed_deadline(TokioInstant::now()) { + drop(connection); + return Ok(ConnectionExit::DeadlineExceeded); + } + } + result = &mut connection => { + responses.signal_transport_error(); + drop(connection); + return classify_connection_result(result); + } + () = head.changed() => {} + () = responses.changed() => {} + } + } + } +} + +fn classify_connection_result( + result: Result<(), hyper::Error>, +) -> Result { + match result { + Ok(()) => Ok(ConnectionExit::Completed), + Err(error) if error_chain_has_admission_abort(&error) => { + Ok(ConnectionExit::AdmissionAborted) + } + Err(error) if error_chain_has_deadline(&error) => Ok(ConnectionExit::DeadlineExceeded), + Err(error) => Err(error), + } +} + +fn error_chain_has_admission_abort(error: &hyper::Error) -> bool { + let mut source = error.source(); + while let Some(current) = source { + if current.downcast_ref::().is_some() { + return true; + } + source = current.source(); + } + false +} + +fn error_chain_has_deadline(error: &hyper::Error) -> bool { + if error.is_timeout() { + return true; + } + let mut source = error.source(); + while let Some(current) = source { + if current + .downcast_ref::() + .is_some_and(|body_error| body_error.is_deadline()) + { + return true; + } + if current.downcast_ref::().is_some() { + return true; + } + if current + .downcast_ref::() + .and_then(io::Error::get_ref) + .is_some_and(::is::) + { + return true; + } + source = current.source(); + } + false +} + +#[cfg(test)] +mod bounded_tests { + use std::convert::Infallible; + use std::time::Duration; + + use axum::body::Body; + use futures::future::join; + use hyper::Response; + use hyper::service::service_fn; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + use tokio::net::{TcpListener, TcpStream}; + use tokio::time::timeout; + + use super::serve_http1; + use crate::ingress_config::AxumIngressConfig; + use crate::response::EgressConnection; + + async fn raw_exchange(request: &[u8]) -> Vec { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = async move { + let (socket, _) = listener.accept().await.unwrap(); + let service = service_fn(|_request| async { + Ok::<_, Infallible>(Response::new(Body::from("ok"))) + }); + let _result = serve_http1( + socket, + service, + EgressConnection::default(), + AxumIngressConfig::default(), + ) + .await; + }; + let client = async { + let mut stream = TcpStream::connect(address).await.unwrap(); + stream.write_all(request).await.unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.unwrap(); + response + }; + let ((), response) = timeout(Duration::from_secs(2), join(server, client)) + .await + .unwrap(); + response + } + + #[tokio::test] + async fn accepted_deadline_precedes_first_poll_but_not_admitted_handler_work() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::time::sleep; + + for delayed_poll in [true, false] { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&calls); + let server = async move { + let (socket, _) = listener.accept().await.unwrap(); + let service = service_fn(move |_request| { + let counted = Arc::clone(&observed); + async move { + counted.fetch_add(1, Ordering::SeqCst); + sleep(Duration::from_millis(200)).await; + Ok::<_, Infallible>(Response::new(Body::from("ok"))) + } + }); + let limits = + AxumIngressConfig::new(1, 8192, 100, Duration::from_millis(100)).unwrap(); + let connection = serve_http1(socket, service, EgressConnection::default(), limits); + if delayed_poll { + sleep(Duration::from_millis(150)).await; + } + let _result = connection.await; + }; + let client = async { + let mut stream = TcpStream::connect(address).await.unwrap(); + stream + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + let mut response = Vec::new(); + let _read = stream.read_to_end(&mut response).await; + response + }; + let ((), response) = timeout(Duration::from_secs(2), join(server, client)) + .await + .unwrap(); + if delayed_poll { + assert!(response.is_empty()); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } else { + assert!(response.starts_with(b"HTTP/1.1 200")); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + } + } + + #[tokio::test] + async fn default_raw_head_limit_rejects_first_byte_over_before_service() { + let prefix = b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nX-Pad: "; + let mut request = prefix.to_vec(); + request.extend(vec![b'a'; 0x0001_0000 - prefix.len() - 4]); + request.extend_from_slice(b"\r\n\r\n"); + let response = raw_exchange(&request).await; + assert!(response.starts_with(b"HTTP/1.1 200")); + request.insert(prefix.len(), b'a'); + let rejected = raw_exchange(&request).await; + assert!(rejected.starts_with(b"HTTP/1.1 431"), "unexpected status"); + } +} + +#[cfg(test)] +mod tests { + use std::convert::Infallible; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::task::Poll; + use std::time::Duration; + + use bytes::Bytes; + use edgezero_core::app::App; + use edgezero_core::body::Body; + use edgezero_core::error::EdgeError; + use edgezero_core::http::{HeaderMap, Method, StatusCode, Version, response_builder}; + use edgezero_core::ingress::{AdmissionDecision, IngressGrant}; + use edgezero_core::response_egress::{ + ResponseEgressAttempt, ResponseEgressCompletion, ResponseEgressHead, + ResponseEgressObserver, ResponseEgressObserverHandle, ResponseEgressOutcome, + ResponseEgressPolicy, ResponseEgressReport, + }; + use edgezero_core::router::{RouteMetadata, RouterService}; + use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; + use futures_util::stream::{pending, poll_fn, repeat_with}; + use hyper::Request; + use hyper::Response; + use hyper::body::Incoming; + use hyper::service::service_fn; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + use tokio::net::{TcpListener, TcpStream}; + use tokio::task::{LocalSet, spawn_local}; + use tokio::time::timeout; + + use super::{ConnectionExit, serve_http1}; + use crate::ingress_config::AxumIngressConfig; + use crate::response::{AxumEgressBody, EgressConnection}; + use crate::service::AxumServiceState; + + #[derive(Clone, Copy)] + enum DeclaredTail { + Clean, + Empty, + EmptyForever, + Error, + Excess, + Pending, + } + + #[derive(Clone, Default)] + struct RecordingObserver(Arc>>); + + struct SourceDropProbe(Arc); + + impl ResponseEgressObserver for RecordingObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.0.lock().expect("reports lock").push(report.clone()); + } + } + + impl Drop for SourceDropProbe { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + fn declared_stream_app( + length: &'static str, + tail: DeclaredTail, + observer: &RecordingObserver, + polls: &Arc, + drops: &Arc, + ) -> App { + let source_polls = Arc::clone(polls); + let source_drops = Arc::clone(drops); + let router = RouterService::builder() + .get("/", move |_ctx| { + let observed_polls = Arc::clone(&source_polls); + let drop_probe = SourceDropProbe(Arc::clone(&source_drops)); + async move { + let stream = poll_fn(move |_context| { + let _keep_source_alive = &drop_probe; + let index = observed_polls.fetch_add(1, Ordering::SeqCst); + if index == 0 && length != "0" { + return Poll::Ready(Some(Ok(Bytes::from_static(b"abc")))); + } + match tail { + DeclaredTail::Empty if index <= 1 => { + Poll::Ready(Some(Ok(Bytes::new()))) + } + DeclaredTail::Clean | DeclaredTail::Empty => Poll::Ready(None), + DeclaredTail::EmptyForever => Poll::Ready(Some(Ok(Bytes::new()))), + DeclaredTail::Error => Poll::Ready(Some(Err(EdgeError::internal( + anyhow::anyhow!("late source error"), + )))), + DeclaredTail::Excess => { + Poll::Ready(Some(Ok(Bytes::from_static(b"extra")))) + } + DeclaredTail::Pending => Poll::Pending, + } + }); + let response = response_builder() + .header("content-length", length) + .body(Body::from_stream(stream)) + .expect("declared response"); + Ok::<_, EdgeError>(response) + } + }) + .build(); + let mut app = App::new(router); + app.set_response_egress_observer(observer.clone()); + let budget = if matches!(tail, DeclaredTail::Pending | DeclaredTail::EmptyForever) { + Duration::from_millis(100) + } else { + Duration::from_secs(5) + }; + app.set_response_egress_policy(move |_, started_at| ResponseEgressPolicy { + write_deadline: Deadline::at_instant(started_at.checked_add(budget).expect("deadline")), + }); + app + } + + async fn exchange_with_app(app: App) -> (String, Result) { + exchange_with_method(app, "GET").await + } + + async fn exchange_with_method( + app: App, + method: &str, + ) -> (String, Result) { + LocalSet::new() + .run_until(async { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind listener"); + let address = listener.local_addr().expect("listener address"); + let state = AxumServiceState::from_app(app); + let server = spawn_local(async move { + let (socket, peer) = listener.accept().await.expect("accept client"); + let responses = EgressConnection::default(); + let service = state.for_connection(peer, responses.clone()); + serve_http1(socket, service, responses, AxumIngressConfig::default()).await + }); + let mut client = TcpStream::connect(address).await.expect("connect client"); + client + .write_all( + format!( + "{method} / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await + .expect("write request"); + let mut wire = Vec::new(); + timeout(Duration::from_secs(2), client.read_to_end(&mut wire)) + .await + .expect("bounded response lifetime") + .expect("read response"); + let exit = server.await.expect("server task"); + (String::from_utf8(wire).expect("ASCII response"), exit) + }) + .await + } + + #[tokio::test(flavor = "current_thread")] + async fn bodyless_tcp_responses_release_the_response_resource_exactly_once() { + for (method, status) in [ + ("HEAD", StatusCode::OK), + ("GET", StatusCode::NO_CONTENT), + ("GET", StatusCode::NOT_MODIFIED), + ] { + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let completed = Arc::new(AtomicUsize::new(0)); + let resources = Arc::new(AtomicUsize::new(0)); + let source_polls = Arc::clone(&polls); + let source_drops = Arc::clone(&drops); + let router = RouterService::builder() + .route( + "/", + Method::from_bytes(method.as_bytes()).expect("method"), + move |_| { + let observed = Arc::clone(&source_polls); + let guard = SourceDropProbe(Arc::clone(&source_drops)); + async move { + let body = poll_fn(move |_cx| { + let _keep_alive = &guard; + observed.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + }); + Ok::<_, EdgeError>( + response_builder() + .status(status) + .body(Body::from_stream(body)) + .expect("response"), + ) + } + }, + ) + .build(); + let mut app = App::new(router); + let observer = RecordingObserver::default(); + app.set_response_egress_observer(observer.clone()); + let terminals = Arc::clone(&completed); + let released = Arc::clone(&resources); + app.set_ingress_admission_policy(move |head| AdmissionDecision::Admit { + completion: ResponseEgressCompletion::new({ + let callback = Arc::clone(&terminals); + let resource = SourceDropProbe(Arc::clone(&released)); + move |report| { + assert_eq!(report.outcome, ResponseEgressOutcome::HostHandoff); + callback.fetch_add(1, Ordering::SeqCst); + drop(resource); + } + }), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + }); + let (wire, exit) = exchange_with_method(app, method).await; + assert_eq!(exit.expect("complete"), ConnectionExit::Completed); + assert_eq!(wire.split_once("\r\n\r\n").expect("wire head").1, ""); + assert_eq!(polls.load(Ordering::SeqCst), 0); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!(completed.load(Ordering::SeqCst), 1); + assert_eq!(resources.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().expect("reports"); + assert_eq!(reports.len(), 1); + assert_eq!(reports.first().expect("report").bytes_written, 0); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn oversized_response_head_closes_and_completes_once_without_polling_body() { + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let resource_drops = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&polls); + let observed_drops = Arc::clone(&drops); + let router = RouterService::builder() + .get("/", move |_ctx| { + let source_polls = Arc::clone(&observed_polls); + let probe = SourceDropProbe(Arc::clone(&observed_drops)); + async move { + let source = poll_fn(move |_cx| { + let _keep_alive = &probe; + source_polls.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + }); + Ok::<_, EdgeError>( + response_builder() + .header( + "x-oversized", + "a".repeat(AxumIngressConfig::default().max_raw_head_bytes()), + ) + .body(Body::from_stream(source)) + .unwrap(), + ) + } + }) + .build(); + let mut app = App::new(router); + app.set_response_egress_observer(observer.clone()); + let observed_resources = Arc::clone(&resource_drops); + app.set_ingress_admission_policy(move |head| AdmissionDecision::Admit { + completion: ResponseEgressCompletion::new({ + let resource = SourceDropProbe(Arc::clone(&observed_resources)); + move |_report| drop(resource) + }), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + }); + let (wire, exit) = exchange_with_app(app).await; + assert!(wire.is_empty()); + let _error = exit.expect_err("oversized response head must close the connection"); + assert_eq!(polls.load(Ordering::SeqCst), 0); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!(resource_drops.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().unwrap(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::TransportError); + assert_eq!(reports[0].bytes_written, 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn hyper_declared_stream_checks_clean_eof_including_zero() { + for (length, tail, expected_body, expected_polls) in [ + ("3", DeclaredTail::Clean, "abc", 2), + ("0", DeclaredTail::Clean, "", 1), + ("3", DeclaredTail::Empty, "abc", 3), + ("0", DeclaredTail::Empty, "", 3), + ] { + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let app = declared_stream_app(length, tail, &observer, &polls, &drops); + + let (wire, exit) = exchange_with_app(app).await; + assert_eq!(exit.expect("clean connection"), ConnectionExit::Completed); + let (head, body) = wire.split_once("\r\n\r\n").expect("response head"); + assert!(head.starts_with("HTTP/1.1 200")); + assert_eq!(body, expected_body); + assert_eq!(polls.load(Ordering::SeqCst), expected_polls); + assert_eq!(drops.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::HostHandoff); + assert_eq!( + reports[0].bytes_written, + u64::try_from(expected_body.len()).expect("body length") + ); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn hyper_declared_stream_rejects_excess_and_error_tails() { + for tail in [DeclaredTail::Excess, DeclaredTail::Error] { + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let app = declared_stream_app("3", tail, &observer, &polls, &drops); + + let (wire, exit) = exchange_with_app(app).await; + assert!(exit.is_err(), "bad tail must close the connection"); + assert!(!wire.ends_with("abc"), "unvalidated final frame was sent"); + assert_eq!(polls.load(Ordering::SeqCst), 2); + assert_eq!(drops.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::SourceError); + assert_eq!(reports[0].bytes_written, 0); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn hyper_declared_stream_pending_eof_is_deadline_bounded() { + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let app = declared_stream_app("3", DeclaredTail::Pending, &observer, &polls, &drops); + + let (wire, exit) = exchange_with_app(app).await; + assert_eq!( + exit.expect("deadline closes connection"), + ConnectionExit::DeadlineExceeded + ); + assert!(!wire.ends_with("abc")); + assert!(polls.load(Ordering::SeqCst) >= 2); + assert_eq!(drops.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + assert_eq!(reports[0].bytes_written, 0); + } + + #[tokio::test(flavor = "current_thread")] + async fn hyper_zero_declared_stream_validation_is_bounded_before_handoff() { + for tail in [ + DeclaredTail::Excess, + DeclaredTail::Error, + DeclaredTail::Pending, + ] { + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let app = declared_stream_app("0", tail, &observer, &polls, &drops); + + let (wire, exit) = exchange_with_app(app).await; + assert_eq!(exit.expect("bounded fallback"), ConnectionExit::Completed); + let expected = if matches!(tail, DeclaredTail::Pending) { + ("HTTP/1.1 504", ResponseEgressOutcome::DeadlineExceeded) + } else { + ("HTTP/1.1 500", ResponseEgressOutcome::ConversionError) + }; + assert!(wire.starts_with(expected.0)); + assert!(polls.load(Ordering::SeqCst) > 0); + assert_eq!(drops.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, expected.1); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn hyper_eof_lookahead_yields_to_host_deadlines_with_a_frozen_clock() { + for length in ["3", "0"] { + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let mut app = declared_stream_app( + length, + DeclaredTail::EmptyForever, + &observer, + &polls, + &drops, + ); + let frozen_at = MonotonicInstant::now(); + app.set_monotonic_clock(MonotonicClock::new(move || frozen_at)); + + let (wire, exit) = exchange_with_app(app).await; + if length == "3" { + assert_eq!( + exit.expect("host deadline closes connection"), + ConnectionExit::DeadlineExceeded + ); + assert!(!wire.ends_with("abc")); + } else { + assert_eq!(exit.expect("bounded fallback"), ConnectionExit::Completed); + assert!(wire.starts_with("HTTP/1.1 504")); + } + assert!(polls.load(Ordering::SeqCst) > 1); + assert_eq!(drops.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn hyper_zero_length_registration_expiry_uses_timeout_fallback() { + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let mut app = declared_stream_app("0", DeclaredTail::Clean, &observer, &polls, &drops); + let started_at = MonotonicInstant::now(); + let deadline = started_at + .checked_add(Duration::from_secs(5)) + .expect("deadline"); + let observed_polls = Arc::clone(&polls); + let after_eof_samples = AtomicUsize::new(0); + app.set_monotonic_clock(MonotonicClock::new(move || { + if observed_polls.load(Ordering::SeqCst) == 0 + || after_eof_samples.fetch_add(1, Ordering::SeqCst) == 0 + { + started_at + } else { + deadline + } + })); + + let (wire, exit) = exchange_with_app(app).await; + assert_eq!(exit.expect("bounded fallback"), ConnectionExit::Completed); + assert!( + wire.starts_with("HTTP/1.1 504"), + "expiry at registration must remain a timeout" + ); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(drops.load(Ordering::SeqCst), 1); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + } + + fn attempt( + observer: &RecordingObserver, + started_at: MonotonicInstant, + ) -> ResponseEgressAttempt { + let headers = HeaderMap::new(); + let route = RouteMetadata::new(Method::GET, "/pending"); + let head = ResponseEgressHead::new( + StatusCode::OK, + Version::HTTP_11, + &headers, + None, + started_at, + Some(&route), + ); + ResponseEgressAttempt::new( + &head, + started_at, + ResponseEgressCompletion::empty(), + ResponseEgressObserverHandle::new(observer.clone()), + MonotonicClock::default(), + ) + } + + #[tokio::test(flavor = "current_thread")] + async fn independent_deadline_closes_connection_with_pending_non_send_body() { + let local = LocalSet::new(); + local + .run_until(async { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind listener"); + let address = listener.local_addr().expect("listener address"); + let observer = RecordingObserver::default(); + let server_observer = observer.clone(); + + let server = spawn_local(async move { + let (stream, _) = listener.accept().await.expect("accept client"); + let responses = EgressConnection::default(); + let service_responses = responses.clone(); + let service = service_fn(move |_request: Request| { + let started_at = MonotonicInstant::now(); + let deadline = Deadline::at_instant( + started_at + .checked_add(Duration::from_millis(25)) + .expect("deadline"), + ); + let body_result = AxumEgressBody::application( + Body::from_stream(pending()), + deadline, + MonotonicClock::default(), + attempt(&server_observer, started_at), + &service_responses, + ); + async move { + let Ok(registered_body) = body_result else { + panic!("register body"); + }; + Ok::<_, Infallible>(Response::new(registered_body)) + } + }); + + serve_http1(stream, service, responses, AxumIngressConfig::default()).await + }); + + let mut client = TcpStream::connect(address).await.expect("connect client"); + client + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .expect("write request"); + + let exit = server + .await + .expect("server task") + .expect("serve connection"); + assert_eq!(exit, ConnectionExit::DeadlineExceeded); + + let mut response = Vec::new(); + timeout( + Duration::from_millis(100), + client.read_to_end(&mut response), + ) + .await + .expect("connection closes promptly") + .expect("read response"); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + }) + .await; + } + + #[tokio::test(flavor = "current_thread")] + async fn zero_read_client_is_closed_when_socket_backpressure_reaches_deadline() { + let local = LocalSet::new(); + local + .run_until(async { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind listener"); + let address = listener.local_addr().expect("listener address"); + let observer = RecordingObserver::default(); + let server_observer = observer.clone(); + + let server = spawn_local(async move { + let (stream, _) = listener.accept().await.expect("accept client"); + let responses = EgressConnection::default(); + let service_responses = responses.clone(); + let service = service_fn(move |_request: Request| { + let started_at = MonotonicInstant::now(); + let deadline = Deadline::at_instant( + started_at + .checked_add(Duration::from_millis(50)) + .expect("deadline"), + ); + let source = + repeat_with(|| Ok(bytes::Bytes::from(vec![0_u8; 256 * 1_024]))); + let body_result = AxumEgressBody::application( + Body::from_stream(source), + deadline, + MonotonicClock::default(), + attempt(&server_observer, started_at), + &service_responses, + ); + async move { + let Ok(registered_body) = body_result else { + panic!("register body"); + }; + Ok::<_, Infallible>(Response::new(registered_body)) + } + }); + serve_http1(stream, service, responses, AxumIngressConfig::default()).await + }); + + let mut client = TcpStream::connect(address).await.expect("connect client"); + client + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .expect("write request"); + + let exit = timeout(Duration::from_secs(2), server) + .await + .expect("deadline closes blocked connection") + .expect("server task") + .expect("serve connection"); + assert_eq!(exit, ConnectionExit::DeadlineExceeded); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + assert!(reports[0].bytes_written > 0); + }) + .await; + } + + #[test] + fn deadline_attributes_winner_and_ordered_pipeline_collateral() { + let observer = RecordingObserver::default(); + let responses = EgressConnection::default(); + let started_at = MonotonicInstant::now(); + let later = Deadline::at_instant( + started_at + .checked_add(Duration::from_secs(2)) + .expect("later deadline"), + ); + let earlier = Deadline::at_instant( + started_at + .checked_add(Duration::from_secs(1)) + .expect("earlier deadline"), + ); + let first_result = AxumEgressBody::application( + Body::from_stream(pending()), + later, + MonotonicClock::default(), + attempt(&observer, started_at), + &responses, + ); + let second_result = AxumEgressBody::application( + Body::from_stream(pending()), + earlier, + MonotonicClock::default(), + attempt(&observer, started_at), + &responses, + ); + let (Ok(first_body), Ok(second_body)) = (first_result, second_result) else { + panic!("register pipelined bodies"); + }; + + let elapsed = responses.next_deadline().expect("next deadline"); + assert!(responses.signal_elapsed_deadline(elapsed)); + drop(first_body); + drop(second_body); + + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 2); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::TransportError); + assert_eq!(reports[1].outcome, ResponseEgressOutcome::DeadlineExceeded); + } + + #[test] + fn host_timer_uses_deadline_as_the_minimum_terminal_timestamp() { + let observer = RecordingObserver::default(); + let responses = EgressConnection::default(); + let started_at = MonotonicInstant::now(); + let deadline_at = started_at + .checked_add(Duration::from_secs(1)) + .expect("deadline"); + let frozen_clock = MonotonicClock::new(move || started_at); + let headers = HeaderMap::new(); + let head = ResponseEgressHead::new( + StatusCode::OK, + Version::HTTP_11, + &headers, + None, + started_at, + None, + ); + let attempt = ResponseEgressAttempt::new( + &head, + started_at, + ResponseEgressCompletion::empty(), + ResponseEgressObserverHandle::new(observer.clone()), + frozen_clock.clone(), + ); + let body_result = AxumEgressBody::application( + Body::from_stream(pending()), + Deadline::at_instant(deadline_at), + frozen_clock, + attempt, + &responses, + ); + let Ok(body) = body_result else { + panic!("register body"); + }; + + let elapsed = responses.next_deadline().expect("host deadline"); + assert!(responses.signal_elapsed_deadline(elapsed)); + drop(body); + + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + assert_eq!(reports[0].elapsed, Duration::from_secs(1)); + } +} diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 1a5405c0d..5c0031689 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -1,36 +1,45 @@ +use std::any::Any; use std::fs; +use std::future; #[cfg(test)] use std::iter; use std::net::{SocketAddr, TcpListener as StdTcpListener}; +use std::panic::AssertUnwindSafe; use std::path::{Path, PathBuf}; -use std::str::FromStr as _; use std::sync::Arc; use anyhow::Context as _; -use axum::Router; +use futures_util::FutureExt as _; +use futures_util::StreamExt as _; +use futures_util::stream::FuturesUnordered; use tokio::net::TcpListener as TokioTcpListener; use tokio::runtime::Builder as RuntimeBuilder; use tokio::signal; -use tower::{Service as _, service_fn}; +use tokio::sync::oneshot::{Receiver as ShutdownReceiver, Sender as ShutdownSender, channel}; +use tokio::task::{LocalSet, spawn_blocking}; use edgezero_core::addr; -use edgezero_core::app::{Hooks, StoreMetadata, StoresMetadata}; -use edgezero_core::config_store::ConfigStoreHandle; +use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; +use edgezero_core::config_store::{ConfigStoreError, ConfigStoreHandle, ConfigStoreOpenFailure}; use edgezero_core::env_config::EnvConfig; use edgezero_core::key_value_store::KvHandle; +use edgezero_core::logging::{BOOT_LOG_LEVEL, BOOT_LOG_TARGET, resolve_logging_level}; use edgezero_core::router::RouterService; use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, }; -use log::LevelFilter; use simple_logger::SimpleLogger; use std::collections::BTreeMap; use crate::config_store::AxumConfigStore; +use crate::config_store_limits::ConfigStoreLimits; +use crate::connection::{ConnectionExit, serve_http1}; +use crate::ingress_config::AxumIngressConfig; use crate::key_value_store::PersistentKvStore; +use crate::response::EgressConnection; use crate::secret_store::EnvSecretStore; -use crate::service::EdgeZeroAxumService; +use crate::service::AxumServiceState; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum KvInitRequirement { @@ -38,11 +47,15 @@ enum KvInitRequirement { Required, } +type ConnectionResult = Result, Box>; + /// Configuration used when running the dev server embedding `EdgeZero` into Axum. #[derive(Clone)] pub struct AxumDevServerConfig { pub addr: SocketAddr, pub enable_ctrl_c: bool, + /// Validated listener, parser and idle-plus-header limits. + pub ingress: AxumIngressConfig, } impl Default for AxumDevServerConfig { @@ -51,6 +64,7 @@ impl Default for AxumDevServerConfig { Self { addr: SocketAddr::from((addr::DEFAULT_HOST, addr::DEFAULT_PORT)), enable_ctrl_c: true, + ingress: AxumIngressConfig::default(), } } } @@ -116,7 +130,14 @@ impl AxumDevServer { let listener = TokioTcpListener::from_std(std_listener) .context("failed to adopt std listener into tokio")?; - serve_with_stores(router, listener, config.enable_ctrl_c, stores).await + serve_with_stores( + App::new(router), + listener, + config.enable_ctrl_c, + config.ingress, + stores, + ) + .await } #[cfg(test)] @@ -126,7 +147,14 @@ impl AxumDevServer { config, stores, } = self; - serve_with_stores(router, listener, config.enable_ctrl_c, stores).await + serve_with_stores( + App::new(router), + listener, + config.enable_ctrl_c, + config.ingress, + stores, + ) + .await } #[must_use] @@ -272,52 +300,128 @@ fn kv_handle_from_path(kv_path: &Path) -> anyhow::Result { } async fn serve_with_stores( - router: RouterService, + app: App, listener: TokioTcpListener, enable_ctrl_c: bool, + ingress: AxumIngressConfig, stores: Stores, ) -> anyhow::Result<()> { - let service = { - let mut service = EdgeZeroAxumService::new(router); - if let Some(registry) = stores.config_registry { - service = service.with_config_registry(registry); - } - if let Some(handle) = stores.config_store { - service = service.with_config_store_handle(handle); - } - if let Some(registry) = stores.kv_registry { - service = service.with_kv_registry(registry); - } - if let Some(handle) = stores.kv { - service = service.with_kv_handle(handle); - } - if let Some(registry) = stores.secret_registry { - service = service.with_secret_registry(registry); - } - if let Some(handle) = stores.secrets { - service = service.with_secret_handle(handle); + struct ShutdownOnDrop(Option>); + + impl Drop for ShutdownOnDrop { + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _sent = sender.send(()); + } } - service - }; - let axum_router = Router::new().fallback_service(service_fn(move |req| { - let mut svc = service.clone(); - async move { svc.call(req).await } - })); - let make_service = axum_router.into_make_service_with_connect_info::(); - - let shutdown = enable_ctrl_c.then_some(async { - let _ctrl_c = signal::ctrl_c().await; + } + + let native_listener = listener + .into_std() + .context("failed to release tokio listener")?; + let (shutdown_sender, shutdown_receiver) = channel(); + let shutdown_guard = ShutdownOnDrop(Some(shutdown_sender)); + let worker = spawn_blocking(move || { + serve_local( + app, + native_listener, + enable_ctrl_c, + ingress, + stores, + shutdown_receiver, + ) }); + let result = worker.await.context("axum server thread failed")?; + drop(shutdown_guard); + result +} - let server = axum::serve(listener, make_service); - if let Some(shutdown_signal) = shutdown { - let graceful_server = server.with_graceful_shutdown(shutdown_signal); - graceful_server.await.context("axum server error")?; - } else { - server.await.context("axum server error")?; +fn serve_local( + app: App, + std_listener: StdTcpListener, + enable_ctrl_c: bool, + ingress: AxumIngressConfig, + stores: Stores, + shutdown_receiver: ShutdownReceiver<()>, +) -> anyhow::Result<()> { + let mut service = AxumServiceState::from_app(app); + if let Some(registry) = stores.config_registry { + service = service.with_config_registry(registry); + } + if let Some(handle) = stores.config_store { + service = service.with_config_store_handle(handle); + } + if let Some(registry) = stores.kv_registry { + service = service.with_kv_registry(registry); } + if let Some(handle) = stores.kv { + service = service.with_kv_handle(handle); + } + if let Some(registry) = stores.secret_registry { + service = service.with_secret_registry(registry); + } + if let Some(handle) = stores.secrets { + service = service.with_secret_handle(handle); + } + + let runtime = RuntimeBuilder::new_current_thread() + .enable_all() + .build() + .context("failed to build axum connection runtime")?; + let local = LocalSet::new(); + #[expect( + clippy::integer_division_remainder_used, + reason = "tokio::select! expands to internal randomized branch selection arithmetic" + )] + let serve = async move { + let listener = TokioTcpListener::from_std(std_listener) + .context("failed to adopt listener on axum connection runtime")?; + let shutdown = async move { + let _closed = shutdown_receiver.await; + }; + let ctrl_c = async move { + if enable_ctrl_c { + let _signal = signal::ctrl_c().await; + } else { + future::pending::<()>().await; + } + }; + tokio::pin!(shutdown); + tokio::pin!(ctrl_c); + let mut connections = FuturesUnordered::new(); + + loop { + tokio::select! { + biased; + () = &mut shutdown => break, + () = &mut ctrl_c => break, + Some(result) = connections.next(), if !connections.is_empty() => { + report_connection_exit(&result); + } + accepted = listener.accept() => { + let (stream, remote_addr) = accepted.context("axum listener accept failed")?; + // One transient accepted socket; no parser, task or waiter on refusal. + if connections.len() >= ingress.max_connections() { + drop(stream); + continue; + } + let responses = EgressConnection::default(); + let connection_service = service.for_connection(remote_addr, responses.clone()); + connections.push(AssertUnwindSafe(serve_http1(stream, connection_service, responses, ingress)).catch_unwind()); + } + } + } + // Dropping the bounded owner releases sockets, parser buffers and egress attempts. + drop(connections); + Ok(()) + }; + runtime.block_on(local.run_until(serve)) +} - Ok(()) +fn report_connection_exit(result: &ConnectionResult) { + if !matches!(result, Ok(Ok(_))) { + log::debug!("axum HTTP/1 connection rejected or disconnected"); + } } /// Entry point for an Axum dev-server application. @@ -329,29 +433,63 @@ async fn serve_with_stores( /// /// # Errors /// Returns an error if the dev server fails to bind or any required store handle cannot be initialised. +fn build_app_for_dispatch() -> anyhow::Result { + App::build::(crate::AXUM_PLATFORM).context("application configuration failed") +} + +fn app_logger(env: &EnvConfig) -> SimpleLogger { + SimpleLogger::new() + .with_level(resolve_logging_level(env)) + .with_module_level(BOOT_LOG_TARGET, BOOT_LOG_LEVEL) +} + +/// Runs an application with the Axum development server. +/// +/// # Errors +/// Returns an error if logger setup, application configuration, runtime setup, store initialization, listener +/// binding, or connection serving fails. #[inline] pub fn run_app() -> anyhow::Result<()> { - let env = EnvConfig::from_env(); - let stores = A::stores(); - let kv_init_requirement = kv_init_requirement(stores); - - let level = env - .logging_level() - .and_then(|raw| LevelFilter::from_str(raw).ok()) - .unwrap_or(LevelFilter::Info); + run_app_with_preflight::(|| Ok(())) +} +/// Run a startup check after logger initialization and before application configuration. +/// +/// Useful for capability validation in embedded runners. Applications that own logging +/// must install their backend and filters before calling this function. +/// +/// # Errors +/// Returns an error if logger setup, `preflight`, application configuration, runtime setup, +/// store initialization, listener binding, or connection serving fails. +#[inline] +pub fn run_app_with_preflight(preflight: F) -> anyhow::Result<()> +where + A: Hooks, + F: FnOnce() -> anyhow::Result<()>, +{ + let env = EnvConfig::from_env(); if !A::owns_logging() { - let _logger_init = SimpleLogger::new().with_level(level).init(); + app_logger(&env) + .init() + .context("failed to initialize application logger")?; } + let ingress = AxumIngressConfig::from_env(&env)?; + let config_limits = ConfigStoreLimits::from_env(&env)?; + log::info!("[edgezero] axum HTTP/1 ingress limits: {ingress:?}"); + log::info!("[edgezero] axum config snapshot limits: {config_limits:?}"); + preflight()?; + let app = build_app_for_dispatch::()?; + let stores = A::stores(); + let kv_init_requirement = kv_init_requirement(stores); + let kv_registry = build_kv_registry(stores.kv, &env, kv_init_requirement)?; + let config_registry = build_config_registry(stores.config, &env, config_limits)?; + let secret_registry = build_secret_registry(stores.secrets, &env); let resolution = resolve_addr(&env); for warning in &resolution.warnings { - log::warn!("{warning}"); + log::warn!(target: BOOT_LOG_TARGET, "{warning}"); } let addr = resolution.addr; - let app = A::build_app(); - let router = app.router().clone(); - log::info!("[edgezero] starting axum server on http://{addr}"); let runtime = RuntimeBuilder::new_multi_thread() @@ -368,17 +506,13 @@ pub fn run_app() -> anyhow::Result<()> { let listener = TokioTcpListener::from_std(std_listener) .context("failed to adopt std listener into tokio")?; - let kv_registry = build_kv_registry(stores.kv, &env, kv_init_requirement)?; - let config_registry = build_config_registry(stores.config, &env); - let secret_registry = build_secret_registry(stores.secrets, &env); - let request_stores = Stores { config_registry, kv_registry, secret_registry, ..Stores::default() }; - serve_with_stores(router, listener, true, request_stores).await + serve_with_stores(app, listener, true, ingress, request_stores).await }) } @@ -405,6 +539,7 @@ fn build_kv_registry( Err(err) => match init { KvInitRequirement::Optional => { log::warn!( + target: BOOT_LOG_TARGET, "KV store '{}' (id `{}`) could not be initialized at {}: {}", store_name, id, @@ -429,6 +564,7 @@ fn build_kv_registry( let default_id = meta.default.to_owned(); if !by_id.contains_key(&default_id) { log::warn!( + target: BOOT_LOG_TARGET, "KV registry default id `{default_id}` failed to initialize; dropping the KV registry — \ handlers will see no KV store" ); @@ -441,44 +577,69 @@ fn build_kv_registry( /// Each declared id reads `.edgezero/local-config-.json`. A missing /// file yields an empty store for that id — the dev server stays usable /// before any `config push` has populated the file. A malformed file logs a -/// warning and the id is dropped from the registry rather than failing -/// startup, matching the cloudflare config-binding behaviour. +/// warning and preserves a typed failing binding. Allocation violations fail +/// startup before any listener is bound. Snapshots load sequentially. fn build_config_registry( config_meta: Option, env: &EnvConfig, -) -> Option { - let meta = config_meta?; + limits: ConfigStoreLimits, +) -> Result, ConfigStoreError> { + build_config_registry_at(config_meta, env, limits, AxumConfigStore::local_path) +} + +fn build_config_registry_at( + config_meta: Option, + env: &EnvConfig, + limits: ConfigStoreLimits, + path_for: impl Fn(&str) -> PathBuf, +) -> Result, ConfigStoreError> { + let Some(meta) = config_meta else { + return Ok(None); + }; + if meta.ids.is_empty() + || !meta.ids.contains(&meta.default) + || meta.ids.iter().enumerate().any(|(index, id)| { + id.is_empty() + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + || meta.ids.iter().take(index).any(|previous| previous == id) + }) + { + return Err(ConfigStoreError::internal(anyhow::anyhow!( + "invalid declared config-store metadata" + ))); + } let mut by_id: BTreeMap = BTreeMap::new(); + let mut resident = 0_usize; for id in meta.ids { - let store = match AxumConfigStore::from_local_file(id) { - Ok(store) => store, - Err(err) => { + let handle = match AxumConfigStore::load_at_startup(&path_for(id), limits, resident) { + Ok(store) => { + resident = resident + .checked_add(store.resident_allocation_bytes()) + .ok_or(ConfigStoreError::ValueTooLarge)?; + ConfigStoreHandle::new(Arc::new(store)) + } + Err(ConfigStoreError::ValueTooLarge) => return Err(ConfigStoreError::ValueTooLarge), + Err(_error) => { log::warn!( - "config store for id `{}` could not be loaded from {}: {}; \ - dropping this id from the registry", - id, - AxumConfigStore::local_path(id).display(), - err + target: BOOT_LOG_TARGET, + "config snapshot unavailable; retaining a failed declared binding" ); - continue; + ConfigStoreHandle::failed_open(ConfigStoreOpenFailure::Unavailable) } }; by_id.insert( (*id).to_owned(), ConfigStoreBinding { - handle: ConfigStoreHandle::new(Arc::new(store)), + handle, default_key: env.store_key("config", id), }, ); } let default_id = meta.default.to_owned(); - if !by_id.contains_key(&default_id) { - log::warn!( - "config registry default id `{default_id}` failed to load; dropping the config registry — \ - handlers will see no config store" - ); - } - StoreRegistry::from_parts(by_id, default_id) + log::info!("[edgezero] axum config snapshot resident charge: {resident} bytes"); + Ok(StoreRegistry::from_parts(by_id, default_id)) } /// Build the per-request secret registry. Axum is `Single` for secrets — every @@ -520,7 +681,242 @@ pub(crate) fn resolve_addr(env: &EnvConfig) -> addr::BindAddrResolution { #[cfg(test)] mod tests { use super::*; + use edgezero_core::error::EdgeError; + use futures::executor::block_on; + use log::LevelFilter; + use std::env; use std::net::{IpAddr, Ipv4Addr}; + use std::process::Command; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct FailingConfiguration; + + struct LoggingConfiguration; + + struct OwnedLoggingConfiguration; + + struct PlatformAwareConfiguration; + + #[expect( + clippy::missing_trait_methods, + reason = "test hook exercises only adapter startup failure" + )] + impl Hooks for FailingConfiguration { + fn configure(_app: &mut App) -> Result<(), EdgeError> { + Err(EdgeError::service_unavailable("configuration unavailable")) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[expect( + clippy::missing_trait_methods, + reason = "test hook probes startup logging" + )] + impl Hooks for LoggingConfiguration { + fn configure(_app: &mut App) -> Result<(), EdgeError> { + log::warn!(target: edgezero_core::BOOT_LOG_TARGET, "boot-warning-probe"); + log::info!(target: edgezero_core::BOOT_LOG_TARGET, "boot-info-probe"); + log::warn!("runtime-warning-probe"); + log::debug!("runtime-debug-probe"); + Err(EdgeError::service_unavailable("stop before listening")) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[expect( + clippy::missing_trait_methods, + reason = "test hook probes application logger ownership" + )] + impl Hooks for OwnedLoggingConfiguration { + fn configure(app: &mut App) -> Result<(), EdgeError> { + LoggingConfiguration::configure(app) + } + + fn owns_logging() -> bool { + true + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[expect( + clippy::missing_trait_methods, + reason = "test hook exercises only target metadata propagation" + )] + impl Hooks for PlatformAwareConfiguration { + fn configure(app: &mut App) -> Result<(), EdgeError> { + if app.platform() == crate::AXUM_PLATFORM { + Ok(()) + } else { + Err(EdgeError::service_unavailable("wrong application platform")) + } + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + fn logging_probe(scenario: &str, level: &str) -> String { + let output = Command::new(env::current_exe().expect("test executable")) + .args([ + "--exact", + "dev_server::tests::startup_logging_child", + "--nocapture", + ]) + .env("EDGEZERO_TEST_LOGGING_SCENARIO", scenario) + .env("EDGEZERO__LOGGING__LEVEL", level) + .env_remove("EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS") + .env_remove("EDGEZERO__ADAPTER__INGRESS__MAX_RAW_HEAD_BYTES") + .env_remove("EDGEZERO__ADAPTER__INGRESS__MAX_HEADER_COUNT") + .env_remove("EDGEZERO__ADAPTER__INGRESS__HEAD_READ_TIMEOUT_MS") + .envs( + (scenario == "invalid-ingress") + .then_some(("EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS", "0")), + ) + .output() + .expect("isolated logging probe"); + let stdout = String::from_utf8(output.stdout).expect("probe stdout"); + let stderr = String::from_utf8(output.stderr).expect("probe stderr"); + assert!(output.status.success(), "{stdout}\n{stderr}"); + assert!( + stdout.contains("1 passed"), + "child probe must execute: {stdout}" + ); + format!("{stdout}\n{stderr}") + } + + #[test] + fn startup_logging_child() { + let Ok(scenario) = env::var("EDGEZERO_TEST_LOGGING_SCENARIO") else { + return; + }; + let result = match scenario.as_str() { + "invalid-ingress" => { + let result = run_app_with_preflight::(|| { + panic!( + "invalid ingress must fail before preflight or application configuration" + ) + }); + assert_eq!( + result + .expect_err("invalid ingress must stop startup") + .to_string(), + "invalid Axum ingress setting `max_connections`" + ); + return; + } + "installed" => { + SimpleLogger::new() + .with_level(LevelFilter::Trace) + .init() + .expect("existing logger"); + let result = run_app_with_preflight::(|| { + log::warn!(target: BOOT_LOG_TARGET, "unexpected-preflight-probe"); + Ok(()) + }); + assert_eq!( + result + .expect_err("logger conflict must stop startup") + .to_string(), + "failed to initialize application logger" + ); + return; + } + "owned" => { + SimpleLogger::new() + .with_level(LevelFilter::Trace) + .init() + .expect("owned logger"); + log::set_max_level(LevelFilter::Debug); + let result = run_app::(); + assert_eq!(log::max_level(), LevelFilter::Debug); + result + } + "preflight" => run_app_with_preflight::(|| { + log::warn!(target: edgezero_core::BOOT_LOG_TARGET, "preflight-warning-probe"); + anyhow::bail!("preflight stopped startup"); + }), + _ => run_app::(), + }; + assert!(result.is_err(), "probe must stop before listener setup"); + } + + #[test] + fn invalid_ingress_stops_before_preflight_configuration_and_binding() { + let output = logging_probe("invalid-ingress", "info"); + assert!(!output.contains("boot-warning-probe"), "{output}"); + } + + #[test] + fn logging_is_ready_before_configure_with_separate_boot_filter() { + for level in ["off", "error", "trace"] { + let output = logging_probe("managed", level); + assert!(output.contains("boot-warning-probe"), "{output}"); + assert!(!output.contains("boot-info-probe"), "{output}"); + assert_eq!( + output.contains("runtime-warning-probe"), + level == "trace", + "{output}" + ); + assert_eq!( + output.contains("runtime-debug-probe"), + level == "trace", + "{output}" + ); + } + } + + #[test] + fn owned_logging_preserves_backend_and_facade_filter() { + let output = logging_probe("owned", "off"); + assert!(output.contains("runtime-debug-probe"), "{output}"); + assert!( + output.contains("boot-info-probe"), + "application owns boot filtering: {output}" + ); + } + + #[test] + fn preflight_runs_after_logger_and_before_configuration() { + let output = logging_probe("preflight", "off"); + assert!(output.contains("preflight-warning-probe"), "{output}"); + assert!(!output.contains("boot-warning-probe"), "{output}"); + } + + #[test] + fn logger_conflict_prevents_preflight_and_configuration() { + let output = logging_probe("installed", "trace"); + assert!(!output.contains("unexpected-preflight-probe"), "{output}"); + assert!(!output.contains("boot-warning-probe"), "{output}"); + } + + #[test] + fn failing_configuration_prevents_listener_boundary() { + let bind_calls = AtomicUsize::new(0); + let result = build_app_for_dispatch::().map(|_app| { + bind_calls.fetch_add(1, Ordering::SeqCst); + }); + + let error = result.expect_err("configuration must fail before listener bind"); + assert_eq!(error.to_string(), "application configuration failed"); + assert_eq!(bind_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn application_configuration_receives_axum_platform_metadata() { + let app = build_app_for_dispatch::().expect("configured app"); + + assert_eq!(app.platform(), crate::AXUM_PLATFORM); + } #[test] fn default_config_uses_expected_address() { @@ -549,6 +945,7 @@ mod tests { let config = AxumDevServerConfig { addr, enable_ctrl_c: false, + ingress: AxumIngressConfig::default(), }; assert_eq!(config.addr.ip(), IpAddr::V4(Ipv4Addr::UNSPECIFIED)); assert_eq!(config.addr.port(), 3000); @@ -573,6 +970,7 @@ mod tests { let config = AxumDevServerConfig { addr: SocketAddr::from(([127, 0, 0, 1], 9000)), enable_ctrl_c: false, + ingress: AxumIngressConfig::default(), }; let server = AxumDevServer::with_config(router, config); assert_eq!(server.config.addr.port(), 9000); @@ -713,7 +1111,9 @@ mod tests { default: "app_config", ids: &["app_config"], }; - let registry = build_config_registry(Some(meta), &env).expect("registry built"); + let registry = build_config_registry(Some(meta), &env, ConfigStoreLimits::default()) + .expect("load") + .expect("registry built"); let binding = registry.named("app_config").expect("binding registered"); assert_eq!(binding.default_key, "app_config_staging"); } @@ -726,10 +1126,644 @@ mod tests { default: "app_config", ids: &["app_config"], }; - let registry = build_config_registry(Some(meta), &env).expect("registry built"); + let registry = build_config_registry(Some(meta), &env, ConfigStoreLimits::default()) + .expect("load") + .expect("registry built"); let binding = registry.named("app_config").expect("binding registered"); assert_eq!(binding.default_key, "app_config"); } + + #[test] + fn malformed_declared_snapshot_is_not_an_absent_binding() { + let directory = tempfile::tempdir().expect("directory"); + let path = directory.path().join("config.json"); + fs::write(&path, "credential=private invalid JSON").expect("fixture"); + let meta = StoreMetadata { + default: "config", + ids: &["config"], + }; + let registry = build_config_registry_at( + Some(meta), + &EnvConfig::default(), + ConfigStoreLimits::default(), + |_id| path.clone(), + ) + .expect("failed binding") + .expect("registry"); + let binding = registry.named("config").expect("declared binding survives"); + let error = block_on(binding.handle.get("key")).expect_err("unavailable, not absent"); + assert!(matches!(error, ConfigStoreError::Unavailable { .. })); + assert!(!error.to_string().contains("credential=private")); + } + + #[test] + fn aggregate_snapshot_overflow_fails_startup_and_retry_recovers() { + let directory = tempfile::tempdir().expect("directory"); + let path = directory.path().join("config.json"); + fs::write(&path, serde_json::json!({"k": "v"}).to_string()).expect("fixture"); + let limits = ConfigStoreLimits::new(128, 1, 1, 1, 4096, 8192).expect("limits"); + let charge = AxumConfigStore::from_path(&path, limits) + .expect("snapshot") + .resident_allocation_bytes(); + let capped = ConfigStoreLimits::new( + 128, + 1, + 1, + 1, + charge.saturating_mul(2).saturating_sub(1), + 8192, + ) + .expect("limits"); + let meta = StoreMetadata { + default: "first", + ids: &["first", "second"], + }; + assert!(matches!( + build_config_registry_at(Some(meta), &EnvConfig::default(), capped, |_id| path + .clone()), + Err(ConfigStoreError::ValueTooLarge) + )); + let registry = build_config_registry_at(Some(meta), &EnvConfig::default(), limits, |_id| { + path.clone() + }) + .expect("retry") + .expect("registry"); + assert!(registry.named("first").is_some()); + assert!(registry.named("second").is_some()); + } + + #[test] + fn invalid_config_registry_metadata_fails_without_opening_stores() { + for meta in [ + StoreMetadata { + default: "first", + ids: &[], + }, + StoreMetadata { + default: "absent", + ids: &["first"], + }, + StoreMetadata { + default: "first", + ids: &["first", "first"], + }, + StoreMetadata { + default: "bad/id", + ids: &["bad/id"], + }, + ] { + let error = build_config_registry_at( + Some(meta), + &EnvConfig::default(), + ConfigStoreLimits::default(), + |_id| panic!("must not open invalid metadata"), + ) + .expect_err("invalid metadata"); + assert!(matches!(error, ConfigStoreError::Internal { .. })); + } + } +} + +#[cfg(test)] +mod bounded_ingress_tests { + use std::future::pending; + use std::net::SocketAddr; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + use bytes::Bytes; + use edgezero_core::app::App; + use edgezero_core::body::Body; + use edgezero_core::error::EdgeError; + use edgezero_core::http::response_builder; + use edgezero_core::ingress::{AdmissionDecision, IngressGrant}; + use edgezero_core::response_egress::{ResponseEgressCompletion, ResponseEgressOutcome}; + use edgezero_core::router::RouterService; + use edgezero_core::{Deadline, MonotonicClock, MonotonicInstant, ResponseEgressPolicy}; + use futures_util::stream::poll_fn; + use std::task::Poll; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + use tokio::net::{TcpListener, TcpStream}; + use tokio::task::{JoinHandle, yield_now}; + use tokio::time::{Instant, sleep, timeout}; + + use super::{AxumDevServer, AxumDevServerConfig}; + use crate::ingress_config::AxumIngressConfig; + + struct Server { + address: SocketAddr, + calls: Arc, + panics: Arc, + task: JoinHandle>, + } + + struct DropProbe(Arc); + + #[derive(Clone, Default)] + struct EgressProbes { + completions: Arc, + polls: Arc, + resources: Arc, + sources: Arc, + } + + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } + } + + async fn closed(stream: &mut TcpStream) { + let mut byte = [0]; + let read = timeout(Duration::from_secs(2), stream.read(&mut byte)) + .await + .expect("connection must close"); + assert!( + matches!(read, Ok(0) | Err(_)), + "refusal must not send a response" + ); + } + + async fn healthy(stream: &mut TcpStream) { + stream + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + let read = async { + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(stream.read_u8().await.unwrap()); + assert!(head.len() < 4096); + } + assert!(head.starts_with(b"HTTP/1.1 200")); + let mut body = [0; 2]; + stream.read_exact(&mut body).await.unwrap(); + assert_eq!(&body, b"ok"); + }; + timeout(Duration::from_secs(2), read).await.unwrap(); + } + + fn stalled_egress_app( + ready: bool, + expected: ResponseEgressOutcome, + probes: &EgressProbes, + ) -> App { + let sources = Arc::clone(&probes.sources); + let polls = Arc::clone(&probes.polls); + let chunk = Bytes::from(vec![b'a'; 0x0004_0000]); + let router = RouterService::builder() + .get("/", |_| async { Ok::<_, EdgeError>("ok") }) + .get("/slow", move |_| { + let source = DropProbe(Arc::clone(&sources)); + let observed_polls = Arc::clone(&polls); + let payload = chunk.clone(); + async move { + let body = poll_fn(move |_cx| { + let _keep_alive = &source; + let polled = observed_polls.fetch_add(1, Ordering::SeqCst); + if ready || polled == 0 { + Poll::Ready(Some(Ok(payload.clone()))) + } else { + Poll::Pending + } + }); + Ok::<_, EdgeError>( + response_builder() + .body(Body::from_stream(body)) + .expect("response"), + ) + } + }) + .build(); + let mut app = App::new(router); + let frozen = MonotonicInstant::now(); + app.set_monotonic_clock(MonotonicClock::new(move || frozen)); + let budget = if expected == ResponseEgressOutcome::TransportError { + Duration::from_secs(1) + } else { + Duration::from_millis(100) + }; + app.set_response_egress_policy(move |_, started| ResponseEgressPolicy { + write_deadline: Deadline::at_instant(started.checked_add(budget).expect("deadline")), + }); + let completions = Arc::clone(&probes.completions); + let resources = Arc::clone(&probes.resources); + app.set_ingress_admission_policy(move |head| { + let completion = if head.target().path() == "/slow" { + ResponseEgressCompletion::new({ + let callback = Arc::clone(&completions); + let resource = DropProbe(Arc::clone(&resources)); + move |report| { + assert_eq!(report.outcome, expected); + callback.fetch_add(1, Ordering::SeqCst); + drop(resource); + } + }) + } else { + ResponseEgressCompletion::empty() + }; + AdmissionDecision::Admit { + completion, + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + } + }); + app + } + + #[tokio::test] + async fn stalled_egress_termination_releases_capacity_and_recovers() { + for (ready, disconnect) in [(false, true), (false, false), (true, false)] { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener"); + let address = listener.local_addr().expect("address"); + let probes = EgressProbes::default(); + let expected = if disconnect { + ResponseEgressOutcome::TransportError + } else { + ResponseEgressOutcome::DeadlineExceeded + }; + let app = stalled_egress_app(ready, expected, &probes); + let task = tokio::spawn(super::serve_with_stores( + app, + listener, + false, + AxumIngressConfig::new(1, 8192, 100, Duration::from_secs(5)).expect("ingress"), + super::Stores::default(), + )); + let mut client = TcpStream::connect(address).await.expect("client"); + client + .write_all(b"GET /slow HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .expect("request"); + timeout(Duration::from_secs(2), async { + while probes.polls.load(Ordering::SeqCst) < 2 { + yield_now().await; + } + }) + .await + .expect("producer is live"); + if disconnect { + drop(client); + } + timeout(Duration::from_secs(2), async { + while probes.completions.load(Ordering::SeqCst) != 1 { + yield_now().await; + } + }) + .await + .expect("terminal callback"); + assert_eq!(probes.resources.load(Ordering::SeqCst), 1); + assert_eq!(probes.sources.load(Ordering::SeqCst), 1); + let stopped = probes.polls.load(Ordering::SeqCst); + timeout(Duration::from_secs(2), async { + loop { + let mut recovered = TcpStream::connect(address) + .await + .expect("recovery connection"); + if recovered + .write_all( + b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + ) + .await + .is_ok() + { + let mut wire = Vec::new(); + if recovered.read_to_end(&mut wire).await.is_ok() && wire.ends_with(b"ok") { + break; + } + } + yield_now().await; + } + }) + .await + .expect("capacity recovery"); + assert_eq!( + probes.polls.load(Ordering::SeqCst), + stopped, + "terminated sources must stop" + ); + assert_eq!(probes.completions.load(Ordering::SeqCst), 1); + task.abort(); + let _shutdown = task.await; + } + } + + async fn start(connections: usize, milliseconds: u64, count: usize) -> Server { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&calls); + let panics = Arc::new(AtomicUsize::new(0)); + let observed_panics = Arc::clone(&panics); + let router = RouterService::builder() + .get("/panic", move |_| { + let counted = Arc::clone(&observed_panics); + async move { + counted.fetch_add(1, Ordering::SeqCst); + panic!("isolated connection failure"); + #[expect( + unreachable_code, + reason = "sets the handler response type after the intentional panic" + )] + Ok::<_, EdgeError>("unreachable") + } + }) + .get("/", move |_| { + let counted = Arc::clone(&observed); + async move { + counted.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("ok") + } + }) + .build(); + let server = AxumDevServer::with_config( + router, + AxumDevServerConfig { + addr: address, + enable_ctrl_c: false, + ingress: AxumIngressConfig::new( + connections, + 8192, + count, + Duration::from_millis(milliseconds), + ) + .unwrap(), + }, + ); + let task = tokio::spawn(server.run_with_listener(listener)); + Server { + address, + calls, + panics, + task, + } + } + + #[tokio::test] + async fn parser_and_unwinding_failures_release_capacity_and_leave_server_healthy() { + let server = start(1, 10_000, 100).await; + for request in [ + &b"invalid\r\n\r\n"[..], + &b"GET /panic HTTP/1.1\r\nHost: localhost\r\n\r\n"[..], + ] { + let mut failed = TcpStream::connect(server.address).await.unwrap(); + failed.write_all(request).await.unwrap(); + let mut response = Vec::new(); + let _read = timeout(Duration::from_secs(2), failed.read_to_end(&mut response)) + .await + .unwrap(); + assert!(!response.starts_with(b"HTTP/1.1 200")); + let mut recovered = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut recovered).await; + drop(recovered); + } + assert_eq!(server.calls.load(Ordering::SeqCst), 2); + assert_eq!(server.panics.load(Ordering::SeqCst), 1); + assert!(!server.task.is_finished()); + } + + #[tokio::test] + async fn shutdown_releases_pending_handler_stream_and_completion_resources() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let started = Arc::new(AtomicUsize::new(0)); + let released = Arc::new(AtomicUsize::new(0)); + let resources = Arc::new(AtomicUsize::new(0)); + let terminals = Arc::new(AtomicUsize::new(0)); + let handler_started = Arc::clone(&started); + let handler_released = Arc::clone(&released); + let stream_started = Arc::clone(&started); + let stream_released = Arc::clone(&released); + let router = RouterService::builder() + .get("/handler", move |_| { + let entered = Arc::clone(&handler_started); + let guard = DropProbe(Arc::clone(&handler_released)); + async move { + entered.fetch_add(1, Ordering::SeqCst); + pending::<()>().await; + drop(guard); + Ok::<_, EdgeError>("unreachable") + } + }) + .get("/stream", move |_| { + let entered = Arc::clone(&stream_started); + let guard = DropProbe(Arc::clone(&stream_released)); + async move { + let body = poll_fn(move |_cx| { + let _keep_alive = &guard; + entered.store(2, Ordering::SeqCst); + Poll::>>::Pending + }); + Ok::<_, EdgeError>(response_builder().body(Body::from_stream(body)).unwrap()) + } + }) + .build(); + let mut app = App::new(router); + let completion_resources = Arc::clone(&resources); + let completion_terminals = Arc::clone(&terminals); + app.set_ingress_admission_policy(move |head| AdmissionDecision::Admit { + completion: ResponseEgressCompletion::new({ + let resource = DropProbe(Arc::clone(&completion_resources)); + let observed = Arc::clone(&completion_terminals); + move |report| { + assert_eq!(report.outcome, ResponseEgressOutcome::TransportError); + observed.fetch_add(1, Ordering::SeqCst); + drop(resource); + } + }), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(10)), + }); + let task = tokio::spawn(super::serve_with_stores( + app, + listener, + false, + AxumIngressConfig::new(2, 8192, 100, Duration::from_secs(10)).unwrap(), + super::Stores::default(), + )); + let mut handler = TcpStream::connect(address).await.unwrap(); + handler + .write_all(b"GET /handler HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + timeout(Duration::from_secs(2), async { + while started.load(Ordering::SeqCst) != 1 { + yield_now().await; + } + }) + .await + .unwrap(); + let mut stream = TcpStream::connect(address).await.unwrap(); + stream + .write_all(b"GET /stream HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + let mut head = Vec::new(); + timeout(Duration::from_secs(2), async { + while !head.ends_with(b"\r\n\r\n") { + head.push(stream.read_u8().await.unwrap()); + assert!(head.len() < 4096); + } + }) + .await + .unwrap(); + assert!(head.starts_with(b"HTTP/1.1 200")); + assert_eq!(started.load(Ordering::SeqCst), 2); + assert_eq!(released.load(Ordering::SeqCst), 0); + assert_eq!(resources.load(Ordering::SeqCst), 0); + task.abort(); + closed(&mut handler).await; + closed(&mut stream).await; + timeout(Duration::from_secs(2), async { + while released.load(Ordering::SeqCst) != 2 || resources.load(Ordering::SeqCst) != 2 { + yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(terminals.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn saturation_refuses_without_queueing_and_disconnect_recovers() { + let server = start(2, 10_000, 100).await; + let mut first = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut first).await; + let mut second = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut second).await; + for _ in 0_usize..64 { + let mut refused = TcpStream::connect(server.address).await.unwrap(); + closed(&mut refused).await; + } + assert_eq!(server.calls.load(Ordering::SeqCst), 2); + drop(first); + // Wait for disconnect processing without depending on an arbitrary sleep. + let recover = async { + loop { + let mut stream = TcpStream::connect(server.address).await.unwrap(); + let written = stream + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .await; + let mut response = Vec::new(); + let read = stream.read_to_end(&mut response).await; + if written.is_ok() && read.is_ok() && response.starts_with(b"HTTP/1.1 200") { + break; + } + yield_now().await; + } + }; + timeout(Duration::from_secs(2), recover).await.unwrap(); + healthy(&mut second).await; + assert_eq!(server.calls.load(Ordering::SeqCst), 4); + } + + #[tokio::test] + async fn silence_and_trickling_initial_and_keepalive_heads_expire() { + let server = start(1, 150, 100).await; + let mut silent = TcpStream::connect(server.address).await.unwrap(); + closed(&mut silent).await; + for keepalive in [false, true] { + let mut stream = TcpStream::connect(server.address).await.unwrap(); + if keepalive { + healthy(&mut stream).await; + } + stream + .write_all(b"GET / HTTP/1.1\r\nX-Trickle: ") + .await + .unwrap(); + let started = Instant::now(); + let trickle = async { + loop { + sleep(Duration::from_millis(10)).await; + if stream.write_all(b"a").await.is_err() { + break; + } + let mut byte = [0]; + if let Ok(read) = + timeout(Duration::from_millis(1), stream.peek(&mut byte)).await + && !matches!(read, Ok(1..)) + { + break; + } + } + }; + timeout(Duration::from_secs(2), trickle) + .await + .expect("trickle must not extend deadline"); + assert!(started.elapsed() < Duration::from_secs(2)); + closed(&mut stream).await; + } + let mut recovered = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut recovered).await; + assert_eq!(server.calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn idle_keepalive_timeout_releases_capacity() { + let server = start(1, 100, 100).await; + let mut idle = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut idle).await; + closed(&mut idle).await; + let mut recovered = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut recovered).await; + } + + #[tokio::test] + async fn raw_count_duplicates_and_byte_boundaries_precede_dispatch() { + let server = start(1, 10_000, 4).await; + let prefix = b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nX-Pad: "; + let mut exact = prefix.to_vec(); + exact.extend(vec![b'a'; 8192 - prefix.len() - 4]); + exact.extend_from_slice(b"\r\n\r\n"); + let mut over = exact.clone(); + over.insert(prefix.len(), b'a'); + let count_exact = b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nX-Dup: a\r\nX-Dup: b\r\n\r\n".to_vec(); + let count_over = b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nX-Dup: a\r\nX-Dup: b\r\nX-Dup: c\r\n\r\n".to_vec(); + let mut long_line = b"GET /".to_vec(); + long_line.extend(vec![b'a'; 8192]); + long_line.extend_from_slice(b" HTTP/1.1\r\nHost: localhost\r\n\r\n"); + for (request, expected) in [ + (exact, b"HTTP/1.1 200"), + (over, b"HTTP/1.1 431"), + (count_exact, b"HTTP/1.1 200"), + (count_over, b"HTTP/1.1 431"), + (long_line, b"HTTP/1.1 431"), + ] { + let mut stream = TcpStream::connect(server.address).await.unwrap(); + stream.write_all(&request).await.unwrap(); + let mut response = Vec::new(); + let _read = timeout(Duration::from_secs(2), stream.read_to_end(&mut response)) + .await + .unwrap(); + assert!(response.starts_with(expected), "unexpected response status"); + } + assert_eq!(server.calls.load(Ordering::SeqCst), 2); + let mut recovered = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut recovered).await; + } + + #[tokio::test] + async fn cancellation_closes_idle_and_partial_connections() { + let server = start(2, 10_000, 100).await; + let mut idle = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut idle).await; + let mut partial = TcpStream::connect(server.address).await.unwrap(); + healthy(&mut partial).await; + partial + .write_all(b"GET / HTTP/1.1\r\nX-Pending:") + .await + .unwrap(); + server.task.abort(); + closed(&mut idle).await; + closed(&mut partial).await; + } } #[cfg(test)] @@ -741,6 +1775,7 @@ mod integration_tests { use edgezero_core::extractor::Secrets; use edgezero_core::router::RouterService; use edgezero_core::secret_store::SecretHandle as CoreSecretHandle; + use reqwest::header::ALLOW; use std::time::{Duration, Instant}; use tokio::task::{JoinHandle, spawn_blocking}; use tokio::time::sleep; @@ -764,6 +1799,7 @@ mod integration_tests { let config = AxumDevServerConfig { addr, enable_ctrl_c: false, + ingress: AxumIngressConfig::default(), }; // Use a unique temp directory for each test server let temp_dir = tempfile::tempdir().expect("create temp dir"); @@ -851,6 +1887,7 @@ mod integration_tests { let response = send_with_retry(&client, |http_client| http_client.get(url.as_str())).await; assert_eq!(response.status(), reqwest::StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(response.headers().get(ALLOW).expect("Allow header"), "POST"); server.handle.abort(); } @@ -859,7 +1896,6 @@ mod integration_tests { async fn server_forwards_headers() { async fn handler(ctx: RequestContext) -> Result { let value = ctx - .request() .headers() .get("x-custom") .and_then(|val| val.to_str().ok()) @@ -894,6 +1930,7 @@ mod integration_tests { let config = AxumDevServerConfig { addr, enable_ctrl_c: false, + ingress: AxumIngressConfig::default(), }; let server = AxumDevServer::with_config(router, config); @@ -1121,6 +2158,7 @@ mod integration_tests { let config = super::AxumDevServerConfig { addr, enable_ctrl_c: false, + ingress: AxumIngressConfig::default(), }; let mut server = super::AxumDevServer::with_config(router, config); if let Some(handle) = secret_handle { @@ -1194,7 +2232,8 @@ mod integration_tests { ); let body = response.text().await.unwrap(); assert!(!body.contains("API_KEY")); - assert!(body.contains("required secret is not configured")); + assert!(body.contains("internal server error")); + assert!(!body.contains("required secret is not configured")); server.handle.abort(); } @@ -1215,7 +2254,8 @@ mod integration_tests { reqwest::StatusCode::INTERNAL_SERVER_ERROR ); let body = response.text().await.unwrap(); - assert!(body.contains( + assert!(body.contains("internal server error")); + assert!(!body.contains( "no secret store configured -- check [stores.secrets] in edgezero.toml and platform bindings" )); diff --git a/crates/edgezero-adapter-axum/src/header_deadline.rs b/crates/edgezero-adapter-axum/src/header_deadline.rs new file mode 100644 index 000000000..a2b307464 --- /dev/null +++ b/crates/edgezero-adapter-axum/src/header_deadline.rs @@ -0,0 +1,333 @@ +use std::error::Error as StdError; +use std::future::Future; +use std::io::{self, IoSlice}; +use std::pin::Pin; +use std::sync::{Arc, Mutex, PoisonError}; +use std::task::{Context, Poll}; +use std::time::{Duration, Instant}; + +use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue}; +use hyper::Request; +use hyper::body::Incoming; +use hyper::ext::ReasonPhrase; +use hyper::rt::{Sleep, Timer}; +use hyper::service::Service; +use hyper_util::rt::TokioTimer; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::TcpStream; +use tokio::sync::Notify; +use tokio::time::Instant as TokioInstant; + +use crate::ingress_config::AxumIngressConfig; +use crate::response::EgressConnection; +/// Shares Hyper's head-read phase with the connection's independent timer race. +/// A successful service call disarms it; parser-error writes retain the deadline. +#[derive(Clone)] +pub(crate) struct HeaderDeadline { + changed: Arc, + deadline: Arc>>, +} + +#[derive(Debug, thiserror::Error)] +#[error("HTTP/1 request head deadline exceeded")] +pub(crate) struct HeaderDeadlineExceeded; + +pub(crate) struct HeaderIo { + deadline: HeaderDeadline, + stream: TcpStream, +} + +pub(crate) struct HeaderService { + deadline: HeaderDeadline, + limits: AxumIngressConfig, + responses: EgressConnection, + service: S, +} + +#[derive(Debug, thiserror::Error)] +#[error("HTTP/1 response head exceeds adapter parser storage limits")] +struct ResponseHeadExceeded; + +impl HeaderDeadline { + pub(crate) async fn changed(&self) { + self.changed.notified().await; + } + + fn check_io(&self) -> io::Result<()> { + if self + .deadline() + .is_some_and(|limit| TokioInstant::now() >= limit) + { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + HeaderDeadlineExceeded, + )); + } + Ok(()) + } + + pub(crate) fn deadline(&self) -> Option { + *self.deadline.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub(crate) fn finish_head(&self, now: TokioInstant) -> Result<(), HeaderDeadlineExceeded> { + let mut deadline = self.deadline.lock().unwrap_or_else(PoisonError::into_inner); + if deadline.is_some_and(|limit| now >= limit) { + return Err(HeaderDeadlineExceeded); + } + *deadline = None; + self.changed.notify_one(); + Ok(()) + } + + pub(crate) fn new(timeout: Duration) -> Self { + let now = TokioInstant::now(); + Self { + changed: Arc::new(Notify::new()), + deadline: Arc::new(Mutex::new(Some(now.checked_add(timeout).unwrap_or(now)))), + } + } +} + +impl Timer for HeaderDeadline { + fn now(&self) -> Instant { + TokioInstant::now().into_std() + } + + fn reset(&self, sleep: &mut Pin>, new_deadline: Instant) { + *sleep = self.sleep_until(new_deadline); + } + + fn sleep(&self, duration: Duration) -> Pin> { + let now = self.now(); + self.sleep_until(now.checked_add(duration).unwrap_or(now)) + } + + fn sleep_until(&self, deadline: Instant) -> Pin> { + let requested = TokioInstant::from_std(deadline); + let mut state = self.deadline.lock().unwrap_or_else(PoisonError::into_inner); + let effective = state.map_or(requested, |existing| existing.min(requested)); + *state = Some(effective); + self.changed.notify_one(); + TokioTimer::new().sleep_until(effective.into_std()) + } +} + +impl HeaderIo { + pub(crate) fn new(stream: TcpStream, deadline: HeaderDeadline) -> Self { + Self { deadline, stream } + } +} + +impl AsyncRead for HeaderIo { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + this.deadline.check_io()?; + Pin::new(&mut this.stream).poll_read(cx, buf) + } +} + +impl AsyncWrite for HeaderIo { + fn is_write_vectored(&self) -> bool { + self.stream.is_write_vectored() + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + this.deadline.check_io()?; + Pin::new(&mut this.stream).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().stream).poll_shutdown(cx) + } + + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let this = self.get_mut(); + this.deadline.check_io()?; + Pin::new(&mut this.stream).poll_write(cx, buf) + } + + fn poll_write_vectored( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bufs: &[IoSlice<'_>], + ) -> Poll> { + let this = self.get_mut(); + this.deadline.check_io()?; + Pin::new(&mut this.stream).poll_write_vectored(cx, bufs) + } +} + +impl HeaderService { + pub(crate) fn new( + service: S, + deadline: HeaderDeadline, + limits: AxumIngressConfig, + responses: EgressConnection, + ) -> Self { + Self { + deadline, + limits, + responses, + service, + } + } +} + +impl Service> for HeaderService +where + S: Service, Response = hyper::Response>, + S::Error: Into>, + S::Future: 'static, + B: 'static, +{ + type Error = Box; + type Future = Pin>>>; + type Response = hyper::Response; + + fn call(&self, req: Request) -> Self::Future { + if let Err(error) = self.deadline.finish_head(TokioInstant::now()) { + let boxed_error: Self::Error = Box::new(error); + return Box::pin(async move { Err(boxed_error) }); + } + let future = self.service.call(req); + let limits = self.limits; + let responses = self.responses.clone(); + Box::pin(async move { + let mut response = future.await.map_err(Into::into)?; + if let Err(error) = bound_response_head(&mut response, limits) { + responses.signal_transport_error(); + let boxed_error: Self::Error = Box::new(error); + return Err(boxed_error); + } + Ok(response) + }) + } +} + +fn bound_response_head( + response: &mut hyper::Response, + limits: AxumIngressConfig, +) -> Result<(), ResponseHeadExceeded> { + let count = response.headers().len(); + let bytes = response + .headers() + .iter() + .try_fold(0_usize, |charged, (name, value)| { + charged + .checked_add(name.as_str().len())? + .checked_add(value.as_bytes().len())? + .checked_add(4) + }); + if count > limits.max_response_header_count() + || bytes.is_none_or(|total| total > limits.max_response_header_bytes()) + { + return Err(ResponseHeadExceeded); + } + // Discard application-supplied spare capacity before Hyper caches this map. + let mut compact = HeaderMap::with_capacity(count); + for (name, value) in response.headers() { + let copied_name = HeaderName::from_bytes(name.as_str().as_bytes()) + .map_err(|_invalid_name| ResponseHeadExceeded)?; + let mut copied_value = HeaderValue::from_bytes(value.as_bytes()) + .map_err(|_invalid_value| ResponseHeadExceeded)?; + copied_value.set_sensitive(value.is_sensitive()); + compact.append(copied_name, copied_value); + } + *response.headers_mut() = compact; + // Only canonical status phrases participate in the bounded encoded head. + response.extensions_mut().remove::(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use edgezero_core::http::{HeaderMap, HeaderValue}; + use hyper::ext::ReasonPhrase; + use hyper::rt::Timer as _; + use tokio::time::{Instant, advance}; + + use super::{HeaderDeadline, bound_response_head}; + use crate::ingress_config::AxumIngressConfig; + + #[test] + fn response_head_compacts_capacity_preserves_duplicates_and_rejects_overflow() { + let limits = AxumIngressConfig::default(); + let mut response = hyper::Response::new(()); + *response.headers_mut() = HeaderMap::with_capacity(0x4000); + response + .headers_mut() + .append("set-cookie", HeaderValue::from_static("a=1")); + let mut sensitive = HeaderValue::from_static("b=2"); + sensitive.set_sensitive(true); + response.headers_mut().append("set-cookie", sensitive); + response + .extensions_mut() + .insert(ReasonPhrase::from_static(b"custom")); + bound_response_head(&mut response, limits).unwrap(); + assert_eq!(response.headers().get_all("set-cookie").iter().count(), 2); + assert!(response.headers().capacity() < 100); + assert!( + response + .headers() + .get_all("set-cookie") + .iter() + .nth(1) + .unwrap() + .is_sensitive() + ); + assert!(response.extensions().get::().is_none()); + let oversized = HeaderValue::from_bytes(&vec![b'a'; limits.max_raw_head_bytes()]).unwrap(); + response.headers_mut().insert("x-large", oversized); + bound_response_head(&mut response, limits).unwrap_err(); + response.headers_mut().clear(); + for _ in 0..=limits.max_response_header_count() { + response + .headers_mut() + .append("x-dup", HeaderValue::from_static("a")); + } + bound_response_head(&mut response, limits).unwrap_err(); + } + + #[tokio::test(start_paused = true)] + async fn rearmed_header_timer_remains_in_tokio_clock_domain() { + let deadline = HeaderDeadline::new(Duration::from_secs(10)); + deadline.finish_head(Instant::now()).unwrap(); + advance(Duration::from_secs(20)).await; + drop(deadline.sleep(Duration::from_secs(10))); + assert_eq!( + deadline + .deadline() + .unwrap() + .checked_duration_since(Instant::now()), + Some(Duration::from_secs(10)) + ); + } + + #[tokio::test] + async fn first_arm_cannot_extend_accept_deadline_and_next_head_gets_new_budget() { + let timer = HeaderDeadline::new(Duration::from_millis(10)); + let accepted_deadline = timer.deadline().unwrap(); + let _sleep = timer.sleep_until((Instant::now() + Duration::from_secs(1)).into_std()); + assert_eq!(timer.deadline(), Some(accepted_deadline)); + timer + .finish_head(accepted_deadline - Duration::from_millis(1)) + .unwrap(); + assert_eq!(timer.deadline(), None); + let next = Instant::now() + Duration::from_secs(1); + let _next_sleep = timer.sleep_until(next.into_std()); + assert_eq!(timer.deadline(), Some(next)); + assert!(timer.finish_head(next).is_err()); + } +} diff --git a/crates/edgezero-adapter-axum/src/ingress_config.rs b/crates/edgezero-adapter-axum/src/ingress_config.rs new file mode 100644 index 000000000..08cccf72f --- /dev/null +++ b/crates/edgezero-adapter-axum/src/ingress_config.rs @@ -0,0 +1,280 @@ +//! Validated limits for Axum's adapter-owned HTTP/1 listener and parser. + +use std::time::Duration; + +use edgezero_core::env_config::EnvConfig; +use edgezero_core::http::{HeaderName, HeaderValue}; + +/// Effective HTTP/1 ingress limits, frozen before the listener starts. +/// +/// The raw-head budget includes the request line, field syntax, CRLFs and final +/// blank line. It bounds both the request line and headers jointly, not with +/// independent byte allowances. The head timeout also bounds keep-alive idle. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AxumIngressConfig { + head_read_timeout: Duration, + max_connections: usize, + max_header_count: usize, + max_raw_head_bytes: usize, +} + +/// A rejected setting. Diagnostics never include the supplied value. +#[derive(Debug, thiserror::Error)] +#[error("invalid Axum ingress setting `{field}`")] +pub struct IngressConfigError { + field: &'static str, +} + +impl Default for AxumIngressConfig { + #[inline] + fn default() -> Self { + Self { + head_read_timeout: Duration::from_secs(10), + max_connections: 256, + max_header_count: 100, + max_raw_head_bytes: 0x0001_0000, + } + } +} + +impl AxumIngressConfig { + /// Resolve `EDGEZERO__ADAPTER__INGRESS__*`; only missing settings use defaults. + /// + /// # Errors + /// Rejects malformed, zero, overflowing or out-of-range explicit settings. + #[inline] + pub fn from_env(env: &EnvConfig) -> Result { + let defaults = Self::default(); + let milliseconds = setting(env, "head_read_timeout_ms", 10_000)?; + let timeout_millis = + u64::try_from(milliseconds).map_err(|_overflow| IngressConfigError { + field: "head_read_timeout_ms", + })?; + Self::new( + setting(env, "max_connections", defaults.max_connections)?, + setting(env, "max_raw_head_bytes", defaults.max_raw_head_bytes)?, + setting(env, "max_header_count", defaults.max_header_count)?, + Duration::from_millis(timeout_millis), + ) + } + + /// Absolute idle-plus-header budget for each request head. + #[must_use] + #[inline] + pub const fn head_read_timeout(self) -> Duration { + self.head_read_timeout + } + + /// Maximum owned live HTTP/1 connections, including keep-alive. + #[must_use] + #[inline] + pub const fn max_connections(self) -> usize { + self.max_connections + } + + /// Maximum raw fields in a request head, counting duplicates separately. + #[must_use] + #[inline] + pub const fn max_header_count(self) -> usize { + self.max_header_count + } + + /// Combined raw request-head byte limit, inclusive for complete heads. + #[must_use] + #[inline] + pub const fn max_raw_head_bytes(self) -> usize { + self.max_raw_head_bytes + } + + /// Maximum application response field bytes before Hyper encodes/caches them. + #[must_use] + #[inline] + pub const fn max_response_header_bytes(self) -> usize { + self.max_raw_head_bytes + } + + /// Response field cap bounds the header map Hyper reuses for request parsing. + #[must_use] + #[inline] + pub fn max_response_header_count(self) -> usize { + self.max_header_count.max(100) + } + + /// Construct validated settings. + /// + /// Ranges: 1..=4096 connections, 8192..=1048576 raw-head bytes, + /// 1..=1024 fields, and 1 ms..=120 s idle-plus-header timeout. + /// + /// # Errors + /// Rejects values outside the supported finite ranges. + #[inline] + pub fn new( + max_connections: usize, + max_raw_head_bytes: usize, + max_header_count: usize, + head_read_timeout: Duration, + ) -> Result { + for (valid, field) in [ + ((1..=4096).contains(&max_connections), "max_connections"), + ( + (8192..=0x0010_0000).contains(&max_raw_head_bytes), + "max_raw_head_bytes", + ), + ((1..=1024).contains(&max_header_count), "max_header_count"), + ( + (Duration::from_millis(1)..=Duration::from_mins(2)).contains(&head_read_timeout), + "head_read_timeout_ms", + ), + ] { + if !valid { + return Err(IngressConfigError { field }); + } + } + Ok(Self { + head_read_timeout, + max_connections, + max_header_count, + max_raw_head_bytes, + }) + } + + /// Hyper's logical buffer threshold; allocation overhead is accounted separately. + #[must_use] + #[inline] + pub const fn parser_buffer_bytes(self) -> usize { + self.max_raw_head_bytes + } + + /// Conservative requested metadata capacity per parser/cache header map. + /// Excludes name/value bytes, the fixed map object, allocator overhead and RSS. + /// Audited against http 1.4.2 and Rust 1.95.0, including collision-driven growth. + #[must_use] + #[inline] + pub fn parser_header_map_allocation_bytes(self) -> usize { + let fields = self.max_response_header_count().saturating_add(1); + let raw_slots = 1_usize << fields.saturating_mul(10).ilog2(); + let buckets = raw_slots.saturating_mul(3) >> 2_usize; + let extras = fields.saturating_sub(1).max(4).next_power_of_two(); + let word = size_of::(); + let bucket_bytes = size_of::() + .saturating_add(size_of::()) + .saturating_add(word.saturating_mul(8)); + let extra_bytes = size_of::().saturating_add(word.saturating_mul(6)); + raw_slots + .saturating_mul(4) + .saturating_add(buckets.saturating_mul(bucket_bytes)) + .saturating_add(extras.saturating_mul(extra_bytes)) + } + + /// Conservative capacity bound per receive backing allocation, not RSS. + /// Audited against Hyper 1.12.0, bytes 1.12.1 and Rust 1.95.0. + #[must_use] + #[inline] + pub const fn parser_read_allocation_bytes(self) -> usize { + self.max_raw_head_bytes.saturating_mul(4) + } + + /// Conservative capacity bound for the separate raw chunked-trailer buffer. + #[must_use] + #[inline] + pub const fn parser_trailer_allocation_bytes(self) -> usize { + self.max_raw_head_bytes.next_power_of_two() + } +} + +fn setting( + env: &EnvConfig, + field: &'static str, + default: usize, +) -> Result { + let Some(value) = env.get(&["adapter", "ingress", field]) else { + return Ok(default); + }; + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(IngressConfigError { field }); + } + value + .parse() + .map_err(|_invalid_integer| IngressConfigError { field }) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use edgezero_core::env_config::EnvConfig; + use edgezero_core::http::HeaderValue; + + use super::AxumIngressConfig; + + #[test] + fn defaults_are_finite_and_effective() { + let config = AxumIngressConfig::default(); + assert_eq!(config.max_connections(), 256); + assert_eq!(config.max_raw_head_bytes(), 0x0001_0000); + assert_eq!(config.max_header_count(), 100); + assert_eq!(config.parser_buffer_bytes(), 0x0001_0000); + assert_eq!(config.parser_read_allocation_bytes(), 0x0004_0000); + assert_eq!(config.parser_trailer_allocation_bytes(), 0x0001_0000); + assert_eq!(config.head_read_timeout(), Duration::from_secs(10)); + assert!(config.parser_header_map_allocation_bytes() > 100 * size_of::()); + assert_eq!( + AxumIngressConfig::from_env(&EnvConfig::default()).unwrap(), + config + ); + } + + #[test] + fn explicit_environment_values_are_validated() { + let env = EnvConfig::from_vars([ + ("EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS", "2"), + ("EDGEZERO__ADAPTER__INGRESS__MAX_RAW_HEAD_BYTES", "8192"), + ("EDGEZERO__ADAPTER__INGRESS__MAX_HEADER_COUNT", "4"), + ("EDGEZERO__ADAPTER__INGRESS__HEAD_READ_TIMEOUT_MS", "25"), + ]); + let config = AxumIngressConfig::from_env(&env).unwrap(); + assert_eq!(config.max_connections(), 2); + assert_eq!(config.max_raw_head_bytes(), 8192); + assert_eq!(config.max_header_count(), 4); + assert_eq!(config.head_read_timeout(), Duration::from_millis(25)); + let non_power_of_two = AxumIngressConfig::new(2, 8193, 4, Duration::from_secs(1)).unwrap(); + assert_eq!(non_power_of_two.parser_buffer_bytes(), 8193); + assert_eq!(non_power_of_two.parser_read_allocation_bytes(), 0x8004); + assert_eq!(non_power_of_two.parser_trailer_allocation_bytes(), 0x4000); + } + + #[test] + fn invalid_explicit_values_never_fall_back() { + for field in [ + "MAX_CONNECTIONS", + "MAX_RAW_HEAD_BYTES", + "MAX_HEADER_COUNT", + "HEAD_READ_TIMEOUT_MS", + ] { + for value in ["", " ", "0", "-1", "+1", "abc", "184467440737095516160"] { + let env = + EnvConfig::from_vars([(format!("EDGEZERO__ADAPTER__INGRESS__{field}"), value)]); + let error = AxumIngressConfig::from_env(&env).unwrap_err(); + assert!(!error.to_string().contains(value) || value.is_empty() || value == " "); + } + } + } + + #[test] + fn programmatic_limits_accept_boundaries_and_reject_overflow() { + AxumIngressConfig::new(1, 8192, 1, Duration::from_millis(1)).unwrap(); + AxumIngressConfig::new(4096, 0x0010_0000, 1024, Duration::from_mins(2)).unwrap(); + for (connections, bytes, count, timeout) in [ + (0, 8192, 1, Duration::from_secs(1)), + (4097, 8192, 1, Duration::from_secs(1)), + (1, 8191, 1, Duration::from_secs(1)), + (1, 0x0010_0001, 1, Duration::from_secs(1)), + (1, 8192, 0, Duration::from_secs(1)), + (1, 8192, 1025, Duration::from_secs(1)), + (1, 8192, 1, Duration::ZERO), + (1, 8192, 1, Duration::from_secs(121)), + ] { + AxumIngressConfig::new(connections, bytes, count, timeout).unwrap_err(); + } + } +} diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index f6bca2ce8..3deadf50b 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -1,28 +1,43 @@ //! Axum adapter for `EdgeZero` routers and applications. +#[cfg(feature = "axum")] +mod config_snapshot; #[cfg(feature = "axum")] pub mod config_store; +pub mod config_store_limits; +#[cfg(feature = "axum")] +mod connection; #[cfg(feature = "axum")] pub mod context; #[cfg(feature = "axum")] pub mod dev_server; #[cfg(feature = "axum")] +mod header_deadline; +pub mod ingress_config; +#[cfg(feature = "axum")] pub mod key_value_store; #[cfg(feature = "axum")] -pub mod proxy; +pub mod outbound; #[cfg(feature = "axum")] pub mod request; #[cfg(feature = "axum")] -pub mod response; +mod response; #[cfg(any(feature = "axum", feature = "cli"))] pub mod secret_env; #[cfg(feature = "axum")] pub mod secret_store; #[cfg(feature = "axum")] -pub mod service; +mod service; -#[cfg(feature = "cli")] +#[cfg(all(feature = "cli", not(target_arch = "wasm32")))] pub mod cli; #[cfg(test)] pub mod test_utils; + +/// Native Axum resource limits and accounting are operator-configured. +pub const AXUM_PLATFORM: edgezero_core::PlatformMetadata = edgezero_core::PlatformMetadata::new( + edgezero_core::PlatformFact::unknown(edgezero_core::PlatformUnknownReason::OperatorConfigured), + edgezero_core::PlatformFact::unknown(edgezero_core::PlatformUnknownReason::OperatorConfigured), + edgezero_core::PlatformFact::unknown(edgezero_core::PlatformUnknownReason::OperatorConfigured), +); diff --git a/crates/edgezero-adapter-axum/src/outbound.rs b/crates/edgezero-adapter-axum/src/outbound.rs new file mode 100644 index 000000000..d3e82aeaf --- /dev/null +++ b/crates/edgezero-adapter-axum/src/outbound.rs @@ -0,0 +1,827 @@ +use async_stream::stream; +use async_trait::async_trait; +use bytes::Bytes; +use core::num::NonZeroU64; +use core::time::Duration; +use edgezero_core::body::{Body, BodyStream}; +use edgezero_core::error::{BadGatewayReason, BudgetSource, EdgeError}; +use edgezero_core::http::header::{ACCEPT_ENCODING, HOST}; +use edgezero_core::http::{HeaderMap, HeaderValue, Method, StatusCode}; +use edgezero_core::outbound::{ + OutboundBatch, OutboundHttpClient, OutboundRequest, OutboundRequestParts, OutboundResponse, + ResponseBodyDisposition, ResponseBodyPolicy, ResponseHeaderLimiter, ResponseMode, + apply_response_body_policy, collect_response_stream, insert_proxy_header, + normalize_for_dispatch, normalize_response_headers, validate_for_dispatch, +}; +use edgezero_core::time::{ + Deadline, DispatchBudget, MonotonicClock, MonotonicInstant, dispatch_budget, +}; +use futures_util::StreamExt as _; +use futures_util::future::{FutureExt as _, LocalBoxFuture}; +use reqwest::header::HeaderMap as ReqwestHeaderMap; +use reqwest::redirect::Policy; +use std::sync::Arc; +use tokio::time::timeout; + +/// Native outbound HTTP implementation used by the Axum adapter. +#[derive(Clone)] +pub struct AxumOutboundClient { + client: Arc, + clock: MonotonicClock, +} + +struct PreparedRequest { + budget: DispatchBudget, + parts: OutboundRequestParts, +} + +impl AxumOutboundClient { + async fn execute(&self, prepared: PreparedRequest) -> Result { + let PreparedRequest { budget, parts } = prepared; + let OutboundRequestParts { + body, + mut headers, + host_authority_override, + max_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_request_body_bytes, + max_response_header_bytes, + max_response_header_count, + method, + response_mode, + uri, + .. + } = parts; + + if !headers.contains_key(ACCEPT_ENCODING) { + headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity")); + } + if let Some(authority) = host_authority_override { + let value = HeaderValue::from_str(authority.as_str()).map_err(EdgeError::internal)?; + headers.insert(HOST, value); + } + let request_body = + collect_request_body(body, max_request_body_bytes, budget, &self.clock).await?; + let request_builder = self + .client + .request(reqwest_method(&method)?, uri.to_string()) + .headers(headers) + .body(request_body); + let request = apply_dispatch_timeout(request_builder, budget, &self.clock)?; + let response = request + .send() + .await + .map_err(|error| classify_send_error(&error, budget, &self.clock))?; + + process_response( + response, + method, + response_mode, + budget, + max_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + self.clock.clone(), + ) + .await + } + + fn prepare( + request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + validate_for_dispatch(&request)?; + Self::prepare_validated(request, started_at, None) + } + + fn prepare_batch( + request: OutboundRequest, + started_at: MonotonicInstant, + observation_cutoff: Deadline, + ) -> Result { + validate_for_dispatch(&request)?; + if request.is_stream_body() { + return Err(EdgeError::bad_request( + "outbound batches require buffered request bodies; use send for a streamed upload", + )); + } + if request.is_stream_response() { + return Err(EdgeError::bad_request( + "outbound batches require buffered responses; use send for a streamed response", + )); + } + Self::prepare_validated(request, started_at, Some(observation_cutoff)) + } + + fn prepare_validated( + mut request: OutboundRequest, + started_at: MonotonicInstant, + observation_cutoff: Option, + ) -> Result { + let budget = dispatch_budget(&request, started_at, observation_cutoff)?; + normalize_for_dispatch(&mut request)?; + Ok(PreparedRequest { + budget, + parts: request.into_parts(), + }) + } + + /// Builds a client with redirects and automatic content decoding disabled. + /// + /// # Errors + /// Returns the underlying client-construction error when TLS initialization fails. + #[inline] + pub fn try_new() -> Result { + Self::try_with_clock(MonotonicClock::default()) + } + + pub(crate) fn try_transport() -> Result, reqwest::Error> { + let client = reqwest::Client::builder() + .redirect(Policy::none()) + .no_brotli() + .no_deflate() + .no_gzip() + .no_zstd() + .build()?; + Ok(Arc::new(client)) + } + + /// Builds a client that evaluates every outbound lifetime against `clock`. + /// + /// # Errors + /// Returns the underlying client-construction error when TLS initialization fails. + #[inline] + pub fn try_with_clock(clock: MonotonicClock) -> Result { + Self::try_transport().map(|client| Self { client, clock }) + } + + pub(crate) fn with_transport_and_clock( + client: Arc, + clock: MonotonicClock, + ) -> Self { + Self { client, clock } + } +} + +#[async_trait(?Send)] +impl OutboundHttpClient for AxumOutboundClient { + #[inline] + async fn send(&self, request: OutboundRequest) -> Result { + let started_at = self.clock.now(); + let prepared = Self::prepare(request, started_at)?; + self.execute(prepared).await + } + + #[inline] + fn start_batch_until( + &self, + requests: Vec, + observation_cutoff: Deadline, + ) -> OutboundBatch { + let batch_started_at = self.clock.now(); + let slot_count = requests.len(); + if observation_cutoff.is_expired_at(batch_started_at) { + return OutboundBatch::cutoff(slot_count); + } + + let mut completed = Vec::new(); + let mut pending_slots = Vec::>::new(); + for (index, request) in requests.into_iter().enumerate() { + match Self::prepare_batch(request, batch_started_at, observation_cutoff) { + Ok(prepared) => { + let client = self.clone(); + pending_slots.push( + async move { + let outcome = client.execute(prepared).await; + (index, client.clock.now(), outcome) + } + .boxed_local(), + ); + } + Err(error) => completed.push((index, self.clock.now(), Err(error))), + } + } + + OutboundBatch::from_futures( + slot_count, + batch_started_at, + observation_cutoff, + completed, + pending_slots, + ) + } +} + +async fn collect_request_body( + body: Body, + maximum: u64, + budget: DispatchBudget, + clock: &MonotonicClock, +) -> Result { + match body { + Body::Once(bytes) => { + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + if length > maximum { + return Err(EdgeError::bad_request( + "outbound request body exceeded configured limit", + )); + } + budget_remaining(budget, clock)?; + Ok(bytes) + } + Body::Stream(mut source) => { + let mut collected = Vec::new(); + let mut total = 0_u64; + loop { + let remaining = budget_remaining(budget, clock)?; + let next_item = timeout(remaining, source.next()) + .await + .map_err(|_elapsed| timeout_error(budget.cause))?; + budget_remaining(budget, clock)?; + let Some(item) = next_item else { + return Ok(Bytes::from(collected)); + }; + let bytes = item?; + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + let Some(next_total) = total.checked_add(length) else { + return Err(EdgeError::bad_request( + "outbound request body size accounting overflow", + )); + }; + if next_total > maximum { + return Err(EdgeError::bad_request( + "outbound request body exceeded configured limit", + )); + } + collected.extend_from_slice(&bytes); + total = next_total; + } + } + } +} + +fn deadline_stream( + mut source: BodyStream, + budget: DispatchBudget, + clock: MonotonicClock, +) -> BodyStream { + stream! { + loop { + let remaining = match budget_remaining(budget, &clock) { + Ok(remaining) => remaining, + Err(error) => { + yield Err(error); + return; + } + }; + let next_item = match timeout(remaining, source.next()).await { + Ok(item) => item, + Err(_elapsed) => { + yield Err(timeout_error(budget.cause)); + return; + } + }; + if budget_remaining(budget, &clock).is_err() { + yield Err(timeout_error(budget.cause)); + return; + } + match next_item { + Some(Ok(bytes)) => yield Ok(bytes), + Some(Err(error)) => { + yield Err(error); + return; + } + None => return, + } + } + } + .boxed_local() +} + +#[expect( + clippy::too_many_arguments, + reason = "the adapter consumes the independent request policy fields without hiding them" +)] +async fn process_response( + response: reqwest::Response, + request_method: Method, + response_mode: ResponseMode, + budget: DispatchBudget, + max_decoder_bytes: u64, + max_brotli_window_bits: u8, + max_chunk_bytes: Option, + max_decoded_response_bytes: Option, + max_encoded_response_bytes: Option, + max_response_header_bytes: Option, + max_response_header_count: Option, + clock: MonotonicClock, +) -> Result { + budget_remaining(budget, &clock)?; + let response_clock = clock.clone(); + let status = StatusCode::from_u16(response.status().as_u16()).map_err(EdgeError::internal)?; + let mut headers = copy_headers(response.headers()); + let mut header_limiter = + ResponseHeaderLimiter::new(max_response_header_bytes, max_response_header_count); + header_limiter.observe(&headers)?; + let normalization = normalize_response_headers(&request_method, status, &mut headers)?; + let disposition = normalization.body_disposition; + insert_proxy_header( + &mut headers, + &mut header_limiter, + HeaderValue::from_static("axum"), + )?; + + if disposition == ResponseBodyDisposition::FramingBodyless { + return Ok(OutboundResponse::new( + request_method, + status, + headers, + Body::empty(), + response_clock, + normalization.content_type_provenance, + )); + } + + let declared_reset_body = matches!( + disposition, + ResponseBodyDisposition::ResetContent { + declared_body: true + } + ); + let native = response_stream(response, budget, clock.clone()); + if matches!(disposition, ResponseBodyDisposition::ResetContent { .. }) { + if !declared_reset_body { + let mut reset_stream = deadline_stream(native, budget, clock); + if let Some(item) = reset_stream.next().await { + item?; + } + } + return Ok(OutboundResponse::new( + request_method, + status, + headers, + Body::empty(), + response_clock, + normalization.content_type_provenance, + )); + } + + let shaped = apply_response_body_policy( + native, + &mut headers, + ResponseBodyPolicy { + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_decoder_bytes, + max_encoded_response_bytes, + response_mode, + }, + )?; + let deadline_bound = deadline_stream(shaped, budget, clock); + let body = match response_mode { + ResponseMode::Buffered { max_bytes } => { + Body::from(collect_response_stream(deadline_bound, max_bytes).await?) + } + ResponseMode::Streamed => Body::from_stream(deadline_bound), + }; + Ok(OutboundResponse::new( + request_method, + status, + headers, + body, + response_clock, + normalization.content_type_provenance, + )) +} + +fn budget_remaining(budget: DispatchBudget, clock: &MonotonicClock) -> Result { + budget + .deadline + .remaining_at(clock.now()) + .map(|remaining| remaining.min(budget.duration)) + .ok_or_else(|| timeout_error(budget.cause)) +} + +fn classify_send_error( + error: &reqwest::Error, + budget: DispatchBudget, + clock: &MonotonicClock, +) -> EdgeError { + if budget.deadline.is_expired_at(clock.now()) { + return timeout_error(budget.cause); + } + if error.is_timeout() { + return timeout_error(budget.cause); + } + let reason = if error.is_connect() { + BadGatewayReason::Unreachable + } else if error.is_builder() { + BadGatewayReason::Protocol + } else { + BadGatewayReason::Transport + }; + EdgeError::bad_gateway_with_reason("upstream request failed", reason) +} + +fn copy_headers(headers: &ReqwestHeaderMap) -> HeaderMap { + let mut copied = HeaderMap::with_capacity(headers.len()); + for (name, value) in headers { + copied.append(name.clone(), value.clone()); + } + copied +} + +fn reqwest_method(method: &Method) -> Result { + reqwest::Method::from_bytes(method.as_str().as_bytes()).map_err(EdgeError::internal) +} + +fn response_stream( + mut response: reqwest::Response, + budget: DispatchBudget, + clock: MonotonicClock, +) -> BodyStream { + stream! { + loop { + match response.chunk().await { + Ok(Some(bytes)) => yield Ok(bytes), + Ok(None) => return, + Err(error) => { + let mapped = if budget.deadline.is_expired_at(clock.now()) || error.is_timeout() { + timeout_error(budget.cause) + } else { + EdgeError::bad_gateway_with_reason( + "upstream response body failed", + BadGatewayReason::Transport, + ) + }; + yield Err(mapped); + return; + } + } + } + } + .boxed_local() +} + +fn timeout_error(cause: BudgetSource) -> EdgeError { + EdgeError::gateway_timeout_caused("outbound request deadline expired", cause) +} + +fn apply_dispatch_timeout( + request: reqwest::RequestBuilder, + budget: DispatchBudget, + clock: &MonotonicClock, +) -> Result { + Ok(request.timeout(budget_remaining(budget, clock)?)) +} + +#[cfg(test)] +mod clock_tests { + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + + use edgezero_core::time::Deadline; + use futures_util::stream; + + use super::*; + + fn scripted_clock(script: Vec) -> MonotonicClock { + let observations = Arc::new(Mutex::new(VecDeque::from(script))); + MonotonicClock::new(move || { + observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + }) + } + + fn test_budget(start: MonotonicInstant, duration: Duration) -> DispatchBudget { + DispatchBudget { + cause: BudgetSource::PerCallTimeout, + deadline: Deadline::at_instant(start.checked_add(duration).expect("deadline")), + duration, + } + } + + #[tokio::test] + async fn method_entry_and_preflight_elapsed_use_the_injected_clock() { + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(9)) + .expect("completed instant"); + let client = AxumOutboundClient::try_with_clock(scripted_clock(vec![start, completed])) + .expect("client"); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = client + .start_batch_until(vec![request], Deadline::after(Duration::from_secs(1))) + .collect() + .await + .expect("valid batch driver"); + + let slot = results.slots[0].as_ref().expect("resolved slot"); + assert_eq!(slot.elapsed, Duration::from_millis(9)); + assert!(matches!(slot.outcome, Err(EdgeError::BadRequest { .. }))); + } + + #[tokio::test] + async fn backwards_clock_fails_slot_without_invalid_elapsed() { + let start = MonotonicInstant::now(); + let earlier = start + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let client = AxumOutboundClient::try_with_clock(scripted_clock(vec![start, earlier])) + .expect("client"); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = client + .start_batch_until(vec![request], Deadline::after(Duration::from_secs(1))) + .collect() + .await + .expect("valid batch driver"); + + let slot = results.slots[0].as_ref().expect("resolved slot"); + assert_eq!(slot.elapsed, Duration::ZERO); + assert!(matches!(slot.outcome, Err(EdgeError::Internal { .. }))); + } + + #[test] + fn backwards_clock_cannot_expand_the_selected_budget() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let earlier = start + .checked_sub(Duration::from_millis(5)) + .expect("earlier instant"); + let clock = scripted_clock(vec![earlier]); + + assert_eq!( + budget_remaining(budget, &clock).expect("remaining budget"), + budget.duration + ); + } + + #[test] + fn request_timer_arms_from_final_remaining_budget() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![budget.deadline.instant()]); + let request = reqwest::Client::new().get("https://example.com/"); + + let error = apply_dispatch_timeout(request, budget, &clock) + .expect_err("final dispatch sample must reject equality expiry"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[tokio::test] + async fn buffered_request_preparation_reduces_the_remaining_injected_budget() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let observed = start + .checked_add(Duration::from_millis(3)) + .expect("observed instant"); + let clock = scripted_clock(vec![observed, observed]); + + let body = collect_request_body(Body::from("body"), 16, budget, &clock) + .await + .expect("prepared body"); + let remaining = budget_remaining(budget, &clock).expect("remaining budget"); + + assert_eq!(body, Bytes::from_static(b"body")); + assert_eq!(remaining, Duration::from_millis(7)); + } + + #[tokio::test] + async fn streamed_upload_checks_injected_clock_after_ready_item() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, budget.deadline.instant()]); + let body = Body::from_stream(stream::once(async { Ok(Bytes::from_static(b"body")) })); + + let error = collect_request_body(body, 16, budget, &clock) + .await + .expect_err("post-ready expiry"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[tokio::test] + async fn converted_response_retains_injected_clock() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_secs(1)); + let observed = Arc::new(Mutex::new(start)); + let clock_observed = Arc::clone(&observed); + let clock = MonotonicClock::new(move || *clock_observed.lock().expect("clock observation")); + let native = reqwest::Response::from( + http::Response::builder() + .status(StatusCode::NO_CONTENT) + .body(reqwest::Body::from(Bytes::new())) + .expect("native response"), + ); + let response = process_response( + native, + Method::GET, + ResponseMode::Streamed, + budget, + 32 * 1024 * 1024, + 24, + None, + None, + None, + None, + None, + clock, + ) + .await + .expect("converted response"); + *observed.lock().expect("clock observation") = budget.deadline.instant(); + + let error = response + .into_bytes_bounded_until(1, budget.deadline) + .await + .expect_err("retained clock reached deadline"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[tokio::test] + async fn response_content_type_provenance_observes_raw_tcp_duplicates() { + use edgezero_core::{ContentTypeIneligibility, ContentTypeProvenance}; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + use tokio::net::TcpListener; + + for opaque_sibling in [false, true] { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("loopback listener"); + let address = listener.local_addr().expect("local address"); + let origin = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accepted connection"); + let mut request = [0_u8; 4096]; + let read = timeout(Duration::from_secs(5), socket.read(&mut request)) + .await + .expect("request deadline") + .expect("request head"); + assert!(read > 0); + let mut head = + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Type: ".to_vec(); + head.extend_from_slice(if opaque_sibling { + b"\xff" + } else { + b"application/json" + }); + head.extend_from_slice(b"\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}"); + socket.write_all(&head).await.expect("upstream response"); + }); + let client = AxumOutboundClient::try_new().expect("outbound client"); + let request = OutboundRequest::get(format!("http://{address}/")) + .expect("request") + .timeout(Duration::from_secs(5)); + let response = client.send(request).await.expect("outbound response"); + assert_eq!( + response.content_type_provenance(), + ContentTypeProvenance::Ineligible(ContentTypeIneligibility::MultipleValues) + ); + assert_eq!( + response.headers().get_all("content-type").iter().count(), + if opaque_sibling { 1 } else { 2 } + ); + assert_eq!(response.body().as_bytes(), Some(b"{}".as_slice())); + origin.await.expect("origin task"); + } + } + + #[tokio::test] + async fn response_content_type_provenance_survives_native_conversion() { + use ContentTypeIneligibility::{Missing, MultipleValues, NonText, Removed}; + use ContentTypeProvenance::{Ineligible, SingleTextUnchanged}; + use edgezero_core::{ContentTypeIneligibility, ContentTypeProvenance}; + + let cases: &[(&[&[u8]], bool, ContentTypeProvenance)] = &[ + (&[], false, Ineligible(Missing)), + (&[b"application/json"], false, SingleTextUnchanged), + (&[b"caf\xc3\xa9"], false, SingleTextUnchanged), + ( + &[b"application/json, text/plain"], + false, + SingleTextUnchanged, + ), + ( + &[b"application/json", b"application/json"], + false, + Ineligible(MultipleValues), + ), + ( + &[b"application/json", b"text/plain"], + false, + Ineligible(MultipleValues), + ), + ( + &[b"application/json", b"\xff"], + false, + Ineligible(MultipleValues), + ), + (&[b"\xff"], false, Ineligible(NonText)), + (&[b"application/json"], true, Ineligible(Removed)), + ]; + for (method, status, response_mode) in [ + ( + Method::GET, + StatusCode::OK, + ResponseMode::Buffered { max_bytes: 32 }, + ), + (Method::GET, StatusCode::OK, ResponseMode::Streamed), + ( + Method::HEAD, + StatusCode::OK, + ResponseMode::Buffered { max_bytes: 32 }, + ), + ( + Method::GET, + StatusCode::NO_CONTENT, + ResponseMode::Buffered { max_bytes: 32 }, + ), + ( + Method::GET, + StatusCode::RESET_CONTENT, + ResponseMode::Buffered { max_bytes: 32 }, + ), + ( + Method::GET, + StatusCode::NOT_MODIFIED, + ResponseMode::Buffered { max_bytes: 32 }, + ), + ] { + for (values, nominated, expected) in cases { + let mut native_headers = HeaderMap::new(); + for value in *values { + native_headers.append( + "content-type", + HeaderValue::from_bytes(value).expect("header bytes"), + ); + } + if *nominated { + native_headers.insert("connection", HeaderValue::from_static("content-type")); + } + let mut native = http::Response::builder() + .status(status) + .body(reqwest::Body::from(Bytes::new())) + .expect("native response"); + *native.headers_mut() = native_headers; + let start = MonotonicInstant::now(); + let response = process_response( + reqwest::Response::from(native), + method.clone(), + response_mode, + test_budget(start, Duration::from_secs(1)), + 32 * 1024 * 1024, + 24, + None, + None, + None, + None, + None, + MonotonicClock::new(move || start), + ) + .await + .expect("converted response"); + assert_eq!(response.content_type_provenance(), *expected); + let converted = response.into_response().expect("application response"); + assert_eq!( + converted.extensions().get::(), + Some(expected) + ); + } + } + } + + #[tokio::test] + async fn response_stream_retains_clock_for_post_ready_expiry() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, budget.deadline.instant()]); + let source = stream::once(async { Ok(Bytes::from_static(b"body")) }).boxed_local(); + let mut body = deadline_stream(source, budget, clock); + + let error = body + .next() + .await + .expect("terminal item") + .expect_err("post-ready expiry"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } +} diff --git a/crates/edgezero-adapter-axum/src/proxy.rs b/crates/edgezero-adapter-axum/src/proxy.rs deleted file mode 100644 index 1dd3e504c..000000000 --- a/crates/edgezero-adapter-axum/src/proxy.rs +++ /dev/null @@ -1,334 +0,0 @@ -use std::time::Duration; - -use async_trait::async_trait; -use edgezero_core::body::Body; -use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderName, HeaderValue, Method, StatusCode}; -use edgezero_core::proxy::{ProxyClient, ProxyRequest, ProxyResponse}; -use futures_util::StreamExt as _; -use reqwest::{Client, header}; - -pub struct AxumProxyClient { - client: Client, -} - -impl AxumProxyClient { - /// Construct a proxy client with the workspace-default 30-second timeout. - /// - /// **Breaking change (pre-1.0):** previously `AxumProxyClient` implemented - /// `Default` and panicked if reqwest's TLS backend could not be initialised. - /// Construction is now fallible so callers can decide how to handle a - /// missing or misconfigured TLS backend. - /// - /// # Errors - /// Returns the underlying [`reqwest::Error`] if `reqwest::Client::builder().build()` - /// fails — typically because the TLS backend cannot be initialised on this target. - #[inline] - pub fn try_new() -> Result { - let client = Client::builder().timeout(Duration::from_secs(30)).build()?; - Ok(Self { client }) - } -} - -#[async_trait(?Send)] -impl ProxyClient for AxumProxyClient { - #[inline] - async fn send(&self, request: ProxyRequest) -> Result { - let (method, uri, headers, body, _extensions) = request.into_parts(); - let reqwest_method = reqwest_method(&method)?; - let mut builder = self.client.request(reqwest_method, uri.to_string()); - - for (name, value) in &headers { - let header_name = header::HeaderName::from_bytes(name.as_str().as_bytes()) - .map_err(EdgeError::internal)?; - let header_value = - header::HeaderValue::from_bytes(value.as_bytes()).map_err(EdgeError::internal)?; - builder = builder.header(header_name, header_value); - } - - builder = match body { - Body::Once(bytes) => builder.body(bytes.to_vec()), - Body::Stream(mut stream) => { - let mut buf = Vec::new(); - while let Some(result) = stream.next().await { - let chunk = result.map_err(EdgeError::internal)?; - buf.extend_from_slice(&chunk); - } - builder.body(buf) - } - }; - - let response = builder.send().await.map_err(EdgeError::internal)?; - let status = - StatusCode::from_u16(response.status().as_u16()).map_err(EdgeError::internal)?; - let mut proxy_response = ProxyResponse::new(status, Body::empty()); - - for (name, value) in response.headers() { - let header_name = - HeaderName::from_bytes(name.as_str().as_bytes()).map_err(EdgeError::internal)?; - let header_value = - HeaderValue::from_bytes(value.as_bytes()).map_err(EdgeError::internal)?; - proxy_response - .headers_mut() - .insert(header_name, header_value); - } - - let bytes = response.bytes().await.map_err(EdgeError::internal)?; - *proxy_response.body_mut() = Body::from(bytes.to_vec()); - - Ok(proxy_response) - } -} - -fn reqwest_method(method: &Method) -> Result { - reqwest::Method::from_bytes(method.as_str().as_bytes()).map_err(EdgeError::internal) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::mem; - - #[test] - fn converts_method_to_reqwest() { - let method = Method::POST; - let req = reqwest_method(&method).expect("reqwest method"); - assert_eq!(req, reqwest::Method::POST); - } - - #[test] - fn converts_all_methods_to_reqwest() { - let cases = [ - (Method::GET, reqwest::Method::GET), - (Method::POST, reqwest::Method::POST), - (Method::PUT, reqwest::Method::PUT), - (Method::DELETE, reqwest::Method::DELETE), - (Method::PATCH, reqwest::Method::PATCH), - (Method::HEAD, reqwest::Method::HEAD), - (Method::OPTIONS, reqwest::Method::OPTIONS), - ]; - for (input, expected) in cases { - let result = reqwest_method(&input).expect("method conversion"); - assert_eq!(result, expected); - } - } - - #[test] - fn default_client_creates_successfully() { - let client = AxumProxyClient::try_new().expect("reqwest client init"); - // Just verify it builds without panicking - assert!(mem::size_of_val(&client) > 0); - } -} - -#[cfg(test)] -mod integration_tests { - use super::*; - use axum::Router; - use axum::body::Bytes as AxumBytes; - use axum::http::header::CONTENT_TYPE; - use axum::http::{HeaderMap as AxumHeaderMap, StatusCode as AxumStatusCode}; - use axum::routing::{delete, get, patch, post, put}; - use edgezero_core::http::Uri; - use tokio::net::TcpListener; - - async fn start_test_server(router: Router) -> String { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); - format!("http://{addr}") - } - - #[tokio::test] - async fn proxy_client_sends_get_request() { - let app = Router::new().route("/test", get(|| async { "hello from server" })); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - let uri: Uri = format!("{base_url}/test").parse().unwrap(); - let request = ProxyRequest::new(Method::GET, uri); - - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::OK); - - match response.body() { - Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"hello from server"), - Body::Stream(_) => panic!("expected buffered body"), - } - } - - #[tokio::test] - async fn proxy_client_sends_post_with_body() { - let app = Router::new().route("/echo", post(|body: AxumBytes| async move { body })); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - let uri: Uri = format!("{base_url}/echo").parse().unwrap(); - let mut request = ProxyRequest::new(Method::POST, uri); - *request.body_mut() = Body::from("request body data"); - - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::OK); - - match response.body() { - Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"request body data"), - Body::Stream(_) => panic!("expected buffered body"), - } - } - - #[tokio::test] - async fn proxy_client_forwards_request_headers() { - let app = Router::new().route( - "/headers", - get(|headers: AxumHeaderMap| async move { - headers - .get("x-custom-header") - .and_then(|val| val.to_str().ok()) - .unwrap_or("missing") - .to_owned() - }), - ); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - let uri: Uri = format!("{base_url}/headers").parse().unwrap(); - let mut request = ProxyRequest::new(Method::GET, uri); - request - .headers_mut() - .insert("x-custom-header", HeaderValue::from_static("custom-value")); - - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::OK); - - match response.body() { - Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"custom-value"), - Body::Stream(_) => panic!("expected buffered body"), - } - } - - #[tokio::test] - async fn proxy_client_receives_response_headers() { - let app = Router::new().route( - "/with-headers", - get(|| async { ([(CONTENT_TYPE, "application/json")], "{}") }), - ); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - let uri: Uri = format!("{base_url}/with-headers").parse().unwrap(); - let request = ProxyRequest::new(Method::GET, uri); - - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::OK); - - let content_type = response - .headers() - .get("content-type") - .and_then(|val| val.to_str().ok()); - assert_eq!(content_type, Some("application/json")); - } - - #[tokio::test] - async fn proxy_client_handles_404() { - let app = Router::new(); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - let uri: Uri = format!("{base_url}/nonexistent").parse().unwrap(); - let request = ProxyRequest::new(Method::GET, uri); - - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn proxy_client_handles_500() { - let app = Router::new().route( - "/error", - get(|| async { (AxumStatusCode::INTERNAL_SERVER_ERROR, "error") }), - ); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - let uri: Uri = format!("{base_url}/error").parse().unwrap(); - let request = ProxyRequest::new(Method::GET, uri); - - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - } - - #[tokio::test] - async fn proxy_client_handles_various_methods() { - let app = Router::new() - .route("/method", get(|| async { "GET" })) - .route("/method", post(|| async { "POST" })) - .route("/method", put(|| async { "PUT" })) - .route("/method", delete(|| async { "DELETE" })) - .route("/method", patch(|| async { "PATCH" })); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - - for (method, expected_body) in [ - (Method::GET, "GET"), - (Method::POST, "POST"), - (Method::PUT, "PUT"), - (Method::DELETE, "DELETE"), - (Method::PATCH, "PATCH"), - ] { - let uri: Uri = format!("{base_url}/method").parse().unwrap(); - let request = ProxyRequest::new(method, uri); - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::OK); - match response.body() { - Body::Once(bytes) => assert_eq!(bytes.as_ref(), expected_body.as_bytes()), - Body::Stream(_) => panic!("expected buffered body"), - } - } - } - - #[tokio::test] - async fn proxy_client_handles_connection_refused() { - let client = AxumProxyClient::try_new().expect("reqwest client init"); - // Use a port that's unlikely to have anything running - let uri: Uri = "http://127.0.0.1:1".parse().unwrap(); - let request = ProxyRequest::new(Method::GET, uri); - - client - .send(request) - .await - .expect_err("expected connection refused"); - } - - #[tokio::test] - async fn proxy_client_sends_streaming_body() { - use bytes::Bytes; - use futures::stream; - - let app = Router::new().route("/stream-echo", post(|body: AxumBytes| async move { body })); - let base_url = start_test_server(app).await; - - let client = AxumProxyClient::try_new().expect("reqwest client init"); - let uri: Uri = format!("{base_url}/stream-echo").parse().unwrap(); - let mut request = ProxyRequest::new(Method::POST, uri); - - // Create a streaming body - Body::stream expects Stream - let chunks = vec![ - Bytes::from("chunk1"), - Bytes::from("chunk2"), - Bytes::from("chunk3"), - ]; - let stream = stream::iter(chunks); - *request.body_mut() = Body::stream(stream); - - let response = client.send(request).await.expect("response"); - assert_eq!(response.status(), StatusCode::OK); - - match response.body() { - Body::Once(bytes) => assert_eq!(bytes.as_ref(), b"chunk1chunk2chunk3"), - Body::Stream(_) => panic!("expected buffered body"), - } - } -} diff --git a/crates/edgezero-adapter-axum/src/request.rs b/crates/edgezero-adapter-axum/src/request.rs index 9e3f5976d..b7da384cb 100644 --- a/crates/edgezero-adapter-axum/src/request.rs +++ b/crates/edgezero-adapter-axum/src/request.rs @@ -1,37 +1,50 @@ use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::Arc; -use axum::body::{Body as AxumBody, to_bytes}; +use axum::body::{Body as AxumBody, BodyDataStream}; use axum::extract::connect_info::ConnectInfo; -use axum::http::Request; +use axum::http::{Request, request::Parts}; use edgezero_core::body::Body; -use edgezero_core::http::HeaderValue; +use edgezero_core::error::EdgeError; use edgezero_core::http::Request as CoreRequest; -use edgezero_core::http::header::CONTENT_TYPE; -use edgezero_core::proxy::ProxyHandle; +use edgezero_core::outbound::HttpClient; +use edgezero_core::time::{Deadline, MonotonicClock}; +use futures_util::{StreamExt as _, stream}; +use tokio::time::timeout; use crate::context::AxumRequestContext; -use crate::proxy::AxumProxyClient; +use crate::outbound::AxumOutboundClient; /// Convert an Axum/Hyper request into an `EdgeZero` core request while preserving streaming bodies /// and exposing connection metadata through `AxumRequestContext`. /// /// # Errors -/// Returns an error if a buffered (`application/json`) body cannot be read into memory. +/// Returns an error if the outbound client cannot be initialized. #[inline] +#[expect( + clippy::unused_async, + reason = "the public converter retains its established async API while request bodies remain lazy" +)] pub async fn into_core_request(request: Request) -> Result { let (parts, axum_body) = request.into_parts(); + into_core_request_parts(parts, axum_body, None, None) +} - let body = match parts.headers.get(CONTENT_TYPE) { - Some(value) if is_json_content_type(value) => { - let bytes = to_bytes(axum_body, usize::MAX) - .await - .map_err(|err| format!("Failed to convert body into bytes: {err}"))?; - Body::from_bytes(bytes) - } - _ => { - let stream = axum_body.into_data_stream(); - Body::from_stream(stream) +pub(crate) fn into_core_request_parts( + parts: Parts, + axum_body: AxumBody, + read_lifetime: Option<(Deadline, MonotonicClock)>, + outbound_transport: Option>, +) -> Result { + let outbound_clock = read_lifetime + .as_ref() + .map_or_else(MonotonicClock::default, |(_, clock)| clock.clone()); + let body = match read_lifetime { + Some((deadline, monotonic_clock)) => { + deadline_body(axum_body.into_data_stream(), deadline, monotonic_clock) } + None => Body::from_external_stream(axum_body.into_data_stream()), }; let mut core_request = CoreRequest::from_parts(parts, body); @@ -52,47 +65,112 @@ pub async fn into_core_request(request: Request) -> Result AxumOutboundClient::with_transport_and_clock(transport, outbound_clock), + None => AxumOutboundClient::try_with_clock(outbound_clock) + .map_err(|_error| "failed to build outbound HTTP client".to_owned())?, + }; core_request .extensions_mut() - .insert(ProxyHandle::with_client(proxy_client)); + .insert(HttpClient::with_client(outbound_client)); Ok(core_request) } -fn is_json_content_type(value: &HeaderValue) -> bool { - let Ok(raw) = value.to_str() else { - return false; - }; - - let media_type = raw.split(';').next().map_or("", str::trim); - if media_type.eq_ignore_ascii_case("application/json") { - return true; - } - - let Some((ty, raw_subtype)) = media_type.split_once('/') else { - return false; - }; - - if !ty.eq_ignore_ascii_case("application") { - return false; - } - - let subtype = raw_subtype.trim(); - let Some(suffix_start) = subtype.len().checked_sub(5) else { - return false; - }; - subtype - .get(suffix_start..) - .is_some_and(|suffix| suffix.eq_ignore_ascii_case("+json")) +fn deadline_body( + body_stream: BodyDataStream, + deadline: Deadline, + monotonic_clock: MonotonicClock, +) -> Body { + let deadline_stream = stream::unfold( + Some(Box::pin(body_stream)), + move |stream_state: Option>>| { + let clock = monotonic_clock.clone(); + async move { + let mut state_stream = stream_state?; + let Some(remaining) = deadline.remaining_at(clock.now()) else { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + }; + let item = match timeout(remaining, state_stream.next()).await { + Ok(item) => item, + Err(_elapsed) => { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + } + }; + if deadline.is_expired_at(clock.now()) { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + } + match item { + Some(Ok(bytes)) => Some((Ok(bytes), Some(state_stream))), + Some(Err(error)) => Some((Err(EdgeError::internal(error)), None)), + None => None, + } + } + }, + ); + Body::from_stream(deadline_stream) } #[cfg(test)] mod tests { use super::*; + use bytes::Bytes; use edgezero_core::body::Body; - use edgezero_core::http::Method; + use edgezero_core::http::{Method, StatusCode}; + use edgezero_core::time::MonotonicInstant; + use std::io; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::Poll; + + struct DropSignal(Arc); + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + #[tokio::test] + async fn deadline_body_releases_source_when_timeout_is_emitted() { + let dropped = Arc::new(AtomicUsize::new(0)); + let signal = DropSignal(Arc::clone(&dropped)); + let source = stream::poll_fn(move |_cx| { + let _keep_alive = &signal; + Poll::>>::Pending + }); + let start = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || start); + let body = deadline_body( + AxumBody::from_stream(source).into_data_stream(), + Deadline::at_instant(start), + clock, + ); + let mut body_stream = body.into_stream().expect("stream"); + + let error = body_stream + .next() + .await + .expect("terminal timeout item") + .expect_err("timeout"); + assert_eq!(error.status(), StatusCode::REQUEST_TIMEOUT); + assert_eq!(dropped.load(Ordering::SeqCst), 1); + } #[tokio::test] async fn converts_request_and_records_connect_info() { @@ -125,6 +203,25 @@ mod tests { .get::>() .is_none() ); + assert!(core_request.extensions().get::().is_some()); + } + + #[tokio::test] + async fn supplied_outbound_transport_is_retained_by_the_core_request() { + let transport = AxumOutboundClient::try_transport().expect("transport"); + let request = Request::builder() + .method(Method::GET) + .uri("/demo") + .body(AxumBody::empty()) + .expect("request"); + let (parts, body) = request.into_parts(); + + let core_request = into_core_request_parts(parts, body, None, Some(Arc::clone(&transport))) + .expect("request conversion"); + + assert_eq!(Arc::strong_count(&transport), 2); + drop(core_request); + assert_eq!(Arc::strong_count(&transport), 1); } #[tokio::test] @@ -142,8 +239,8 @@ mod tests { } #[tokio::test] - async fn json_content_type_buffers_body() { - let json_payload = r#"{"name":"test"}"#; + async fn json_content_type_stays_streaming() { + let json_payload = serde_json::json!({"name": "test"}).to_string(); let request = Request::builder() .method(Method::POST) .uri("/api/test") @@ -156,12 +253,7 @@ mod tests { .expect("request conversion"); assert_eq!(core_request.method(), &Method::POST); - match core_request.body() { - Body::Once(bytes) => { - assert_eq!(bytes.as_ref(), json_payload.as_bytes()); - } - Body::Stream(_) => panic!("JSON body should be buffered, not streaming"), - } + assert!(matches!(core_request.body(), Body::Stream(_))); } #[tokio::test] @@ -179,27 +271,4 @@ mod tests { assert!(matches!(core_request.body(), Body::Stream(_))); } - - #[test] - fn json_content_type_detection() { - assert!(is_json_content_type(&HeaderValue::from_static( - "application/json" - ))); - assert!(is_json_content_type(&HeaderValue::from_static( - "application/json; charset=utf-8" - ))); - assert!(is_json_content_type(&HeaderValue::from_static( - "application/vnd.api+json" - ))); - assert!(is_json_content_type(&HeaderValue::from_static( - "APPLICATION/VND.CUSTOM+JSON; CHARSET=UTF-8" - ))); - - assert!(!is_json_content_type(&HeaderValue::from_static( - "text/json" - ))); - assert!(!is_json_content_type(&HeaderValue::from_static( - "application/json+xml" - ))); - } } diff --git a/crates/edgezero-adapter-axum/src/response.rs b/crates/edgezero-adapter-axum/src/response.rs index cf3bb6c69..8ba115953 100644 --- a/crates/edgezero-adapter-axum/src/response.rs +++ b/crates/edgezero-adapter-axum/src/response.rs @@ -1,101 +1,1119 @@ -use axum::body::Body as AxumBody; -use axum::http::header::CONTENT_TYPE; -use axum::http::{HeaderValue, Response, StatusCode}; -use futures::executor::block_on; -use futures_util::{StreamExt as _, pin_mut}; -use tracing::error; +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::error::Error as StdError; +use std::fmt; +use std::mem; +use std::pin::Pin; +use std::ptr; +use std::rc::{Rc, Weak}; +use std::task::{Context, Poll}; -use edgezero_core::body::Body; -use edgezero_core::http::Response as CoreResponse; +use axum::http::Response; +use bytes::Bytes; +use edgezero_core::body::{Body, BodyStream}; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{Method, Response as CoreResponse, StatusCode}; +use edgezero_core::response_egress::{ + RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET, ResponseEgressAttempt, ResponseEgressEnvelope, + ResponseEgressFallbackDisposition, ResponseEgressOutcome, +}; +use edgezero_core::response_egress_framing::{ + PreparedResponseEgress, ResponseEgressBodyLengthError, prepare_response_egress, + response_egress_source_error_category, +}; +use edgezero_core::time::{Deadline, MonotonicClock}; +use futures_util::StreamExt as _; +use http_body::{Body as HttpBody, Frame, SizeHint}; +use tokio::sync::Notify; +use tokio::task::yield_now; +use tokio::time::Instant as TokioInstant; +use tokio::time::timeout; -/// Convert an `EdgeZero` response into one consumable by Axum/Hyper. -/// -/// Streaming responses are collected into an in-memory buffer. While this sacrifices -/// incremental flushing, it keeps the adapter compatible with the non-`Send` streaming type used by -/// `edgezero_core::Body` and works well for local development. +const INTERNAL_FALLBACK_BODY: &[u8] = b"internal server error"; +const TIMEOUT_FALLBACK_BODY: &[u8] = b"response write deadline exceeded"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EgressKind { + Application, + Fallback, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct AxumBodyError(ResponseEgressOutcome); + +impl fmt::Display for AxumBodyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self.0 { + ResponseEgressOutcome::DeadlineExceeded => "response write deadline exceeded", + ResponseEgressOutcome::SourceError => "response body source failed", + ResponseEgressOutcome::ClientDisconnected + | ResponseEgressOutcome::Completed + | ResponseEgressOutcome::ConversionError + | ResponseEgressOutcome::HostHandoff + | ResponseEgressOutcome::RequestCancelled + | ResponseEgressOutcome::TransportError + | ResponseEgressOutcome::Unspecified + | _ => "response transport failed", + }) + } +} + +#[expect( + clippy::missing_trait_methods, + reason = "the category-only body error has no nested diagnostic source" +)] +impl StdError for AxumBodyError {} + +impl AxumBodyError { + pub(crate) const fn is_deadline(self) -> bool { + matches!(self.0, ResponseEgressOutcome::DeadlineExceeded) + } +} + +#[derive(Default)] +struct EgressConnectionInner { + changed: Notify, + responses: RefCell>>, +} + +struct ConnectionResponseState { + active: Cell, + connection: Weak, + host_deadline: TokioInstant, + requested_terminal: Cell>, +} + +impl ConnectionResponseState { + fn request_terminal(&self, outcome: ResponseEgressOutcome) { + if self.active.get() && self.requested_terminal.get().is_none() { + self.requested_terminal.set(Some(outcome)); + } + } + + fn unregister(&self) { + if !self.active.replace(false) { + return; + } + let Some(connection) = self.connection.upgrade() else { + return; + }; + connection + .responses + .borrow_mut() + .retain(|candidate| !ptr::eq(Rc::as_ptr(candidate), self)); + connection.changed.notify_one(); + } +} + +/// Per-HTTP/1-connection response registry used by the connection supervisor. /// -#[inline] -pub fn into_axum_response(response: CoreResponse) -> Response { - let (parts, core_body) = response.into_parts(); - let body = match core_body { - Body::Once(bytes) => AxumBody::from(bytes), - Body::Stream(stream) => { - let result = block_on(async { - let mut buf = Vec::new(); - pin_mut!(stream); - while let Some(chunk) = stream.next().await { - let bytes = chunk?; - buf.extend_from_slice(&bytes); +/// The registry stores only deadline events. The non-clone response attempt remains owned by the +/// body coordinator and observes the event when Hyper next polls or drops that body. +#[derive(Clone, Default)] +pub(crate) struct EgressConnection { + inner: Rc, +} + +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "registry operations are grouped by registration, deadline, transport, and notification lifecycle" +)] +impl EgressConnection { + fn register( + &self, + deadline: Deadline, + clock: &MonotonicClock, + ) -> Option> { + let remaining = deadline.remaining_at(clock.now())?; + let host_deadline = TokioInstant::now().checked_add(remaining)?; + let state = Rc::new(ConnectionResponseState { + active: Cell::new(true), + connection: Rc::downgrade(&self.inner), + host_deadline, + requested_terminal: Cell::new(None), + }); + self.inner + .responses + .borrow_mut() + .push_back(Rc::clone(&state)); + self.inner.changed.notify_one(); + Some(state) + } + + pub(crate) fn next_deadline(&self) -> Option { + self.inner + .responses + .borrow() + .iter() + .filter(|state| state.active.get()) + .map(|state| state.host_deadline) + .min() + } + + /// Marks the first ordered response at the earliest elapsed deadline as the winner and every + /// other unsettled response as HTTP/1 connection collateral. + pub(crate) fn signal_elapsed_deadline(&self, observed_at: TokioInstant) -> bool { + let responses = self.inner.responses.borrow(); + let Some(winner) = responses + .iter() + .filter(|state| state.active.get() && state.host_deadline <= observed_at) + .min_by_key(|state| state.host_deadline) + .cloned() + else { + return false; + }; + for state in responses.iter().filter(|state| state.active.get()) { + if Rc::ptr_eq(state, &winner) { + state.request_terminal(ResponseEgressOutcome::DeadlineExceeded); + } else { + state.request_terminal(ResponseEgressOutcome::TransportError); + } + } + true + } + + pub(crate) fn signal_transport_error(&self) { + for state in self + .inner + .responses + .borrow() + .iter() + .filter(|state| state.active.get()) + { + state.request_terminal(ResponseEgressOutcome::TransportError); + } + } + + pub(crate) async fn changed(&self) { + self.inner.changed.notified().await; + } + + #[cfg(test)] + fn is_empty(&self) -> bool { + self.inner.responses.borrow().is_empty() + } +} + +enum ResponseSource { + Done, + Once(Option), + Stream(BodyStream), +} + +struct ActiveEgress { + attempt: ResponseEgressAttempt, + clock: MonotonicClock, + deadline: Deadline, + kind: EgressKind, + state: Rc, +} + +/// Private Hyper body that owns one response-egress attempt through source completion or drop. +pub(crate) struct AxumEgressBody { + active: Option, + remaining_hint: Option, + source: ResponseSource, + staged_final: Option, +} + +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "constructors precede coordinator transitions and query helpers" +)] +impl AxumEgressBody { + #[cfg(test)] + pub(crate) fn application( + body: Body, + deadline: Deadline, + clock: MonotonicClock, + attempt: ResponseEgressAttempt, + connection: &EgressConnection, + ) -> Result> { + Self::owned( + body, + None, + true, + deadline, + clock, + attempt, + EgressKind::Application, + connection, + ) + } + + fn detached(bytes: Bytes) -> Self { + Self { + active: None, + remaining_hint: u64::try_from(bytes.len()).ok(), + source: if bytes.is_empty() { + ResponseSource::Done + } else { + ResponseSource::Once(Some(bytes)) + }, + staged_final: None, + } + } + + #[expect( + clippy::too_many_arguments, + reason = "the private constructor keeps the complete response-lifetime contract explicit" + )] + fn owned( + body: Body, + declared_length: Option, + transmits_body: bool, + deadline: Deadline, + clock: MonotonicClock, + mut attempt: ResponseEgressAttempt, + kind: EgressKind, + connection: &EgressConnection, + ) -> Result> { + let Some(state) = connection.register(deadline, &clock) else { + return Err(Box::new(attempt)); + }; + if !attempt.begin_writing() { + state.unregister(); + return Err(Box::new(attempt)); + } + let (source, inferred_length) = if transmits_body { + match body { + Body::Once(bytes) if bytes.is_empty() => (ResponseSource::Done, Some(0)), + Body::Once(bytes) => { + let length = u64::try_from(bytes.len()).ok(); + (ResponseSource::Once(Some(bytes)), length) } - Ok::, anyhow::Error>(buf) - }); - match result { - Ok(buf) => AxumBody::from(buf), - Err(err) => { - error!("streaming response error: {err}"); - return error_response_500("streaming response error"); + Body::Stream(stream) => (ResponseSource::Stream(stream), None), + } + } else { + drop(body); + (ResponseSource::Done, Some(0)) + }; + Ok(Self { + active: Some(ActiveEgress { + attempt, + clock, + deadline, + kind, + state, + }), + remaining_hint: declared_length.or(inferred_length), + source, + staged_final: None, + }) + } + + fn account(&mut self, length: usize) -> Result<(), AxumBodyError> { + let Ok(accepted_length) = u64::try_from(length) else { + self.fail(ResponseEgressOutcome::TransportError); + return Err(AxumBodyError(ResponseEgressOutcome::TransportError)); + }; + if self.active.as_mut().is_some_and(|active| { + !active + .attempt + .account_bytes(accepted_length, active.clock.now()) + }) { + self.fail(ResponseEgressOutcome::TransportError); + return Err(AxumBodyError(ResponseEgressOutcome::TransportError)); + } + if let Some(remaining) = self.remaining_hint.as_mut() { + *remaining = remaining.saturating_sub(accepted_length); + } + Ok(()) + } + + fn fail(&mut self, outcome: ResponseEgressOutcome) { + self.source = ResponseSource::Done; + self.staged_final = None; + self.remaining_hint = Some(0); + let Some(mut active) = self.active.take() else { + return; + }; + let observed_at = if outcome == ResponseEgressOutcome::DeadlineExceeded { + active.clock.now().max(active.deadline.instant()) + } else { + active.clock.now() + }; + match active.kind { + EgressKind::Application => { + active.attempt.terminate(outcome, observed_at); + } + EgressKind::Fallback => { + active + .attempt + .finish_fallback(ResponseEgressFallbackDisposition::Aborted, observed_at); + } + } + active.state.unregister(); + } + + fn poll_chunk(&mut self, cx: &mut Context<'_>) -> Poll>> { + if self.staged_final.is_some() { + return self.poll_final_eof(cx); + } + let next = match &mut self.source { + ResponseSource::Done => Poll::Ready(None), + ResponseSource::Once(bytes) => { + let final_bytes = bytes.take(); + self.source = ResponseSource::Done; + Poll::Ready(final_bytes.map(Ok)) + } + ResponseSource::Stream(stream) => stream.as_mut().poll_next(cx), + }; + if matches!(self.source, ResponseSource::Stream(_)) + && let Some(remaining) = self.remaining_hint + && let Poll::Ready(Some(Ok(bytes))) = &next + && u64::try_from(bytes.len()) == Ok(remaining) + { + self.staged_final = Some(bytes.clone()); + return self.poll_final_eof(cx); + } + next + } + + // Hyper stops polling once Content-Length is satisfied, so validate EOF before handing it + // the final frame. At most that one source-controlled frame is retained during lookahead. + fn poll_final_eof(&mut self, cx: &mut Context<'_>) -> Poll>> { + let ResponseSource::Stream(stream) = &mut self.source else { + return Poll::Ready(Some(Err(EdgeError::internal(anyhow::anyhow!( + "response EOF lookahead lost its source" + ))))); + }; + match stream.as_mut().poll_next(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(None) => { + self.source = ResponseSource::Done; + Poll::Ready(self.staged_final.take().map(Ok)) + } + Poll::Ready(Some(Ok(bytes))) if bytes.is_empty() => { + cx.waker().wake_by_ref(); + Poll::Pending + } + Poll::Ready(Some(Ok(_bytes))) => Poll::Ready(Some(Err(EdgeError::internal( + ResponseEgressBodyLengthError::Exceeded, + )))), + Poll::Ready(Some(Err(error))) => Poll::Ready(Some(Err(error))), + } + } + + fn finish(&mut self) { + let Some(mut active) = self.active.take() else { + return; + }; + let observed_at = active.clock.now(); + match active.kind { + EgressKind::Application => { + active + .attempt + .terminate(ResponseEgressOutcome::HostHandoff, observed_at); + } + EgressKind::Fallback => { + active + .attempt + .finish_fallback(ResponseEgressFallbackDisposition::Completed, observed_at); + } + } + active.state.unregister(); + } + + fn requested_failure(&self) -> Option { + let active = self.active.as_ref()?; + active.state.requested_terminal.get() + } + + fn deadline_expired(&self) -> bool { + self.active + .as_ref() + .is_some_and(|active| active.deadline.is_expired_at(active.clock.now())) + } +} + +impl Drop for AxumEgressBody { + fn drop(&mut self) { + if self.active.is_none() { + return; + } + if let Some(outcome) = self.requested_failure() { + self.fail(outcome); + return; + } + if matches!( + self.source, + ResponseSource::Done | ResponseSource::Once(None) + ) { + self.finish(); + return; + } + self.fail(ResponseEgressOutcome::TransportError); + } +} + +impl HttpBody for AxumEgressBody { + type Data = Bytes; + type Error = AxumBodyError; + + fn is_end_stream(&self) -> bool { + matches!( + self.source, + ResponseSource::Done | ResponseSource::Once(None) + ) && self.staged_final.is_none() + } + + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + let this = self.get_mut(); + if this.active.is_none() && matches!(this.source, ResponseSource::Done) { + return Poll::Ready(None); + } + if let Some(outcome) = this.requested_failure() { + this.fail(outcome); + return Poll::Ready(Some(Err(AxumBodyError(outcome)))); + } + if this.deadline_expired() { + this.fail(ResponseEgressOutcome::DeadlineExceeded); + return Poll::Ready(Some(Err(AxumBodyError( + ResponseEgressOutcome::DeadlineExceeded, + )))); + } + + let next = this.poll_chunk(cx); + if !matches!(next, Poll::Pending) { + if let Some(outcome) = this.requested_failure() { + this.fail(outcome); + return Poll::Ready(Some(Err(AxumBodyError(outcome)))); + } + if this.deadline_expired() { + this.source = ResponseSource::Done; + this.fail(ResponseEgressOutcome::DeadlineExceeded); + return Poll::Ready(Some(Err(AxumBodyError( + ResponseEgressOutcome::DeadlineExceeded, + )))); + } + } + match next { + Poll::Pending => Poll::Pending, + Poll::Ready(Some(Ok(bytes))) => { + if let Err(error) = this.account(bytes.len()) { + return Poll::Ready(Some(Err(error))); + } + Poll::Ready(Some(Ok(Frame::data(bytes)))) + } + Poll::Ready(Some(Err(error))) => { + log::warn!( + "response-egress Axum body source failed after commit: {}", + response_egress_source_error_category(&error) + ); + this.source = ResponseSource::Done; + this.fail(ResponseEgressOutcome::SourceError); + Poll::Ready(Some(Err(AxumBodyError(ResponseEgressOutcome::SourceError)))) + } + Poll::Ready(None) => { + this.source = ResponseSource::Done; + this.finish(); + Poll::Ready(None) + } + } + } + + fn size_hint(&self) -> SizeHint { + let mut hint = SizeHint::new(); + if let Some(remaining) = self.remaining_hint { + hint.set_exact(remaining); + } + hint + } +} + +/// Converts one owned core envelope into the private body used by `EdgeZero`'s Hyper server. +pub(crate) async fn prepare_egress_response( + egress: ResponseEgressEnvelope, + connection: &EgressConnection, +) -> Response { + let request_method = egress.request_method().clone(); + match egress.begin() { + Ok((prepared, policy, attempt, clock)) => { + if policy.write_deadline.is_expired_at(clock.now()) { + return prepare_fallback( + &request_method, + ResponseEgressOutcome::DeadlineExceeded, + attempt, + &clock, + connection, + ); + } + let validated = match validate_empty_stream( + prepared, + &request_method, + policy.write_deadline, + &clock, + ) + .await + { + Ok(validated) => validated, + Err(cause) => { + return prepare_fallback(&request_method, cause, attempt, &clock, connection); + } + }; + match start_prepared( + validated, + policy.write_deadline, + attempt, + clock.clone(), + EgressKind::Application, + connection, + ) { + Ok(response) => response, + Err(returned_attempt) => { + let cause = if policy.write_deadline.is_expired_at(clock.now()) { + ResponseEgressOutcome::DeadlineExceeded + } else { + ResponseEgressOutcome::ConversionError + }; + prepare_fallback( + &request_method, + cause, + *returned_attempt, + &clock, + connection, + ) } } } + Err(failure) => { + let (attempt, clock, cause) = failure.into_parts(); + prepare_fallback(&request_method, cause, attempt, &clock, connection) + } + } +} + +async fn validate_empty_stream( + prepared: PreparedResponseEgress, + request_method: &Method, + deadline: Deadline, + clock: &MonotonicClock, +) -> Result { + if !prepared.transmits_body() || prepared.declared_length() != Some(0) { + return Ok(prepared); + } + let mut response = prepared.into_response(); + if let Body::Stream(mut source) = mem::take(response.body_mut()) { + let remaining = deadline + .remaining_at(clock.now()) + .ok_or(ResponseEgressOutcome::DeadlineExceeded)?; + let validation = async { + loop { + if deadline.is_expired_at(clock.now()) { + return Err(ResponseEgressOutcome::DeadlineExceeded); + } + let next = source.next().await; + if deadline.is_expired_at(clock.now()) { + return Err(ResponseEgressOutcome::DeadlineExceeded); + } + match next { + None => return Ok(()), + Some(Ok(bytes)) if bytes.is_empty() => yield_now().await, + Some(Ok(_bytes)) => return Err(ResponseEgressOutcome::ConversionError), + Some(Err(error)) => { + log::warn!( + "response-egress Axum zero-length source failed before commit: {}", + response_egress_source_error_category(&error) + ); + return Err(ResponseEgressOutcome::ConversionError); + } + } + } + }; + timeout(remaining, validation) + .await + .map_err(|_elapsed| ResponseEgressOutcome::DeadlineExceeded)??; + } + prepare_response_egress(request_method, response) + .map_err(|_error| ResponseEgressOutcome::ConversionError) +} + +fn start_prepared( + prepared: PreparedResponseEgress, + deadline: Deadline, + attempt: ResponseEgressAttempt, + clock: MonotonicClock, + kind: EgressKind, + connection: &EgressConnection, +) -> Result, Box> { + let declared_length = prepared.declared_length(); + let transmits_body = prepared.transmits_body(); + let (parts, core_body) = prepared.into_response().into_parts(); + match AxumEgressBody::owned( + core_body, + declared_length, + transmits_body, + deadline, + clock, + attempt, + kind, + connection, + ) { + Ok(egress_body) => Ok(Response::from_parts(parts, egress_body)), + Err(returned_attempt) => Err(returned_attempt), + } +} + +fn prepare_fallback( + request_method: &Method, + cause: ResponseEgressOutcome, + mut attempt: ResponseEgressAttempt, + clock: &MonotonicClock, + connection: &EgressConnection, +) -> Response { + if !attempt.begin_fallback(cause) { + attempt.terminate(ResponseEgressOutcome::ConversionError, clock.now()); + return detached_fallback(request_method, ResponseEgressOutcome::ConversionError); + } + let started_at = clock.now(); + let Some(fallback_deadline) = started_at.checked_add(RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET) + else { + attempt.finish_fallback(ResponseEgressFallbackDisposition::Aborted, clock.now()); + return detached_fallback(request_method, cause); + }; + let fallback = fallback_response(cause); + let Ok(prepared) = prepare_response_egress(request_method, fallback) else { + attempt.finish_fallback(ResponseEgressFallbackDisposition::Aborted, clock.now()); + return detached_fallback(request_method, cause); }; + match start_prepared( + prepared, + Deadline::at_instant(fallback_deadline), + attempt, + clock.clone(), + EgressKind::Fallback, + connection, + ) { + Ok(response) => response, + Err(mut returned_attempt) => { + returned_attempt + .finish_fallback(ResponseEgressFallbackDisposition::Aborted, clock.now()); + detached_fallback(request_method, cause) + } + } +} - Response::from_parts(parts, body) +fn fallback_response(cause: ResponseEgressOutcome) -> CoreResponse { + let (status, body) = fallback_parts(cause); + let mut response = CoreResponse::new(Body::from(body)); + *response.status_mut() = status; + response } -/// Build a minimal 500 response without any builder steps that could fail. -/// Used as a fallback on the request path so we never panic on synthesis. -fn error_response_500(message: &'static str) -> Response { - let mut response = Response::new(AxumBody::from(message)); - *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; - response.headers_mut().insert( - CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); +fn detached_fallback( + request_method: &Method, + cause: ResponseEgressOutcome, +) -> Response { + let (status, body) = fallback_parts(cause); + let payload = if *request_method == Method::HEAD { + Bytes::new() + } else { + Bytes::from_static(body) + }; + let mut response = Response::new(AxumEgressBody::detached(payload)); + *response.status_mut() = status; response } +const fn fallback_parts(cause: ResponseEgressOutcome) -> (StatusCode, &'static [u8]) { + if matches!(cause, ResponseEgressOutcome::DeadlineExceeded) { + (StatusCode::GATEWAY_TIMEOUT, TIMEOUT_FALLBACK_BODY) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, INTERNAL_FALLBACK_BODY) + } +} + #[cfg(test)] mod tests { - use super::*; + use std::collections::VecDeque; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::task::Poll; + use std::time::Duration; + + use bytes::Bytes; use edgezero_core::body::Body; - use edgezero_core::http::{StatusCode, response_builder}; - use futures::stream; - - #[test] - fn converts_core_response_stream_into_axum_body() { - let stream = stream::iter(vec![ - Ok::<_, anyhow::Error>(bytes::Bytes::from_static(b"hel")), - Ok(bytes::Bytes::from_static(b"lo")), - ]); - let body = Body::from_stream(stream); - let response = response_builder() - .status(StatusCode::OK) - .header("content-type", "text/plain") - .body(body) - .expect("response"); + use edgezero_core::http::{HeaderMap, Method, StatusCode, Version, response_builder}; + use edgezero_core::response_egress::{ + ResponseEgressAttempt, ResponseEgressCompletion, ResponseEgressHead, + ResponseEgressObserver, ResponseEgressObserverHandle, ResponseEgressOutcome, + ResponseEgressReport, + }; + use edgezero_core::response_egress_framing::prepare_response_egress; + use edgezero_core::router::RouteMetadata; + use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; + use futures_util::FutureExt as _; + use futures_util::future::poll_fn as poll_future; + use futures_util::stream::{pending, poll_fn as poll_stream}; + use http_body::Body as _; - let axum_response = into_axum_response(response); - assert_eq!(axum_response.status(), StatusCode::OK); + use super::{ + AxumEgressBody, EgressConnection, EgressKind, INTERNAL_FALLBACK_BODY, + TIMEOUT_FALLBACK_BODY, prepare_fallback, start_prepared, + }; + + #[derive(Clone, Default)] + struct RecordingObserver(Arc>>); + + impl ResponseEgressObserver for RecordingObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.0.lock().expect("reports lock").push(report.clone()); + } + } + + fn scripted_clock(script: Vec) -> MonotonicClock { + let observations = Arc::new(Mutex::new(VecDeque::from(script))); + MonotonicClock::new(move || { + observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + }) + } + + fn attempt(observer: &RecordingObserver, now: MonotonicInstant) -> ResponseEgressAttempt { + let headers = HeaderMap::new(); + let route = RouteMetadata::new(Method::GET, "/test"); + let head = ResponseEgressHead::new( + StatusCode::OK, + Version::HTTP_11, + &headers, + None, + now, + Some(&route), + ); + ResponseEgressAttempt::new( + &head, + now, + ResponseEgressCompletion::empty(), + ResponseEgressObserverHandle::new(observer.clone()), + MonotonicClock::default(), + ) + } + + #[tokio::test(flavor = "current_thread")] + async fn body_is_lazy_and_accounts_each_frame_before_clean_handoff() { + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let source_polls = Arc::clone(&polls); + let source = poll_stream(move |_cx| { + let poll_index = source_polls.fetch_add(1, Ordering::SeqCst); + Poll::Ready(match poll_index { + 0 => Some(Ok(Bytes::from_static(b"abc"))), + 1 => Some(Ok(Bytes::from_static(b"de"))), + _ => None, + }) + }); + let now = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || now); + let deadline = + Deadline::at_instant(now.checked_add(Duration::from_secs(1)).expect("deadline")); + let connection = EgressConnection::default(); + let body_result = AxumEgressBody::application( + Body::from_stream(source), + deadline, + clock, + attempt(&observer, now), + &connection, + ); + let Ok(registered_body) = body_result else { + panic!("register body"); + }; + let mut pinned_body = Box::pin(registered_body); + + assert_eq!(polls.load(Ordering::SeqCst), 0); + let first = poll_future(|cx| pinned_body.as_mut().poll_frame(cx)) + .await + .expect("first frame") + .expect("first frame result") + .into_data() + .expect("first data"); + assert_eq!(first, Bytes::from_static(b"abc")); + assert!(observer.0.lock().expect("reports lock").is_empty()); + + let second = poll_future(|cx| pinned_body.as_mut().poll_frame(cx)) + .await + .expect("second frame") + .expect("second frame result") + .into_data() + .expect("second data"); + assert_eq!(second, Bytes::from_static(b"de")); + assert!( + poll_future(|cx| pinned_body.as_mut().poll_frame(cx)) + .await + .is_none() + ); + + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].bytes_written, 5); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::HostHandoff); + assert!(connection.is_empty()); + } + + #[tokio::test(flavor = "current_thread")] + async fn dropping_unfinished_body_reports_transport_error_once() { + let observer = RecordingObserver::default(); + let now = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || now); + let deadline = + Deadline::at_instant(now.checked_add(Duration::from_secs(1)).expect("deadline")); + let connection = EgressConnection::default(); + let body_result = AxumEgressBody::application( + Body::from_stream(pending()), + deadline, + clock, + attempt(&observer, now), + &connection, + ); + let Ok(registered_body) = body_result else { + panic!("register body"); + }; + + drop(registered_body); + + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].bytes_written, 0); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::TransportError); + assert!(connection.is_empty()); + } + + #[tokio::test(flavor = "current_thread")] + async fn dropping_handed_off_frame_after_connection_abort_reports_transport_error() { + let observer = RecordingObserver::default(); + let now = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || now); + let deadline = + Deadline::at_instant(now.checked_add(Duration::from_secs(1)).expect("deadline")); + let connection = EgressConnection::default(); + let body_result = AxumEgressBody::application( + Body::from(Bytes::from_static(b"sent to Hyper")), + deadline, + clock, + attempt(&observer, now), + &connection, + ); + let Ok(body) = body_result else { + panic!("register body"); + }; + let mut pinned_body = Box::pin(body); + + let frame = poll_future(|cx| pinned_body.as_mut().poll_frame(cx)) + .await + .expect("one frame") + .expect("data frame"); assert_eq!( - axum_response - .headers() - .get("content-type") - .expect("header") - .to_str() - .unwrap(), - "text/plain" + frame.into_data().expect("data"), + Bytes::from_static(b"sent to Hyper") + ); + assert!(observer.0.lock().expect("reports lock").is_empty()); + + connection.signal_transport_error(); + drop(pinned_body); + + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::TransportError); + assert!(connection.is_empty()); + } + + #[tokio::test(flavor = "current_thread")] + async fn response_egress_post_ready_deadline_precedence() { + let observer = RecordingObserver::default(); + let now = MonotonicInstant::now(); + let deadline = now.checked_add(Duration::from_secs(1)).expect("deadline"); + let clock = scripted_clock(vec![now, now, deadline, deadline]); + let connection = EgressConnection::default(); + let body_result = AxumEgressBody::application( + Body::from_stream(poll_stream(|_context| { + Poll::Ready(Some(Ok(Bytes::from_static(b"late")))) + })), + Deadline::at_instant(deadline), + clock, + attempt(&observer, now), + &connection, ); + let Ok(body) = body_result else { + panic!("register body"); + }; + let mut pinned_body = Box::pin(body); + + let error = poll_future(|context| pinned_body.as_mut().poll_frame(context)) + .await + .expect("terminal frame") + .expect_err("deadline must win over ready source data"); + + assert!(error.is_deadline()); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].bytes_written, 0); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + assert!(connection.is_empty()); + } + + #[tokio::test(flavor = "current_thread")] + async fn expired_fallback_registration_preserves_get_payload_and_head_suppression() { + for (method, cause, payload) in [ + ( + Method::GET, + ResponseEgressOutcome::DeadlineExceeded, + TIMEOUT_FALLBACK_BODY, + ), + ( + Method::HEAD, + ResponseEgressOutcome::DeadlineExceeded, + TIMEOUT_FALLBACK_BODY, + ), + ( + Method::GET, + ResponseEgressOutcome::ConversionError, + INTERNAL_FALLBACK_BODY, + ), + ( + Method::HEAD, + ResponseEgressOutcome::ConversionError, + INTERNAL_FALLBACK_BODY, + ), + ] { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let clock_polls = Arc::new(AtomicUsize::new(0)); + let clock = MonotonicClock::new(move || { + let tick = + u64::try_from(clock_polls.fetch_add(1, Ordering::SeqCst)).expect("clock tick"); + started_at + .checked_add(Duration::from_secs(tick * 2)) + .expect("clock sample") + }); + let connection = EgressConnection::default(); + let response = prepare_fallback( + &method, + cause, + attempt(&observer, started_at), + &clock, + &connection, + ); + let status = if cause == ResponseEgressOutcome::DeadlineExceeded { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + assert_eq!(response.status(), status); + let mut body = Box::pin(response.into_body()); + if method == Method::HEAD { + assert!(body.is_end_stream()); + assert!( + poll_future(|context| body.as_mut().poll_frame(context)) + .await + .is_none() + ); + } else { + assert!(!body.is_end_stream(), "detached payload is not exhausted"); + let frame = poll_future(|context| body.as_mut().poll_frame(context)) + .await + .expect("fallback frame") + .expect("fallback result"); + assert_eq!(frame.into_data().expect("payload"), payload); + assert!( + body.is_end_stream(), + "final detached frame exhausts the source" + ); + assert!( + poll_future(|context| body.as_mut().poll_frame(context)) + .await + .is_none() + ); + } + assert!(connection.is_empty()); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, cause); + } + } - let collected = block_on(async { - let mut data = Vec::new(); - let mut body_stream = axum_response.into_body().into_data_stream(); - while let Some(result) = body_stream.next().await { - let chunk = result.expect("chunk"); - data.extend_from_slice(&chunk); + #[tokio::test(flavor = "current_thread")] + async fn declared_stream_is_incremental_and_releases_staged_data_on_failure() { + struct DropProbe(Arc); + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let observer = RecordingObserver::default(); + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let source_polls = Arc::clone(&polls); + let drop_probe = DropProbe(Arc::clone(&drops)); + let source = poll_stream(move |_context| { + let _keep_source_alive = &drop_probe; + match source_polls.fetch_add(1, Ordering::SeqCst) { + 0 => Poll::Ready(Some(Ok(Bytes::from_static(b"ab")))), + 1 => Poll::Ready(Some(Ok(Bytes::from_static(b"c")))), + _ => Poll::Pending, } - data }); + let response = response_builder() + .header("content-length", "3") + .body(Body::from_stream(source)) + .expect("response"); + let prepared = prepare_response_egress(&Method::GET, response).expect("prepared response"); + let started_at = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || started_at); + let connection = EgressConnection::default(); + let Ok(registered_response) = start_prepared( + prepared, + clock.deadline_after(Duration::from_secs(1)), + attempt(&observer, started_at), + clock, + EgressKind::Application, + &connection, + ) else { + panic!("registered response"); + }; + let mut body = Box::pin(registered_response.into_body()); + + let first = poll_future(|context| body.as_mut().poll_frame(context)) + .await + .expect("first frame") + .expect("valid frame"); + assert_eq!(first.into_data().expect("data"), "ab"); + assert_eq!(polls.load(Ordering::SeqCst), 1, "no eager whole-body read"); + assert!( + poll_future(|context| body.as_mut().poll_frame(context)) + .now_or_never() + .is_none() + ); + assert_eq!(polls.load(Ordering::SeqCst), 3); + assert_eq!(drops.load(Ordering::SeqCst), 0); - assert_eq!(collected, b"hello"); + connection.signal_transport_error(); + let error = poll_future(|context| body.as_mut().poll_frame(context)) + .await + .expect("failure frame") + .expect_err("transport signal wins"); + assert_eq!(error.0, ResponseEgressOutcome::TransportError); + assert!(body.is_end_stream()); + assert_eq!( + drops.load(Ordering::SeqCst), + 1, + "failed source drops before body drop" + ); + assert!( + poll_future(|context| body.as_mut().poll_frame(context)) + .await + .is_none() + ); + drop(body); + let reports = observer.0.lock().expect("reports lock"); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].bytes_written, 2); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::TransportError); + assert!(connection.is_empty()); } } diff --git a/crates/edgezero-adapter-axum/src/secret_store.rs b/crates/edgezero-adapter-axum/src/secret_store.rs index abf1c3d03..70ac1f058 100644 --- a/crates/edgezero-adapter-axum/src/secret_store.rs +++ b/crates/edgezero-adapter-axum/src/secret_store.rs @@ -13,6 +13,7 @@ use std::env; use async_trait::async_trait; use bytes::Bytes; use edgezero_core::secret_store::{SecretError, SecretStore}; +use edgezero_core::{BoundedStoreRead, Deadline, MonotonicClock}; use crate::secret_env::secret_env_var; @@ -68,6 +69,82 @@ impl SecretStore for EnvSecretStore { } } } + + #[inline] + async fn get_bytes_bounded( + &self, + _store_name: &str, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + if deadline.is_expired_at(clock.now()) { + return Err(SecretError::DeadlineExceeded); + } + + let var = secret_env_var(key).map_err(SecretError::Validation)?; + + #[cfg(unix)] + let stored_value = env::var_os(&var); + + #[cfg(not(unix))] + let stored_value = env::var(&var); + + if deadline.is_expired_at(clock.now()) { + return Err(SecretError::DeadlineExceeded); + } + + #[cfg(not(unix))] + let stored_value = match stored_value { + Ok(value) => Some(value), + Err(std::env::VarError::NotPresent) => None, + Err(std::env::VarError::NotUnicode(_)) => { + return Err(SecretError::Internal(anyhow::anyhow!( + "secret store returned an invalid Unicode value" + ))); + } + }; + + #[cfg(unix)] + let backend_bytes = { + use std::os::unix::ffi::OsStrExt as _; + + stored_value.as_ref().map_or(Ok(0_u64), |value| { + u64::try_from(value.as_os_str().as_bytes().len()) + .map_err(|_length_error| SecretError::ValueTooLarge) + })? + }; + + #[cfg(not(unix))] + let backend_bytes = stored_value.as_ref().map_or(Ok(0_u64), |value| { + u64::try_from(value.len()).map_err(|_length_error| SecretError::ValueTooLarge) + })?; + + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + return Err(SecretError::ValueTooLarge); + } + + #[cfg(unix)] + let value = { + use std::os::unix::ffi::OsStringExt as _; + + stored_value.map(|value| Bytes::from(value.into_vec())) + }; + + #[cfg(not(unix))] + let value = stored_value.map(|value| Bytes::from(value.into_bytes())); + + if deadline.is_expired_at(clock.now()) { + return Err(SecretError::DeadlineExceeded); + } + + Ok(BoundedStoreRead { + backend_bytes, + value, + }) + } } #[cfg(test)] @@ -85,17 +162,201 @@ mod tests { use super::*; use crate::test_utils::env_guard; use bytes::Bytes; - use edgezero_core::secret_store::InMemorySecretStore; + use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; use edgezero_core::secret_store_contract_tests; use edgezero_core::test_env::EnvOverride; + use edgezero_core::{Deadline, MonotonicClock, MonotonicInstant}; use futures::executor::block_on; #[cfg(unix)] use std::ffi::OsString; + use std::sync::Arc; + use std::time::Duration; + + #[tokio::test(flavor = "current_thread")] + async fn bounded_get_bytes_accepts_exact_caps_and_reports_exact_backend_bytes() { + let _guard = env_guard().lock().await; + let _env = EnvOverride::set( + "EDGEZERO__SECRETS____EDGEZERO_TEST_BOUNDED_SECRET__", + "hello", + ); + let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); + let clock = MonotonicClock::default(); + + let read = handle + .get_bytes_bounded( + "env", + "__EDGEZERO_TEST_BOUNDED_SECRET__", + &clock, + Deadline::after(Duration::from_secs(1)), + 5, + 5, + ) + .await + .expect("exact cap must succeed"); + + assert_eq!(read.backend_bytes, 5); + assert_eq!(read.value, Some(Bytes::from_static(b"hello"))); + } + + #[tokio::test(flavor = "current_thread")] + async fn bounded_get_bytes_rejects_either_cap() { + let _guard = env_guard().lock().await; + let _env = EnvOverride::set( + "EDGEZERO__SECRETS____EDGEZERO_TEST_OVERSIZE_SECRET__", + "hello", + ); + let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); + let clock = MonotonicClock::default(); + + for (max_backend_bytes, max_value_bytes) in [(4, 5), (5, 4)] { + let error = handle + .get_bytes_bounded( + "env", + "__EDGEZERO_TEST_OVERSIZE_SECRET__", + &clock, + Deadline::after(Duration::from_secs(1)), + max_backend_bytes, + max_value_bytes, + ) + .await + .expect_err("over-cap secret must fail"); + + assert!(matches!(error, SecretError::ValueTooLarge)); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn bounded_get_bytes_rejects_an_expired_deadline() { + let _guard = env_guard().lock().await; + let _env = EnvOverride::set( + "EDGEZERO__SECRETS____EDGEZERO_TEST_EXPIRED_SECRET__", + "hello", + ); + let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); + let expired = Deadline::at_instant(MonotonicInstant::now()); + let clock = MonotonicClock::default(); + + let error = handle + .get_bytes_bounded( + "env", + "__EDGEZERO_TEST_EXPIRED_SECRET__", + &clock, + expired, + 5, + 5, + ) + .await + .expect_err("expired deadline must fail"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + } + + #[tokio::test(flavor = "current_thread")] + async fn bounded_get_bytes_preserves_handle_name_validation() { + let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); + let clock = MonotonicClock::default(); + + let error = handle + .get_bytes_bounded( + "", + "key", + &clock, + Deadline::after(Duration::from_secs(1)), + 5, + 5, + ) + .await + .expect_err("empty store name must be rejected"); + + assert!(matches!(error, SecretError::Validation(_))); + } + + #[tokio::test(flavor = "current_thread")] + async fn bounded_secret_read_uses_injected_clock() { + let _guard = env_guard().lock().await; + let _env = EnvOverride::set( + "EDGEZERO__SECRETS____EDGEZERO_TEST_CLOCKED_SECRET__", + "hello", + ); + let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); + let process_now = MonotonicInstant::now(); + let injected_now = process_now + .checked_sub(Duration::from_mins(1)) + .expect("injected instant"); + let clock = MonotonicClock::new(move || injected_now); + let deadline = Deadline::at_instant( + injected_now + .checked_add(Duration::from_secs(1)) + .expect("deadline instant"), + ); + + let read = handle + .get_bytes_bounded( + "env", + "__EDGEZERO_TEST_CLOCKED_SECRET__", + &clock, + deadline, + 5, + 5, + ) + .await + .expect("the application clock is still before its deadline"); + + assert_eq!(read.value, Some(Bytes::from_static(b"hello"))); + } fn read(key: &str) -> Result, SecretError> { block_on(EnvSecretStore::new().get_bytes("env", key)) } + #[tokio::test(flavor = "current_thread")] + async fn bounded_and_unbounded_reads_share_prefixed_secret_mapping() { + let _guard = env_guard().lock().await; + let _bare = EnvOverride::set("EDGEZERO_TEST_BOUNDED_MAPPING", "bare"); + let prefixed_guard = EnvOverride::set( + "EDGEZERO__SECRETS__EDGEZERO_TEST_BOUNDED_MAPPING", + "prefixed", + ); + let store = EnvSecretStore::new(); + let clock = MonotonicClock::default(); + let deadline = clock.deadline_after(Duration::from_secs(1)); + let bounded = store + .get_bytes_bounded( + "env", + "edgezero_test_bounded_mapping", + &clock, + deadline, + 8, + 8, + ) + .await + .unwrap(); + assert_eq!(bounded.value, Some(Bytes::from_static(b"prefixed"))); + assert_eq!(bounded.backend_bytes, 8); + assert_eq!( + bounded.value, + store + .get_bytes("env", "edgezero_test_bounded_mapping") + .await + .unwrap() + ); + + drop(prefixed_guard); + let _missing = EnvOverride::remove("EDGEZERO__SECRETS__EDGEZERO_TEST_BOUNDED_MAPPING"); + let missing = store + .get_bytes_bounded( + "env", + "EDGEZERO_TEST_BOUNDED_MAPPING", + &clock, + deadline, + 8, + 8, + ) + .await + .unwrap(); + assert!(missing.value.is_none()); + } + #[cfg(unix)] #[test] fn get_bytes_preserves_non_utf8_secret_values() { diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index ecaf9b1c1..4f5925afa 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -1,51 +1,88 @@ -use std::convert::Infallible; use std::future::Future; +use std::net::SocketAddr; use std::pin::Pin; +use std::sync::Arc; +#[cfg(test)] use std::task::{Context, Poll}; use axum::body::Body as AxumBody; +use axum::extract::connect_info::ConnectInfo; use axum::http::{Request, Response}; +use edgezero_core::app::App; use edgezero_core::config_store::ConfigStoreHandle; -use edgezero_core::http::StatusCode; +use edgezero_core::error::EdgeError; +use edgezero_core::http::Method; +use edgezero_core::ingress::{ + IngressBeginOutcome, IngressDispatchOutcome, IngressFraming, IngressHeadAccounting, + IngressHeadParts, validate_normalized_ingress_parts, +}; use edgezero_core::key_value_store::KvHandle; +#[cfg(test)] use edgezero_core::router::RouterService; use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, }; -use tokio::{runtime::Handle, task}; -use tower::Service; +use edgezero_core::time::MonotonicInstant; +use hyper::body::Incoming; +use hyper::service::Service as HyperService; +#[cfg(test)] +use tower::Service as TowerService; -use crate::request::into_core_request; -use crate::response::into_axum_response; +#[cfg(test)] +use crate::connection::{ConnectionExit, serve_http1}; +#[cfg(test)] +use crate::ingress_config::AxumIngressConfig; +use crate::outbound::AxumOutboundClient; +use crate::request::into_core_request_parts; +use crate::response::{AxumEgressBody, EgressConnection, prepare_egress_response}; + +/// Private Hyper service error used only to close/reset an admission-aborted connection. +#[derive(Debug, thiserror::Error)] +#[error("ingress admission aborted")] +pub(crate) struct AxumIngressAbort; -/// Tower service that adapts `EdgeZero` router requests to Axum/Hyper compatible responses. +/// Shared application state used to construct one private Hyper service per HTTP/1 connection. #[derive(Clone)] -pub struct EdgeZeroAxumService { +pub(crate) struct AxumServiceState { + app: Arc, config_registry: Option, config_store_handle: Option, kv_handle: Option, kv_registry: Option, - router: RouterService, + outbound_transport: Option>, secret_handle: Option, secret_registry: Option, } -impl EdgeZeroAxumService { +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "constructor and store wiring methods remain grouped ahead of request dispatch" +)] +impl AxumServiceState { + /// Creates a service that preserves all policies configured on `App`. #[must_use] #[inline] - pub fn new(router: RouterService) -> Self { + pub fn from_app(app: App) -> Self { Self { + app: Arc::new(app), config_registry: None, config_store_handle: None, kv_handle: None, kv_registry: None, - router, + outbound_transport: AxumOutboundClient::try_transport().ok(), secret_handle: None, secret_registry: None, } } + #[must_use] + #[inline] + #[cfg(test)] + pub fn new(router: RouterService) -> Self { + Self::from_app(App::new(router)) + } + /// Attach an id-keyed config-store registry to this service. #[must_use] #[inline] @@ -121,30 +158,28 @@ impl EdgeZeroAxumService { self.secret_registry = Some(registry); self } -} -impl Service> for EdgeZeroAxumService { - type Error = Infallible; - type Future = Pin> + Send>>; - type Response = Response; + pub(crate) fn for_connection( + &self, + remote_addr: SocketAddr, + connection: EgressConnection, + ) -> AxumConnectionService { + AxumConnectionService { + connection, + remote_addr, + state: self.clone(), + } + } - #[inline] - fn call(&mut self, req: Request) -> Self::Future { - let router = self.router.clone(); - // Hard-cutoff: legacy bare `KvHandle` / - // `ConfigStoreHandle` / `SecretHandle` entries are NO - // LONGER inserted into request extensions. The legacy - // `with_*_handle` constructors still take a single - // handle, but the dispatcher synthesises a one-id - // `Registry` under the conventional `"default"` - // id from that handle — and only the registry goes into - // extensions. Handlers must use the registry-aware - // `RequestContext` accessors (`kv_store_default`, - // `config_store_default`, `secret_store_default`) or - // the `Kv` / `Config` / `Secrets` extractors. The - // pre-rewrite `ctx.kv_handle()` / `config_handle()` / - // `secret_handle()` accessors are gone (spec - // hard-cutoff). + fn resolved_store_registries( + &self, + ) -> ( + Option, + Option, + Option, + ) { + // Legacy single-handle constructors synthesize only the conventional + // `default` registry entry; request extensions contain registries only. let config_registry = self.config_registry.clone().or_else(|| { self.config_store_handle.clone().map(|handle| { ConfigRegistry::single_id( @@ -169,68 +204,294 @@ impl Service> for EdgeZeroAxumService { ) }) }); - Box::pin(async move { - let mut core_request = match into_core_request(req).await { - Ok(converted) => converted, - Err(err) => { - let mut err_response = Response::new(AxumBody::from(err.clone())); - *err_response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; - - return Ok(err_response); - } - }; + (config_registry, kv_registry, secret_registry) + } - if let Some(registry) = config_registry { - core_request.extensions_mut().insert(registry); + async fn dispatch_request( + &self, + mut request: Request, + connection: &EgressConnection, + remote_addr: Option, + ) -> Result, AxumIngressAbort> { + if let Some(peer_addr) = remote_addr { + request.extensions_mut().insert(ConnectInfo(peer_addr)); + } + let request_start = self.app.monotonic_now(); + let app = Arc::clone(&self.app); + let outbound_transport_handle = self.outbound_transport.clone(); + let (config_registry, kv_registry, secret_registry) = self.resolved_store_registries(); + let (parts, native_body) = request.into_parts(); + let request_method = parts.method.clone(); + if let Err(error) = validate_normalized_ingress_parts(&parts, app.ingress_head_limits()) { + return prepare_detached_ingress_error( + &app, + error, + request_method.clone(), + request_start, + connection, + ) + .await; + } + let head_parts = IngressHeadParts::from_parts( + &parts, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + let prepared = match app.begin_ingress(head_parts, request_start) { + Ok(IngressBeginOutcome::Admitted(prepared)) => prepared, + Ok(IngressBeginOutcome::Refused(response)) => { + return Ok(prepare_egress_response(response, connection).await); } - if let Some(registry) = kv_registry { - core_request.extensions_mut().insert(registry); + Ok(IngressBeginOutcome::Aborted) => return Err(AxumIngressAbort), + Ok(_) => { + return prepare_detached_ingress_error( + &app, + EdgeError::internal(anyhow::anyhow!("unsupported ingress admission outcome")), + request_method, + request_start, + connection, + ) + .await; } - if let Some(registry) = secret_registry { - core_request.extensions_mut().insert(registry); + Err(error) => { + return prepare_detached_ingress_error( + &app, + error, + request_method.clone(), + request_start, + connection, + ) + .await; + } + }; + let read_deadline = prepared.read_deadline(); + let monotonic_clock = prepared.monotonic_clock(); + let Some(outbound_transport) = outbound_transport_handle else { + let egress = app.admitted_error_egress( + prepared, + EdgeError::internal(anyhow::anyhow!( + "failed to initialize outbound HTTP transport" + )), + ); + return Ok(prepare_egress_response(egress, connection).await); + }; + let mut core_request = match into_core_request_parts( + parts, + native_body, + Some((read_deadline, monotonic_clock)), + Some(outbound_transport), + ) { + Ok(converted) => converted, + Err(error) => { + let egress = app.admitted_error_egress( + prepared, + EdgeError::internal(anyhow::anyhow!( + "failed to convert inbound request: {error}" + )), + ); + return Ok(prepare_egress_response(egress, connection).await); } + }; - let core_response = task::block_in_place(move || { - Handle::current().block_on(router.oneshot(core_request)) - }); - let response = match core_response { - Ok(response) => into_axum_response(response), - Err(err) => { - let body = AxumBody::from(format!("internal error: {err}")); - let mut fallback = Response::new(body); - *fallback.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; - fallback - } - }; - Ok(response) - }) + if let Some(registry) = config_registry { + core_request.extensions_mut().insert(registry); + } + if let Some(registry) = kv_registry { + core_request.extensions_mut().insert(registry); + } + if let Some(registry) = secret_registry { + core_request.extensions_mut().insert(registry); + } + + let egress = app.dispatch_admitted(prepared, core_request).await; + Ok(prepare_egress_response(egress, connection).await) } +} + +#[derive(Clone)] +pub(crate) struct AxumConnectionService { + connection: EgressConnection, + remote_addr: SocketAddr, + state: AxumServiceState, +} + +impl HyperService> for AxumConnectionService { + type Error = AxumIngressAbort; + type Future = Pin>>>; + type Response = Response; #[inline] - fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + fn call(&self, req: Request) -> Self::Future { + let state = self.state.clone(); + let connection = self.connection.clone(); + let remote_addr = self.remote_addr; + let converted_request = req.map(AxumBody::new); + Box::pin(async move { + state + .dispatch_request(converted_request, &connection, Some(remote_addr)) + .await + }) + } +} + +#[cfg(test)] +impl TowerService> for AxumServiceState { + type Error = AxumIngressAbort; + type Future = Pin>>>; + type Response = Response; + + fn call(&mut self, req: Request) -> Self::Future { + let state = self.clone(); + Box::pin(async move { + state + .dispatch_request(req, &EgressConnection::default(), None) + .await + }) + } + + fn poll_ready(&mut self, _context: &mut Context<'_>) -> Poll> { Poll::Ready(Ok(())) } } +async fn prepare_detached_ingress_error( + app: &App, + error: EdgeError, + request_method: Method, + request_start: MonotonicInstant, + connection: &EgressConnection, +) -> Result, AxumIngressAbort> { + match app.detached_ingress_error_egress(error, request_method, request_start) { + IngressDispatchOutcome::Response(envelope) => { + Ok(prepare_egress_response(*envelope, connection).await) + } + IngressDispatchOutcome::Aborted | _ => Err(AxumIngressAbort), + } +} + #[cfg(test)] mod tests { + #![expect( + clippy::arbitrary_source_item_ordering, + reason = "shared test collectors precede fixtures and behavior cases" + )] + use super::*; - use axum::body::to_bytes; + use bytes::{Bytes, BytesMut}; use edgezero_core::body::Body; - use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::config_store::{ + BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigStoreHandle, + finish_bounded_config_read, + }; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; - use edgezero_core::http::{StatusCode, response_builder}; + use edgezero_core::http::{ + HeaderMap, HeaderValue, Response as CoreResponse, StatusCode, response_builder, + }; + use edgezero_core::ingress::{ + AdmissionDecision, BufferedIngressResponse, IngressGrant, IngressHeadLimits, + }; use edgezero_core::key_value_store::KvStore; - use std::sync::Arc; + use edgezero_core::middleware::{Middleware, Next}; + use edgezero_core::outbound::OutboundRequest; + use edgezero_core::response_egress::{ + DEFAULT_RESPONSE_WRITE_BUDGET, DetachedResponseEgressDecision, ResponseEgressCompletion, + ResponseEgressFallbackDisposition, ResponseEgressObserver, ResponseEgressOutcome, + ResponseEgressPolicy, ResponseEgressReport, + }; + use edgezero_core::router::{RouteMetadata, RouteResolution}; + use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; + use futures_util::stream::poll_fn; + use http_body::Body as _; + use std::future::poll_fn as poll_future; + use std::io; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::task::Poll; + use std::time::Duration; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + use tokio::net::{TcpListener, TcpStream}; + use tokio::time::{sleep, timeout}; use tower::ServiceExt as _; + async fn to_bytes(mut body: AxumEgressBody, limit: usize) -> Result { + let mut buffered = BytesMut::new(); + loop { + let next_frame = poll_future(|context| Pin::new(&mut body).poll_frame(context)).await; + let Some(frame_result) = next_frame else { + return Ok(buffered.freeze()); + }; + let response_frame = frame_result.map_err(anyhow::Error::new)?; + let Ok(bytes) = response_frame.into_data() else { + continue; + }; + if buffered.len().saturating_add(bytes.len()) > limit { + return Err(anyhow::anyhow!("response body exceeded test limit")); + } + buffered.extend_from_slice(&bytes); + } + } + struct FixedConfigStore(String); + struct CountingMiddleware(Arc); + + struct DropSignal(Arc); + + #[derive(Clone)] + struct RecordingEgressObserver(Arc>>); + + impl ResponseEgressObserver for RecordingEgressObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.0.lock().expect("reports lock").push(report.clone()); + } + } + + #[async_trait::async_trait(?Send)] + impl Middleware for CountingMiddleware { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } + } + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + #[async_trait::async_trait(?Send)] impl ConfigStore for FixedConfigStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) + } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + finish_bounded_config_read( + self.get(key).await, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) } } @@ -245,13 +506,961 @@ mod tests { Ok::<_, EdgeError>(response) }) .build(); - let mut service = EdgeZeroAxumService::new(router); + let mut service = AxumServiceState::new(router); let request = Request::builder().uri("/").body(AxumBody::empty()).unwrap(); let response = service.ready().await.unwrap().call(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn admission_abort_skips_body_handler_middleware_and_egress() { + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_body_polls = Arc::clone(&body_polls); + let native_body = AxumBody::from_stream(poll_fn(move |_cx| { + observed_body_polls.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + })); + let handler_calls = Arc::new(AtomicUsize::new(0)); + let observed_handler_calls = Arc::clone(&handler_calls); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let reports = Arc::new(Mutex::new(Vec::new())); + let router = RouterService::builder() + .post("/upload", move |_ctx: RequestContext| { + let calls = Arc::clone(&observed_handler_calls); + async move { + calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("unexpected") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + let mut app = App::new(router); + app.set_ingress_admission_policy(|_| AdmissionDecision::Abort); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let request = Request::builder() + .method("POST") + .uri("/upload") + .body(native_body) + .expect("request"); + + let result = AxumServiceState::from_app(app).oneshot(request).await; + + assert!(result.is_err()); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + assert_eq!(handler_calls.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + assert!(reports.lock().expect("reports lock").is_empty()); + } + + #[tokio::test(flavor = "current_thread")] + async fn admission_abort_closes_http1_without_response_bytes() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind listener"); + let address = listener.local_addr().expect("listener address"); + let mut app = App::new(RouterService::builder().build()); + app.set_ingress_admission_policy(|_| AdmissionDecision::Abort); + let state = AxumServiceState::from_app(app); + + let server = async move { + let (stream, remote_addr) = listener.accept().await.expect("accept client"); + let responses = EgressConnection::default(); + serve_http1( + stream, + state.for_connection(remote_addr, responses.clone()), + responses, + AxumIngressConfig::default(), + ) + .await + .expect("intentional admission abort") + }; + let client = async move { + let mut stream = TcpStream::connect(address).await.expect("connect client"); + stream + .write_all( + b"POST /missing HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\n\r\ndata", + ) + .await + .expect("write request"); + let mut response = Vec::new(); + let read = stream.read_to_end(&mut response).await; + (read, response) + }; + + let (exit, (read, response)) = tokio::join!(server, client); + assert_eq!(exit, ConnectionExit::AdmissionAborted); + if let Err(error) = read { + assert!(matches!( + error.kind(), + io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe + )); + } + assert!(response.is_empty()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn standard_service_installs_the_exact_application_outbound_clock() { + async fn elapsed(ctx: RequestContext) -> Result { + let client = ctx + .http_client() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing HTTP client")))?; + let request = OutboundRequest::get("https://example.com/")?.stream_response(); + let results = client + .send_all_until(vec![request], Deadline::after(Duration::from_secs(1))) + .await?; + Ok(results.slots[0] + .as_ref() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("unresolved HTTP slot")))? + .elapsed + .as_millis() + .to_string()) + } + + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(7)) + .expect("completed instant"); + let observations = Arc::new(AtomicUsize::new(0)); + let clock_observations = Arc::clone(&observations); + let router = RouterService::builder().get("/clock", elapsed).build(); + let mut app = App::new(router); + app.set_monotonic_clock(MonotonicClock::new(move || { + if clock_observations.fetch_add(1, Ordering::SeqCst) < 2 { + start + } else { + completed + } + })); + let request = Request::builder() + .uri("/clock") + .body(AxumBody::empty()) + .expect("request"); + + let response = AxumServiceState::from_app(app) + .oneshot(request) + .await + .expect("response"); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body"); + + assert_eq!(body, "7"); + assert!(observations.load(Ordering::SeqCst) >= 3); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn configured_admission_refuses_before_native_body_poll() { + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let native_body = AxumBody::from_stream(poll_fn(move |_cx| { + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + })); + let admission_calls = Arc::new(AtomicUsize::new(0)); + let observed_calls = Arc::clone(&admission_calls); + let observed_during_admission = Arc::clone(&body_polls); + + let router = RouterService::builder() + .post("/upload", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("handler must not run") + }) + .build(); + let mut app = App::new(router); + app.set_ingress_admission_policy(move |head| { + observed_calls.fetch_add(1, Ordering::SeqCst); + assert_eq!(observed_during_admission.load(Ordering::SeqCst), 0); + assert!(matches!( + head.route_resolution(), + RouteResolution::Matched(_) + )); + AdmissionDecision::Refuse { + completion: ResponseEgressCompletion::empty(), + response: response_builder() + .status(StatusCode::TOO_MANY_REQUESTS) + .body(Body::empty()) + .expect("refusal"), + } + }); + let mut service = AxumServiceState::from_app(app); + let request = Request::builder() + .method("POST") + .uri("/upload") + .body(native_body) + .expect("request"); + + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(admission_calls.load(Ordering::SeqCst), 1); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn normalized_ingress_error_returns_typed_response_without_application_observation() { + let reports = Arc::new(Mutex::new(Vec::new())); + let completion_reports = Arc::new(Mutex::new(Vec::new())); + let clock_samples = Arc::new(AtomicUsize::new(0)); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let observed_clock_samples = Arc::clone(&clock_samples); + let now = MonotonicInstant::now(); + let mut app = App::new(RouterService::builder().build()); + app.set_monotonic_clock(MonotonicClock::new(move || { + observed_clock_samples.fetch_add(1, Ordering::SeqCst); + now + })); + app.set_ingress_head_limits( + IngressHeadLimits::default() + .with_max_request_target_bytes(4) + .expect("target limit"), + ); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let observed_factory_calls = Arc::clone(&factory_calls); + let observed_completion_calls = Arc::clone(&completion_calls); + let observed_completion_reports = Arc::clone(&completion_reports); + app.set_detached_response_egress_decision_factory(move |head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + let terminal_calls = Arc::clone(&observed_completion_calls); + let terminal_reports = Arc::clone(&observed_completion_reports); + DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::new(move |report| { + terminal_calls.fetch_add(1, Ordering::SeqCst); + terminal_reports + .lock() + .expect("completion reports lock") + .push(report.clone()); + }), + deadline: head.write_deadline_after(Duration::ZERO), + } + }); + let request = Request::builder() + .uri("/too-long") + .body(AxumBody::empty()) + .expect("request"); + + let response = AxumServiceState::from_app(app) + .oneshot(request) + .await + .expect("typed response"); + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body"); + assert_eq!(body, b"response write deadline exceeded".as_slice()); + assert!(reports.lock().expect("reports lock").is_empty()); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert_eq!(completion_calls.load(Ordering::SeqCst), 1); + let completed = completion_reports.lock().expect("completion reports lock"); + assert_eq!(completed.len(), 1); + assert_eq!( + completed[0].outcome, + ResponseEgressOutcome::DeadlineExceeded + ); + assert_eq!( + completed[0].fallback_disposition, + Some(ResponseEgressFallbackDisposition::Completed) + ); + assert!(clock_samples.load(Ordering::SeqCst) >= 2); + } + + #[tokio::test(flavor = "current_thread")] + async fn normalized_ingress_error_decision_abort_closes_without_response() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind listener"); + let address = listener.local_addr().expect("listener address"); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let observed_factory_calls = Arc::clone(&factory_calls); + let mut app = App::new(RouterService::builder().build()); + app.set_ingress_head_limits( + IngressHeadLimits::default() + .with_max_request_target_bytes(4) + .expect("target limit"), + ); + app.set_detached_response_egress_decision_factory(move |_head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + DetachedResponseEgressDecision::Abort + }); + let state = AxumServiceState::from_app(app); + + let server = async move { + let (stream, remote_addr) = listener.accept().await.expect("accept client"); + let responses = EgressConnection::default(); + serve_http1( + stream, + state.for_connection(remote_addr, responses.clone()), + responses, + AxumIngressConfig::default(), + ) + .await + .expect("intentional detached error abort") + }; + let client = async move { + let mut stream = TcpStream::connect(address).await.expect("connect client"); + stream + .write_all(b"GET /too-long HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .expect("write request"); + let mut response = Vec::new(); + let read = stream.read_to_end(&mut response).await; + (read, response) + }; + + let (exit, (read, response)) = tokio::join!(server, client); + assert_eq!(exit, ConnectionExit::AdmissionAborted); + if let Err(error) = read { + assert!(matches!( + error.kind(), + io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe + )); + } + assert!(response.is_empty()); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn admission_policy_error_decision_send_uses_detached_completion() { + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_body_polls = Arc::clone(&body_polls); + let native_body = AxumBody::from_stream(poll_fn(move |_cx| { + observed_body_polls.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + })); + let handler_calls = Arc::new(AtomicUsize::new(0)); + let observed_handler_calls = Arc::clone(&handler_calls); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .get("/matched", move |_ctx: RequestContext| { + let request_handler_calls = Arc::clone(&observed_handler_calls); + async move { + request_handler_calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("handler must not run") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + let decision_completion_calls = Arc::new(AtomicUsize::new(0)); + let decision_resource_drops = Arc::new(AtomicUsize::new(0)); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let mut app = App::new(router); + let observed_decision_completion_calls = Arc::clone(&decision_completion_calls); + let observed_decision_resource_drops = Arc::clone(&decision_resource_drops); + let observed_grant_drops = Arc::clone(&grant_drops); + app.set_ingress_admission_policy(move |head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::Matched(_) + )); + let decision_resource = DropSignal(Arc::clone(&observed_decision_resource_drops)); + let terminal_calls = Arc::clone(&observed_decision_completion_calls); + AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::new(move |_report| { + let _resource = decision_resource; + terminal_calls.fetch_add(1, Ordering::SeqCst); + }), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes: 1, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text( + StatusCode::PAYLOAD_TOO_LARGE, + "too large", + ), + on_timeout: BufferedIngressResponse::text(StatusCode::REQUEST_TIMEOUT, "timeout"), + } + }); + let reports = Arc::new(Mutex::new(Vec::new())); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let observed_factory_calls = Arc::clone(&factory_calls); + let observed_completion_calls = Arc::clone(&completion_calls); + app.set_detached_response_egress_decision_factory(move |head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + let terminal_calls = Arc::clone(&observed_completion_calls); + DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::new(move |_report| { + terminal_calls.fetch_add(1, Ordering::SeqCst); + }), + deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET), + } + }); + let request = Request::builder() + .uri("/matched") + .body(native_body) + .expect("request"); + + let response = AxumServiceState::from_app(app) + .oneshot(request) + .await + .expect("typed policy error response"); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let _body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body"); + + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + assert_eq!(handler_calls.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + assert_eq!(decision_completion_calls.load(Ordering::SeqCst), 0); + assert_eq!(decision_resource_drops.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert_eq!(completion_calls.load(Ordering::SeqCst), 1); + assert!(reports.lock().expect("reports lock").is_empty()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn admission_policy_error_decision_abort_skips_response_and_application_work() { + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_body_polls = Arc::clone(&body_polls); + let native_body = AxumBody::from_stream(poll_fn(move |_cx| { + observed_body_polls.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + })); + let handler_calls = Arc::new(AtomicUsize::new(0)); + let observed_handler_calls = Arc::clone(&handler_calls); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .get("/matched", move |_ctx: RequestContext| { + let calls = Arc::clone(&observed_handler_calls); + async move { + calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("handler must not run") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + let decision_resource_drops = Arc::new(AtomicUsize::new(0)); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_decision_resource_drops = Arc::clone(&decision_resource_drops); + let observed_grant_drops = Arc::clone(&grant_drops); + let mut app = App::new(router); + app.set_ingress_admission_policy(move |head| AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::new({ + let resource = DropSignal(Arc::clone(&observed_decision_resource_drops)); + move |_report| drop(resource) + }), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes: 1, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text(StatusCode::PAYLOAD_TOO_LARGE, "too large"), + on_timeout: BufferedIngressResponse::text(StatusCode::REQUEST_TIMEOUT, "timeout"), + }); + let reports = Arc::new(Mutex::new(Vec::new())); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let observed_factory_calls = Arc::clone(&factory_calls); + app.set_detached_response_egress_decision_factory(move |_head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + DetachedResponseEgressDecision::Abort + }); + let request = Request::builder() + .uri("/matched") + .body(native_body) + .expect("request"); + + let outcome = AxumServiceState::from_app(app).oneshot(request).await; + + assert!(outcome.is_err()); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + assert_eq!(handler_calls.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + assert_eq!(decision_resource_drops.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert!(reports.lock().expect("reports lock").is_empty()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn post_admission_failure_uses_owned_error_egress() { + let reports = Arc::new(Mutex::new(Vec::new())); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_grant_drops = Arc::clone(&grant_drops); + let router = RouterService::builder() + .get("/owned", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("handler must not run") + }) + .build(); + let mut app = App::new(router); + app.set_ingress_admission_policy(move |head| AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + }); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let mut service = AxumServiceState::from_app(app); + service.outbound_transport = None; + let request = Request::builder() + .uri("/owned") + .body(AxumBody::empty()) + .expect("request"); + + let response = service + .oneshot(request) + .await + .expect("owned error response"); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let _body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body"); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + let observed = reports.lock().expect("reports lock"); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].outcome, ResponseEgressOutcome::HostHandoff); + assert_eq!( + observed[0].route.as_ref().map(RouteMetadata::pattern), + Some("/owned") + ); + } + + fn guarded_fallback_app() -> (App, Arc, Arc) { + let handler_call_count = Arc::new(AtomicUsize::new(0)); + let observed_handler_calls = Arc::clone(&handler_call_count); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .get("/known", move |_ctx: RequestContext| { + let request_handler_calls = Arc::clone(&observed_handler_calls); + async move { + request_handler_calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("handler must not run") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + (App::new(router), handler_call_count, middleware_calls) + } + + fn fallback_body_app( + max_body_bytes: usize, + read_budget: Duration, + grant_drop_count: &Arc, + ) -> (App, Arc, Arc) { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let observed_grant_drops = Arc::clone(grant_drop_count); + app.set_ingress_admission_policy(move |head| match head.route_resolution() { + RouteResolution::Matched(_) => AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(read_budget), + }, + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound | _ => { + AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes, + read_deadline: head.read_deadline_after(read_budget), + on_exceeded: buffered_terminal_response( + StatusCode::PAYLOAD_TOO_LARGE, + "exceeded", + b"configured overflow\0response", + ), + on_timeout: buffered_terminal_response( + StatusCode::GATEWAY_TIMEOUT, + "timeout", + b"configured timeout\0response", + ), + } + } + }); + (app, handler_calls, middleware_calls) + } + + fn buffered_terminal_response( + status: StatusCode, + marker: &'static str, + body: &'static [u8], + ) -> BufferedIngressResponse { + BufferedIngressResponse::new( + status, + terminal_response_headers(marker), + Bytes::from_static(body), + ) + } + + fn terminal_response_headers(marker: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("x-fallback-terminal", HeaderValue::from_static(marker)); + headers + } + + fn tracked_lengthless_request( + path: &str, + body_chunks: Vec, + grant_drops: &Arc, + source_drops: &Arc, + body_polls: &Arc, + ) -> Request { + let observed_grant_drops = Arc::clone(grant_drops); + let source_drop = DropSignal(Arc::clone(source_drops)); + let observed_body_polls = Arc::clone(body_polls); + let mut pending_chunks = body_chunks.into_iter(); + let stream = poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + observed_body_polls.fetch_add(1, Ordering::SeqCst); + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + Poll::Ready(pending_chunks.next().map(Ok::)) + }); + let request = Request::builder() + .method("POST") + .uri(path) + .body(AxumBody::from_stream(stream)) + .expect("request"); + assert!(request.headers().get("content-length").is_none()); + request + } + + fn assert_no_fallback_dispatch(handler_calls: &AtomicUsize, middleware_calls: &AtomicUsize) { + assert_eq!(handler_calls.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + } + + async fn assert_terminal_response( + response: Response, + status: StatusCode, + marker: &'static str, + body: &[u8], + ) { + assert_eq!(response.status(), status); + assert_eq!(response.headers(), &terminal_response_headers(marker)); + assert_eq!( + to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body"), + body + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn bounded_fallback_preserves_404_and_405_at_exact_cap() { + for (path, expected) in [ + ("/missing", StatusCode::NOT_FOUND), + ("/known", StatusCode::METHOD_NOT_ALLOWED), + ] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_body_app(4, Duration::from_secs(1), &grant_drops); + let request = tracked_lengthless_request( + path, + vec![Bytes::from_static(b"ab"), Bytes::from_static(b"cd")], + &grant_drops, + &source_drops, + &body_polls, + ); + let response = AxumServiceState::from_app(app) + .ready() + .await + .expect("ready") + .call(request) + .await + .expect("response"); + assert_eq!(response.status(), expected); + if expected == StatusCode::METHOD_NOT_ALLOWED { + assert_eq!( + response.headers().get("allow").expect("Allow header"), + "GET" + ); + } + assert_eq!(body_polls.load(Ordering::SeqCst), 3); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn bounded_fallback_overflow_precedes_404_and_405() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_body_app(4, Duration::from_secs(1), &grant_drops); + let request = tracked_lengthless_request( + path, + vec![Bytes::from_static(b"abcd"), Bytes::from_static(b"e")], + &grant_drops, + &source_drops, + &body_polls, + ); + let response = AxumServiceState::from_app(app) + .ready() + .await + .expect("ready") + .call(request) + .await + .expect("response"); + assert_terminal_response( + response, + StatusCode::PAYLOAD_TOO_LARGE, + "exceeded", + b"configured overflow\0response", + ) + .await; + assert_eq!(body_polls.load(Ordering::SeqCst), 2); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn bounded_fallback_deadline_interrupts_pending_native_body() { + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_grant_drops = Arc::clone(&grant_drops); + let source_drops = Arc::new(AtomicUsize::new(0)); + let source_drop = DropSignal(Arc::clone(&source_drops)); + let native_body = AxumBody::from_stream(poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + })); + let (app, handler_calls, middleware_calls) = + fallback_body_app(4_096, Duration::from_millis(500), &grant_drops); + let mut service = AxumServiceState::from_app(app); + let request = Request::builder() + .method("POST") + .uri("/missing") + .body(native_body) + .expect("request"); + + service.ready().await.expect("ready"); + let response = timeout(Duration::from_secs(5), service.call(request)) + .await + .expect("fallback deadline must preempt the pending native body") + .expect("response"); + + assert_terminal_response( + response, + StatusCode::GATEWAY_TIMEOUT, + "timeout", + b"configured timeout\0response", + ) + .await; + assert!(body_polls.load(Ordering::SeqCst) > 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[expect( + clippy::integer_division_remainder_used, + reason = "tokio::select! expands to internal randomized branch selection arithmetic" + )] + async fn cancelled_service_releases_pending_fallback_drain_and_grant() { + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_grant_drops = Arc::clone(&grant_drops); + let source_drops = Arc::new(AtomicUsize::new(0)); + let source_drop = DropSignal(Arc::clone(&source_drops)); + let native_body = AxumBody::from_stream(poll_fn(move |_context| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + })); + let (app, handler_calls, middleware_calls) = + fallback_body_app(4_096, Duration::from_secs(5), &grant_drops); + let mut service = AxumServiceState::from_app(app); + let request = Request::builder() + .method("POST") + .uri("/missing") + .body(native_body) + .expect("request"); + + service.ready().await.expect("ready"); + let mut response = Box::pin(service.call(request)); + tokio::select! { + _result = &mut response => panic!("pending drain completed unexpectedly"), + () = sleep(Duration::from_millis(25)) => {} + } + assert!(body_polls.load(Ordering::SeqCst) > 0); + assert_eq!(grant_drops.load(Ordering::SeqCst), 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 0); + drop(response); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn fallback_refusal_returns_503_without_polling_native_body() { + for path in ["/missing", "/known"] { + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let source_drops = Arc::new(AtomicUsize::new(0)); + let source_drop = DropSignal(Arc::clone(&source_drops)); + let native_body = AxumBody::from_stream(poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::>>::Pending + })); + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + app.set_ingress_admission_policy(|head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound + )); + AdmissionDecision::Refuse { + completion: ResponseEgressCompletion::empty(), + response: response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .body(Body::from("fallback unavailable\n")) + .expect("refusal response"), + } + }); + let request = Request::builder() + .method("POST") + .uri(path) + .body(native_body) + .expect("request"); + + let response = AxumServiceState::from_app(app) + .ready() + .await + .expect("ready") + .call(request) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body"), + b"fallback unavailable\n".as_slice() + ); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn consumed_response_reports_one_host_handoff_with_accepted_bytes() { + let router = RouterService::builder() + .get("/observed", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("ok") + }) + .build(); + let reports = Arc::new(Mutex::new(Vec::new())); + let mut app = App::new(router); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let mut service = AxumServiceState::from_app(app); + let request = Request::builder() + .method("GET") + .uri("/observed") + .body(AxumBody::empty()) + .expect("request"); + + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + to_bytes(response.into_body(), usize::MAX).await.unwrap(), + b"ok".as_slice() + ); + + let observed = reports.lock().expect("reports lock"); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].outcome, ResponseEgressOutcome::HostHandoff); + assert_eq!(observed[0].bytes_written, 2); + assert_eq!( + observed[0].route.as_ref().map(RouteMetadata::pattern), + Some("/observed") + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn injected_clock_controls_response_write_deadline() { + let router = RouterService::builder() + .get("/deadline", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("too late") + }) + .build(); + let reports = Arc::new(Mutex::new(Vec::new())); + let start = MonotonicInstant::now(); + let observed_now = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&observed_now); + let policy_now = Arc::clone(&observed_now); + let mut app = App::new(router); + app.set_monotonic_clock(MonotonicClock::new(move || { + *clock_now.lock().expect("clock lock") + })); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + app.set_response_egress_policy(move |_head, started_at| { + let deadline = started_at + .checked_add(Duration::from_secs(1)) + .expect("write deadline"); + *policy_now.lock().expect("clock lock") = deadline; + ResponseEgressPolicy { + write_deadline: Deadline::at_instant(deadline), + } + }); + let request = Request::builder() + .method("GET") + .uri("/deadline") + .body(AxumBody::empty()) + .expect("request"); + + let response = AxumServiceState::from_app(app) + .ready() + .await + .expect("ready") + .call(request) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("fallback body"); + assert_eq!(body, b"response write deadline exceeded".as_slice()); + let observed = reports.lock().expect("reports lock"); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + assert_eq!(observed[0].elapsed, Duration::from_secs(1)); + assert_eq!( + observed[0].fallback_disposition, + Some(ResponseEgressFallbackDisposition::Completed) + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn admitted_deadline_reaches_body_cell_as_request_timeout() { + let router = RouterService::builder() + .post("/upload", |ctx: RequestContext| async move { + let _bytes = ctx.body_bytes(1024).await?; + Ok::<_, EdgeError>("unexpected success") + }) + .build(); + let mut app = App::new(router); + let request_start = MonotonicInstant::now(); + app.set_monotonic_clock(MonotonicClock::new(move || request_start)); + app.set_ingress_admission_policy(move |head| { + assert_eq!(head.request_start(), request_start); + AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + read_deadline: Deadline::at_instant(head.request_start()), + } + }); + let mut service = AxumServiceState::from_app(app); + let request = Request::builder() + .method("POST") + .uri("/upload") + .body(AxumBody::from("data")) + .expect("request"); + + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn with_config_store_handle_injects_into_request() { // Hard-cutoff: legacy `ctx.config_handle()` is @@ -272,12 +1481,12 @@ mod tests { .unwrap_or_default(); let response = response_builder() .status(StatusCode::OK) - .body(Body::from(val)) + .body(Body::from(val.as_str())) .expect("response"); Ok::<_, EdgeError>(response) }) .build(); - let mut service = EdgeZeroAxumService::new(router).with_config_store_handle(handle); + let mut service = AxumServiceState::new(router).with_config_store_handle(handle); let request = Request::builder() .uri("/check") @@ -313,7 +1522,7 @@ mod tests { Ok::<_, EdgeError>(response) }) .build(); - let mut service = EdgeZeroAxumService::new(router).with_kv_handle(handle); + let mut service = AxumServiceState::new(router).with_kv_handle(handle); let request = Request::builder() .uri("/check") @@ -386,7 +1595,7 @@ mod tests { // Wire BOTH: registry first, then a bare handle. The bare // handle would synthesise a "default" id under the legacy // path; the dispatcher's `or_else` precedence must skip it. - let mut service = EdgeZeroAxumService::new(router) + let mut service = AxumServiceState::new(router) .with_kv_registry(registry) .with_kv_handle(bare_handle); @@ -439,7 +1648,7 @@ mod tests { Ok::<_, EdgeError>(response) }) .build(); - let mut service = EdgeZeroAxumService::new(router).with_kv_handle(handle); + let mut service = AxumServiceState::new(router).with_kv_handle(handle); let request = Request::builder() .uri("/probe") @@ -471,7 +1680,7 @@ mod tests { Ok::<_, EdgeError>(response) }) .build(); - let mut service = EdgeZeroAxumService::new(router); + let mut service = AxumServiceState::new(router); let request = Request::builder() .uri("/no-config") @@ -520,7 +1729,7 @@ mod tests { Ok::<_, EdgeError>(response) }) .build(); - let mut service = EdgeZeroAxumService::new(router).with_secret_handle(handle); + let mut service = AxumServiceState::new(router).with_secret_handle(handle); let request = Request::builder() .uri("/check") @@ -546,7 +1755,7 @@ mod tests { Ok::<_, EdgeError>(response) }) .build(); - let mut service = EdgeZeroAxumService::new(router); + let mut service = AxumServiceState::new(router); let request = Request::builder() .uri("/no-kv") @@ -623,7 +1832,7 @@ mod tests { Ok::<_, EdgeError>(response) }) .build(); - let service = EdgeZeroAxumService::new(router).with_kv_registry(registry); + let service = AxumServiceState::new(router).with_kv_registry(registry); assert_eq!( body_at(&service, "/named/sessions").await, @@ -665,7 +1874,7 @@ mod tests { Ok::<_, EdgeError>(response) }) .build(); - let service = EdgeZeroAxumService::new(router).with_kv_registry(registry); + let service = AxumServiceState::new(router).with_kv_registry(registry); assert_eq!(body_at(&service, "/lookup/only").await, "present=true"); assert_eq!(body_at(&service, "/lookup/missing").await, "present=false"); @@ -674,7 +1883,7 @@ mod tests { /// Send a GET request through `service` and return the response body as a UTF-8 string. /// Lifted out of the registry-aware tests so each can stay flat (clippy /// `items_after_statements` rejects nested `async fn` definitions). - async fn body_at(service: &EdgeZeroAxumService, path: &str) -> String { + async fn body_at(service: &AxumServiceState, path: &str) -> String { let request = Request::builder() .uri(path) .body(AxumBody::empty()) diff --git a/crates/edgezero-adapter-axum/src/templates/axum.toml.hbs b/crates/edgezero-adapter-axum/src/templates/axum.toml.hbs index 30fc6e192..322d0aee2 100644 --- a/crates/edgezero-adapter-axum/src/templates/axum.toml.hbs +++ b/crates/edgezero-adapter-axum/src/templates/axum.toml.hbs @@ -3,3 +3,8 @@ crate = "{{proj_axum}}" crate_dir = "." host = "127.0.0.1" port = 8787 + +# Config snapshots load before listener bind; restart after config push. +# Set EDGEZERO__ADAPTER__CONFIG_STORE__* for allocation budgets. +# Defaults: file 16 MiB, 1024 entries, key 1024 bytes, value 8 MiB, +# all-store residency 32 MiB, startup residency plus staging 112 MiB. diff --git a/crates/edgezero-adapter-axum/tests/contract.rs b/crates/edgezero-adapter-axum/tests/contract.rs new file mode 100644 index 000000000..6cad71e57 --- /dev/null +++ b/crates/edgezero-adapter-axum/tests/contract.rs @@ -0,0 +1,546 @@ +#![cfg(all(test, feature = "axum"))] +#![expect( + clippy::expect_used, + clippy::tests_outside_test_module, + reason = "this integration-test crate uses explicit fixture diagnostics and consists only of contract tests" +)] + +use core::convert::Infallible; +use std::io::Write as _; +use std::time::Duration; + +use async_stream::stream as async_body_stream; +use axum::Router; +use axum::body::Body; +use axum::http::HeaderValue; +use axum::http::header::{CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, HOST, SET_COOKIE}; +use axum::response::Response; +use axum::routing::get; +use bytes::Bytes; +use edgezero_adapter_axum::outbound::AxumOutboundClient; +use edgezero_core::body::Body as CoreBody; +use edgezero_core::error::{BadGatewayReason, BudgetSource, EdgeError, ResponseLimitReason}; +use edgezero_core::http::{HeaderMap, Method, StatusCode}; +use edgezero_core::time::Deadline; +use edgezero_core::{ + FLATE_DECODER_FIXED_CHARGE_BYTES, OutboundBatchNext, OutboundBatchTermination, + OutboundCachePolicy, OutboundHttpClient as _, OutboundRequest, PROXY_HEADER, +}; +use flate2::Compression; +use flate2::write::{GzEncoder, ZlibEncoder}; +use futures_util::stream; +use tokio::net::TcpListener; +use tokio::time::sleep; + +fn gzip(input: &[u8]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(input).expect("gzip input"); + encoder.finish().expect("gzip output") +} + +fn zlib(input: &[u8]) -> Vec { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(input).expect("deflate input"); + encoder.finish().expect("deflate output") +} + +async fn start_origin(router: Router) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind origin"); + let address = listener.local_addr().expect("origin address"); + tokio::spawn(async move { + axum::serve(listener, router).await.expect("serve origin"); + }); + format!("http://{address}") +} + +#[tokio::test] +async fn redirect_response_is_not_followed() { + let origin = start_origin( + Router::new() + .route("/target", get(|| async { "followed" })) + .route( + "/redirect", + get(|| async { (StatusCode::FOUND, [("location", "/target")]) }), + ), + ) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + let request = OutboundRequest::get(format!("{origin}/redirect")).expect("request"); + + let response = client.send(request).await.expect("response"); + + assert_eq!(response.status(), StatusCode::FOUND); + assert_eq!( + response + .headers() + .get(PROXY_HEADER) + .and_then(|value| value.to_str().ok()), + Some("axum") + ); +} + +#[tokio::test] +async fn batch_collection_reports_per_slot_elapsed() { + let origin = start_origin(Router::new().route("/", get(|| async { "ok" }))).await; + let client = AxumOutboundClient::try_new().expect("client"); + let requests = vec![ + OutboundRequest::get(format!("{origin}/")).expect("reachable request"), + OutboundRequest::new( + Method::GET, + "http://127.0.0.1:1/unreachable" + .parse() + .expect("unreachable URI"), + ) + .expect("unreachable request"), + ]; + + let results = client + .start_batch_until(requests, Deadline::after(Duration::from_secs(1))) + .collect() + .await + .expect("valid batch driver"); + + assert_eq!(results.slots.len(), 2); + assert_eq!(results.termination, OutboundBatchTermination::Completed); + results.slots[0] + .as_ref() + .expect("reachable slot resolved") + .outcome + .as_ref() + .expect("reachable slot succeeds"); + assert!(matches!( + results.slots[1] + .as_ref() + .expect("unreachable slot resolved") + .outcome, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Unreachable, + .. + }) + )); +} + +#[tokio::test] +async fn batch_preflight_precedence_and_indices() { + let client = AxumOutboundClient::try_new().expect("client"); + let streamed_upload = OutboundRequest::post("https://example.com/upload") + .expect("upload request") + .body(CoreBody::stream(stream::iter([bytes::Bytes::from_static( + b"body", + )]))); + let streamed_response = OutboundRequest::get("https://example.com/stream") + .expect("stream request") + .stream_response(); + let method_error = OutboundRequest::get("https://example.com/get") + .expect("GET request") + .body(CoreBody::stream(stream::iter([bytes::Bytes::new()]))); + + let results = client + .start_batch_until( + vec![streamed_upload, streamed_response, method_error], + Deadline::after(Duration::from_secs(1)), + ) + .collect() + .await + .expect("valid batch driver"); + + assert_eq!(results.termination, OutboundBatchTermination::Completed); + + let messages: Vec<_> = results + .slots + .iter() + .map( + |slot| match &slot.as_ref().expect("preflight slot resolved").outcome { + Err(EdgeError::BadRequest { message }) => message.as_str(), + other => panic!("expected preflight rejection, got {other:?}"), + }, + ) + .collect(); + assert_eq!( + messages, + [ + "outbound batches require buffered request bodies; use send for a streamed upload", + "outbound batches require buffered responses; use send for a streamed response", + "GET/HEAD request must not carry a streamed body; emptiness cannot be determined without consuming the stream", + ] + ); +} + +#[tokio::test] +async fn buffered_response_deadline_covers_body_completion() { + let origin = start_origin(Router::new().route( + "/", + get(|| async { + let body = async_body_stream! { + yield Ok::<_, Infallible>(Bytes::from_static(b"first")); + sleep(Duration::from_millis(100)).await; + yield Ok::<_, Infallible>(Bytes::from_static(b"second")); + }; + Response::new(Body::from_stream(body)) + }), + )) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + let request = OutboundRequest::get(format!("{origin}/")) + .expect("request") + .timeout(Duration::from_millis(10)); + + let error = client + .send(request) + .await + .expect_err("body completion must share the absolute request deadline"); + + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::PerCallTimeout, + .. + } + )); +} + +#[tokio::test] +async fn batch_preserves_request_deadline_provenance() { + let origin = start_origin(Router::new().route( + "/", + get(|| async { + sleep(Duration::from_millis(100)).await; + "late" + }), + )) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + let request = OutboundRequest::get(format!("{origin}/")) + .expect("request") + .deadline(Deadline::after(Duration::from_millis(10))); + + let results = client + .start_batch_until(vec![request], Deadline::after(Duration::from_secs(1))) + .collect() + .await + .expect("valid batch driver"); + + assert_eq!(results.termination, OutboundBatchTermination::Completed); + + assert!(matches!( + results.slots[0] + .as_ref() + .expect("request deadline slot resolved") + .outcome, + Err(EdgeError::GatewayTimeout { + cause: BudgetSource::RequestDeadline, + .. + }) + )); +} + +#[tokio::test] +async fn batch_yields_complete_exchanges_in_completion_order() { + let origin = start_origin( + Router::new() + .route( + "/slow", + get(|| async { + sleep(Duration::from_millis(75)).await; + "slow" + }), + ) + .route("/fast", get(|| async { "fast" })), + ) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + let requests = vec![ + OutboundRequest::get(format!("{origin}/slow")).expect("slow request"), + OutboundRequest::get(format!("{origin}/fast")).expect("fast request"), + ]; + let mut batch = client.start_batch_until(requests, Deadline::after(Duration::from_secs(1))); + + let OutboundBatchNext::Item(first) = batch.next().await.expect("valid batch driver") else { + panic!("expected first completion"); + }; + let OutboundBatchNext::Item(second) = batch.next().await.expect("valid batch driver") else { + panic!("expected second completion"); + }; + + assert_eq!(first.index, 1); + assert_eq!(second.index, 0); + assert!(matches!( + batch.next().await.expect("valid batch driver"), + OutboundBatchNext::Finished(OutboundBatchTermination::Completed) + )); +} + +#[tokio::test] +async fn early_observation_cutoff_preserves_completed_slots_and_leaves_pending_none() { + let origin = start_origin( + Router::new() + .route("/fast", get(|| async { "fast" })) + .route( + "/slow", + get(|| async { + sleep(Duration::from_millis(100)).await; + "slow" + }), + ), + ) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + let results = client + .start_batch_until( + vec![ + OutboundRequest::get(format!("{origin}/fast")).expect("fast request"), + OutboundRequest::get(format!("{origin}/slow")).expect("slow request"), + ], + Deadline::after(Duration::from_millis(25)), + ) + .collect() + .await + .expect("valid batch driver"); + + assert_eq!(results.termination, OutboundBatchTermination::Cutoff); + assert!(results.slots[0].is_some()); + assert!(results.slots[1].is_none()); +} + +#[tokio::test] +async fn authority_override_changes_host_only_and_cache_bypass_adds_no_origin_header() { + let origin = start_origin(Router::new().route( + "/", + get(|headers: HeaderMap| async move { + let host = headers + .get(HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or("missing"); + let cache_control = headers + .get(CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .unwrap_or("absent"); + format!("{host}|{cache_control}") + }), + )) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + let request = OutboundRequest::get(format!("{origin}/")) + .expect("request") + .host_authority_override("virtual.example:8443") + .expect("authority override") + .cache_policy(OutboundCachePolicy::Bypass); + + let body = client + .send(request) + .await + .expect("response") + .into_bytes_bounded(1_024) + .await + .expect("response body"); + + assert_eq!(body, "virtual.example:8443|absent"); +} + +#[tokio::test] +async fn streamed_response_deadline_preserves_timeout_provenance() { + let origin = start_origin(Router::new().route( + "/", + get(|| async { + let body = async_body_stream! { + sleep(Duration::from_secs(2)).await; + yield Ok::<_, Infallible>(Bytes::from_static(b"late")); + }; + Response::new(Body::from_stream(body)) + }), + )) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + let request = OutboundRequest::get(format!("{origin}/")) + .expect("request") + .stream_response() + .timeout(Duration::from_millis(500)); + + let response = client.send(request).await.expect("response headers"); + let error = response + .into_body() + .into_bytes_bounded(1_024) + .await + .expect_err("stream body must retain the request deadline"); + + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::PerCallTimeout, + .. + } + )); +} + +#[tokio::test] +async fn encoded_and_decoded_limits_report_independent_origins() { + let decoded = vec![b'x'; 4_096]; + let encoded = gzip(&decoded); + let origin_body = encoded.clone(); + let origin = start_origin(Router::new().route( + "/", + get(move || { + let bytes = origin_body.clone(); + async move { + Response::builder() + .header(CONTENT_ENCODING, "gzip") + .body(Body::from(bytes)) + .expect("origin response") + } + }), + )) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + + let decoded_error = client + .send( + OutboundRequest::get(format!("{origin}/")) + .expect("decoded-limit request") + .max_encoded_response_bytes(u64::try_from(encoded.len()).expect("encoded length")) + .max_decoded_response_bytes(128), + ) + .await + .expect_err("decoded response must exceed its independent limit"); + assert!(matches!( + decoded_error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::DecodedBody, + .. + } + )); + + let encoded_error = client + .send( + OutboundRequest::get(format!("{origin}/")) + .expect("encoded-limit request") + .max_encoded_response_bytes( + u64::try_from(encoded.len()) + .expect("encoded length") + .saturating_sub(1), + ) + .max_decoded_response_bytes(u64::try_from(decoded.len()).expect("decoded length")), + ) + .await + .expect_err("encoded response must exceed its independent limit"); + assert!(matches!( + encoded_error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::EncodedBody, + .. + } + )); +} + +#[tokio::test] +async fn deflate_response_is_decoded_stripped_and_bounded() { + let decoded = vec![b'd'; 4_096]; + let encoded = zlib(&decoded); + let origin_body = encoded.clone(); + let origin = start_origin(Router::new().route( + "/", + get(move || { + let bytes = origin_body.clone(); + async move { + Response::builder() + .header(CONTENT_ENCODING, "deflate") + .header(CONTENT_LENGTH, bytes.len()) + .body(Body::from(bytes)) + .expect("origin response") + } + }), + )) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + + let response = client + .send( + OutboundRequest::get(format!("{origin}/")) + .expect("request") + .max_decoder_bytes(FLATE_DECODER_FIXED_CHARGE_BYTES) + .max_encoded_response_bytes(u64::try_from(encoded.len()).expect("encoded length")) + .max_decoded_response_bytes(u64::try_from(decoded.len()).expect("decoded length")), + ) + .await + .expect("decoded response"); + assert!(response.headers().get(CONTENT_ENCODING).is_none()); + assert!(response.headers().get(CONTENT_LENGTH).is_none()); + assert_eq!( + response + .into_bytes_bounded(u64::try_from(decoded.len()).expect("decoded length")) + .await + .expect("decoded body"), + decoded + ); + + let decoded_error = client + .send( + OutboundRequest::get(format!("{origin}/")) + .expect("request") + .max_decoded_response_bytes(128), + ) + .await + .expect_err("decoded response must exceed its limit"); + assert!(matches!( + decoded_error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::DecodedBody, + .. + } + )); +} + +#[tokio::test] +async fn passthrough_coding_is_not_charged_to_decoded_limit() { + let origin = start_origin(Router::new().route( + "/", + get(|| async { + Response::builder() + .header(CONTENT_ENCODING, "zstd") + .body(Body::from("opaque")) + .expect("origin response") + }), + )) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + let request = OutboundRequest::get(format!("{origin}/")) + .expect("request") + .max_encoded_response_bytes(6) + .max_decoded_response_bytes(1); + + let response = client.send(request).await.expect("response"); + let bytes = response.into_bytes_bounded(6).await.expect("response body"); + + assert_eq!(bytes, "opaque"); +} + +#[tokio::test] +async fn repeated_response_headers_are_preserved() { + let origin = start_origin(Router::new().route( + "/", + get(|| async { + let mut response = Response::new(Body::empty()); + response + .headers_mut() + .append(SET_COOKIE, HeaderValue::from_static("a=1")); + response + .headers_mut() + .append(SET_COOKIE, HeaderValue::from_static("b=2")); + response + }), + )) + .await; + let client = AxumOutboundClient::try_new().expect("client"); + + let response = client + .send(OutboundRequest::get(format!("{origin}/")).expect("request")) + .await + .expect("response"); + let values: Vec<_> = response + .headers() + .get_all(SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("header value")) + .collect(); + + assert_eq!(values, ["a=1", "b=2"]); +} diff --git a/crates/edgezero-adapter-cloudflare/.cargo/config.toml b/crates/edgezero-adapter-cloudflare/.cargo/config.toml index 6d90049f0..e33593026 100644 --- a/crates/edgezero-adapter-cloudflare/.cargo/config.toml +++ b/crates/edgezero-adapter-cloudflare/.cargo/config.toml @@ -1,7 +1,5 @@ [build] -target = "wasm32-wasip1" +target = "wasm32-unknown-unknown" -[target.'cfg(target_arch = "wasm32")'] -# Use Viceroy to run wasm built from binaries in this crate or dependents. -# Points to the demo service config by default. -runner = "wasmtime run --dir=. -- " +[target.wasm32-unknown-unknown] +runner = "wasm-bindgen-test-runner" diff --git a/crates/edgezero-adapter-cloudflare/Cargo.toml b/crates/edgezero-adapter-cloudflare/Cargo.toml index 5dbb80fc8..e127424f6 100644 --- a/crates/edgezero-adapter-cloudflare/Cargo.toml +++ b/crates/edgezero-adapter-cloudflare/Cargo.toml @@ -12,7 +12,7 @@ workspace = true [features] default = [] -cloudflare = ["dep:worker", "dep:serde_json"] +cloudflare = ["dep:async-stream", "dep:worker", "dep:serde_json", "dep:web-sys", "dep:wasm-streams"] cli = [ "dep:edgezero-adapter", "edgezero-adapter/cli", @@ -22,6 +22,7 @@ cli = [ "dep:toml_edit", "dep:walkdir", ] +test-utils = [] [dependencies] anyhow = { workspace = true } @@ -30,6 +31,7 @@ edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = [ "cli", ] } async-trait = { workspace = true } +async-stream = { workspace = true, optional = true } brotli = { workspace = true } bytes = { workspace = true } flate2 = { workspace = true } @@ -40,18 +42,18 @@ ctor = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } tempfile = { workspace = true, optional = true } toml_edit = { workspace = true, optional = true } -worker = { version = "0.8", default-features = false, features = ["http"], optional = true } +worker = { workspace = true, optional = true } +wasm-streams = { workspace = true, optional = true } walkdir = { workspace = true, optional = true } +web-sys = { version = "0.3", optional = true, features = [ + "AbortSignal", + "ReadableStream", + "TransformStream", + "WritableStream", + "WritableStreamDefaultWriter", +] } [dev-dependencies] edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } tempfile = { workspace = true } wasm-bindgen-test = "0.3" -web-sys = { version = "0.3", features = [ - "Window", - "Response", - "Request", - "Headers", - "ReadableStream", - "Blob", -] } diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index f60893b1b..cf985f1c1 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -10,13 +10,14 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, - register_adapter, + Adapter, AdapterAction, AdapterExecutionTarget, AdapterPushContext, ProvisionStores, + ReadConfigEntry, ResolvedStoreId, register_adapter, }; use edgezero_adapter::scaffold::{ AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, }; +use edgezero_core::{Capability, CapabilitySupport, PlatformMetadata}; use walkdir::WalkDir; static CLOUDFLARE_ADAPTER: CloudflareCliAdapter = CloudflareCliAdapter; @@ -134,6 +135,35 @@ struct CloudflareCliAdapter; reason = "cloudflare has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `read_config_entry` and `read_config_entry_local` are both overridden below (wrangler kv key get --remote / --local). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." )] impl Adapter for CloudflareCliAdapter { + fn capability(&self, capability: Capability) -> CapabilitySupport { + match capability { + Capability::ConfigReadDeadlines + | Capability::InboundReadDeadlines + | Capability::IngressAdmissionAbort + | Capability::OutboundAuthorityOverride + | Capability::OutboundBatchCancellation + | Capability::OutboundHeaderFidelity + | Capability::OutboundDeadlines + | Capability::ResponseEgressAbort + | Capability::ResponseEgressBackpressure + | Capability::ResponseEgressCompletion + | Capability::ResponseWriteDeadlines + | Capability::StreamedUploadDeadlines => CapabilitySupport::BestEffort, + Capability::IngressAdmission + | Capability::LazyStreamedResponsePassthrough + | Capability::OutboundBatchCompletionOrder + | Capability::OutboundBatchSlotIsolation + | Capability::OutboundCacheBypass + | Capability::OutboundFlexiblePhaseBudget + | Capability::OutboundHttp => CapabilitySupport::Native, + Capability::ConfigReadAllocationBounds + | Capability::OutboundCompleteResourceAccounting + | Capability::RawIngressFramingValidation + | Capability::RawIngressHeadLimits + | _ => CapabilitySupport::Unsupported, + } + } + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { // `wrangler` is the native sign-in surface for Cloudflare @@ -167,6 +197,30 @@ impl Adapter for CloudflareCliAdapter { } } + fn execute_target( + &self, + action: AdapterAction, + target: &AdapterExecutionTarget, + args: &[String], + ) -> Result<(), String> { + let manifest = target_manifest(target)?; + match action { + AdapterAction::Build => build_from_manifest(&manifest, args).map(|_artifact| ()), + AdapterAction::Deploy => deploy_from_manifest(&manifest, args), + AdapterAction::Serve => serve_from_manifest(&manifest, args), + AdapterAction::AuthLogin + | AdapterAction::AuthLogout + | AdapterAction::AuthStatus + | AdapterAction::DeployStaging + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback + | _ => Err(format!( + "cloudflare adapter does not support pinned target action {action:?}" + )), + } + } + fn merged_id_kinds(&self) -> &'static [&'static str] { // Both KV and Config back to Worker KV namespaces via the // same `[[kv_namespaces]] binding = ` @@ -189,6 +243,10 @@ impl Adapter for CloudflareCliAdapter { "cloudflare" } + fn platform_metadata(&self) -> PlatformMetadata { + crate::CLOUDFLARE_PLATFORM + } + fn provision( &self, manifest_root: &Path, @@ -929,6 +987,10 @@ fn read_wrangler_kv_key( pub fn build(extra_args: &[String]) -> Result { let manifest = find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + build_from_manifest(&manifest, extra_args) +} + +fn build_from_manifest(manifest: &Path, extra_args: &[String]) -> Result { let manifest_dir = manifest .parent() .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; @@ -971,6 +1033,10 @@ pub fn build(extra_args: &[String]) -> Result { pub fn deploy(extra_args: &[String]) -> Result<(), String> { let manifest = find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + deploy_from_manifest(&manifest, extra_args) +} + +fn deploy_from_manifest(manifest: &Path, extra_args: &[String]) -> Result<(), String> { let manifest_dir = manifest .parent() .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; @@ -1112,6 +1178,10 @@ fn register_ctor() { pub fn serve(extra_args: &[String]) -> Result<(), String> { let manifest = find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + serve_from_manifest(&manifest, extra_args) +} + +fn serve_from_manifest(manifest: &Path, extra_args: &[String]) -> Result<(), String> { let manifest_dir = manifest .parent() .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; @@ -1132,9 +1202,24 @@ pub fn serve(extra_args: &[String]) -> Result<(), String> { Ok(()) } +fn target_manifest(target: &AdapterExecutionTarget) -> Result { + let manifest = target.platform_manifest().map_or_else( + || target.app_root().join("wrangler.toml"), + Path::to_path_buf, + ); + if !manifest.is_file() { + return Err(format!( + "pinned Cloudflare manifest {} is not a regular file", + manifest.display() + )); + } + Ok(manifest) +} + #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] use edgezero_core::test_env::PathPrepend; @@ -1154,6 +1239,159 @@ mod tests { const TEST_CONFIG_ID: &str = "app_config"; const TEST_SECRET_ID: &str = "default"; + #[test] + fn bounded_config_scaffolds_require_gc_cleanup_support() { + for source in [ + include_str!("templates/wrangler.toml.hbs"), + include_str!( + "../../../examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml" + ), + include_str!( + "../../../tests/fixtures/reusable-app/crates/fixture-cloudflare/wrangler.toml" + ), + ] { + let document = source + .parse::() + .expect("Wrangler TOML"); + let flags = document + .get("compatibility_flags") + .and_then(toml_edit::Item::as_array) + .expect("explicit compatibility flags"); + assert!( + flags + .iter() + .any(|value| value.as_str() == Some("enable_weak_ref")) + ); + } + } + + #[test] + fn adapter_capability_matrix_matches_contracts() { + let expected = [ + ( + Capability::ConfigReadAllocationBounds, + CapabilitySupport::Unsupported, + ), + ( + Capability::ConfigReadDeadlines, + CapabilitySupport::BestEffort, + ), + ( + Capability::InboundReadDeadlines, + CapabilitySupport::BestEffort, + ), + (Capability::IngressAdmission, CapabilitySupport::Native), + ( + Capability::IngressAdmissionAbort, + CapabilitySupport::BestEffort, + ), + ( + Capability::RawIngressFramingValidation, + CapabilitySupport::Unsupported, + ), + ( + Capability::RawIngressHeadLimits, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressAbort, + CapabilitySupport::BestEffort, + ), + ( + Capability::ResponseEgressBackpressure, + CapabilitySupport::BestEffort, + ), + ( + Capability::ResponseEgressCompletion, + CapabilitySupport::BestEffort, + ), + ( + Capability::ResponseWriteDeadlines, + CapabilitySupport::BestEffort, + ), + (Capability::OutboundHttp, CapabilitySupport::Native), + ( + Capability::OutboundCompleteResourceAccounting, + CapabilitySupport::Unsupported, + ), + ( + Capability::OutboundHeaderFidelity, + CapabilitySupport::BestEffort, + ), + (Capability::OutboundDeadlines, CapabilitySupport::BestEffort), + ( + Capability::OutboundFlexiblePhaseBudget, + CapabilitySupport::Native, + ), + ( + Capability::OutboundAuthorityOverride, + CapabilitySupport::BestEffort, + ), + ( + Capability::OutboundBatchCancellation, + CapabilitySupport::BestEffort, + ), + ( + Capability::OutboundBatchCompletionOrder, + CapabilitySupport::Native, + ), + ( + Capability::OutboundBatchSlotIsolation, + CapabilitySupport::Native, + ), + (Capability::OutboundCacheBypass, CapabilitySupport::Native), + ( + Capability::StreamedUploadDeadlines, + CapabilitySupport::BestEffort, + ), + ( + Capability::LazyStreamedResponsePassthrough, + CapabilitySupport::Native, + ), + ]; + + for (capability, support) in expected { + assert_eq!( + CLOUDFLARE_ADAPTER.capability(capability), + support, + "{capability:?}" + ); + } + } + + #[test] + fn adapter_platform_metadata_matches_runtime_metadata() { + let metadata = CLOUDFLARE_ADAPTER.platform_metadata(); + let source = edgezero_core::PlatformResourceSource::PlatformLimit { + provider: "Cloudflare Workers", + }; + + assert_eq!(metadata, crate::CLOUDFLARE_PLATFORM); + assert_eq!( + metadata.memory_ceiling(), + edgezero_core::PlatformFact::known( + edgezero_core::MemoryCeiling::new( + 128_000_000, + edgezero_core::MemoryCeilingScope::PerInstance, + None, + ), + source, + ) + ); + assert_eq!( + metadata.inbound_request_population_bound(), + edgezero_core::PlatformFact::unknown( + edgezero_core::PlatformUnknownReason::ProviderUnpublished + ) + ); + assert_eq!( + metadata.host_ingress_memory_accounting(), + edgezero_core::PlatformFact::unknown( + edgezero_core::PlatformUnknownReason::ProviderUnpublished + ) + ); + } + // ---------- extract_namespace_id ---------- #[test] diff --git a/crates/edgezero-adapter-cloudflare/src/config_store.rs b/crates/edgezero-adapter-cloudflare/src/config_store.rs index 6fea0ae7c..a389be19a 100644 --- a/crates/edgezero-adapter-cloudflare/src/config_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/config_store.rs @@ -2,7 +2,8 @@ //! //! Each declared config id maps to its own Cloudflare KV namespace binding, //! resolved at request time from `EDGEZERO__STORES__CONFIG____NAME`. -//! Reads are async (`worker::kv::KvStore::get(key).text().await`). +//! Bounded reads drain a byte stream into caller-sized buffers; raw `get` is +//! explicitly unbounded. Neither path certifies opaque host allocations. //! //! ```toml //! # wrangler.toml @@ -16,16 +17,32 @@ //! arbitrary dotted keys had to be JSON-packed inside one variable. The KV //! backing has no such restriction. +#![cfg_attr( + all(feature = "cloudflare", target_arch = "wasm32"), + expect( + clippy::self_named_module_files, + reason = "the workspace denies both contradictory module-file conventions; retain its self-named-file convention" + ) +)] + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +mod kv; + +use std::future::Future; +#[cfg(test)] +use std::future::{pending, ready}; +use std::time::Duration; + use async_trait::async_trait; -use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; +use edgezero_core::config_store::{ + BoundedStoreRead, ConfigStore, ConfigStoreError, finish_bounded_config_read, +}; +use edgezero_core::time::{Deadline, MonotonicClock}; +use futures_util::future::{Either, select}; #[cfg(test)] use std::collections::HashMap; -#[cfg(not(any(all(feature = "cloudflare", target_arch = "wasm32"), test)))] -use std::convert::Infallible; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -use worker::Env; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -use worker::kv::KvStore as WorkerKvStore; +use worker::{Delay, Env}; /// Config store backed by a Cloudflare KV namespace. /// @@ -39,10 +56,7 @@ enum CloudflareConfigBackend { #[cfg(test)] InMemory(HashMap), #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] - Kv(WorkerKvStore), - /// Never constructed; keeps the enum inhabited off production/test cfgs. - #[cfg(not(any(all(feature = "cloudflare", target_arch = "wasm32"), test)))] - _Uninhabited(Infallible), + Kv(kv::KvBinding), } impl CloudflareConfigStore { @@ -61,32 +75,151 @@ impl CloudflareConfigStore { #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[inline] pub fn from_env(env: &Env, binding_name: &str) -> Result { - let store = env.kv(binding_name).map_err(|err| { - ConfigStoreError::unavailable(format!( - "failed to open config KV binding '{binding_name}': {err}" - )) - })?; + let store = kv::KvBinding::from_this(env.as_ref(), binding_name)?; Ok(Self { inner: CloudflareConfigBackend::Kv(store), }) } + + #[cfg_attr( + not(all(feature = "cloudflare", target_arch = "wasm32")), + expect( + clippy::unused_async, + reason = "native fixture is synchronous; the production SDK read is asynchronous" + ) + )] + async fn materialized_get(&self, key: &str) -> Result, ConfigStoreError> { + match &self.inner { + #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] + CloudflareConfigBackend::Kv(store) => store.materialized_get(key).await, + #[cfg(test)] + CloudflareConfigBackend::InMemory(data) => Ok(data.get(key).cloned()), + } + } } #[async_trait(?Send)] impl ConfigStore for CloudflareConfigStore { #[inline] - async fn get(&self, key: &str) -> Result, ConfigStoreError> { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + self.materialized_get(key) + .await + .map(|value| value.map(Into::into)) + } + + #[inline] + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { match &self.inner { #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] - CloudflareConfigBackend::Kv(store) => store.get(key).text().await.map_err(|err| { - ConfigStoreError::internal(anyhow::anyhow!("kv config get failed: {err}")) - }), + CloudflareConfigBackend::Kv(store) => { + bounded_config_read( + store.streaming_get( + key, + max_backend_bytes.min(max_value_bytes), + clock, + deadline, + ), + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + .await + } #[cfg(test)] - CloudflareConfigBackend::InMemory(data) => Ok(data.get(key).cloned()), - #[cfg(not(any(all(feature = "cloudflare", target_arch = "wasm32"), test)))] - CloudflareConfigBackend::_Uninhabited(never) => { - let _: &str = key; - match *never {} + CloudflareConfigBackend::InMemory(_) => { + bounded_config_read( + self.materialized_get(key), + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + .await + } + } + } +} + +// The timer covers acquisition, draining and conversion. Reader drop requests +// cancellation, but finite host teardown is a separate deployed evidence gate. +async fn bounded_config_read( + read: F, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, +) -> Result, ConfigStoreError> +where + F: Future, ConfigStoreError>>, +{ + #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] + { + return bounded_config_read_with_timer( + read, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + Delay::from, + ) + .await; + } + #[cfg(not(all(feature = "cloudflare", target_arch = "wasm32")))] + { + bounded_config_read_with_timer( + read, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + |_| pending(), + ) + .await + } +} + +async fn bounded_config_read_with_timer( + read: F, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + mut make_timer: MakeTimer, +) -> Result, ConfigStoreError> +where + F: Future, ConfigStoreError>>, + MakeTimer: FnMut(Duration) -> Timer, + Timer: Future, +{ + futures_util::pin_mut!(read); + loop { + let Some(remaining) = deadline.remaining_at(clock.now()) else { + return Err(ConfigStoreError::DeadlineExceeded); + }; + let timer = make_timer(remaining); + futures_util::pin_mut!(timer); + match select(timer, read.as_mut()).await { + Either::Left(((), _read)) => { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + } + Either::Right((result, _timer)) => { + return finish_bounded_config_read( + result, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ); } } } @@ -94,7 +227,333 @@ impl ConfigStore for CloudflareConfigStore { #[cfg(test)] mod tests { + #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] + mod streaming { + use super::*; + use wasm_bindgen_test::wasm_bindgen_test; + use worker::js_sys::{Function, Number, Promise, Reflect, global}; + use worker::wasm_bindgen::JsCast as _; + use worker::wasm_bindgen::JsValue; + use worker::wasm_bindgen_futures::JsFuture; + + fn streaming_store(length: usize, stalled: bool) -> (CloudflareConfigStore, JsValue) { + // An actual KV binding double: text reads fail and the byte source only + // fills caller-owned BYOB views, without materializing a complete value. + let factory = Function::new_with_args( + "length, stalled", + " + // worker::Delay expects browser numeric handles, not Node objects. + if (!globalThis.edgezeroNumericTimers) { + globalThis.edgezeroNumericTimers = true; + const set = globalThis.setTimeout; + const clear = globalThis.clearTimeout; + const timers = new Map(); + let next = 0; + globalThis.setTimeout = (callback, delay, ...args) => { + const id = ++next; + timers.set(id, set(() => { timers.delete(id); callback(...args); }, delay)); + return id; + }; + globalThis.clearTimeout = id => { + clear(timers.get(id) ?? id); + timers.delete(id); + }; + } + const stats = { cancels: 0, gets: 0, pulls: 0, maxView: 0 }; + const kv = { + get: async (key, options) => { + stats.gets++; + if (options.type !== 'stream') throw new Error('text read forbidden'); + if (key === 'missing') return null; + if (key === 'bad-binding') return 'not-a-stream'; + if (key === 'acquisition-error') throw new Error('TOKEN-DO-NOT-LEAK'); + if (key === 'non-byte') return new ReadableStream({ + cancel() { stats.cancels++; return Promise.reject(new Error('TOKEN-DO-NOT-LEAK')); } + }); + let position = 0; + const stream = new ReadableStream({ + type: 'bytes', + pull(controller) { + stats.pulls++; + if (key === 'source-error') throw new Error('TOKEN-DO-NOT-LEAK'); + const view = controller.byobRequest.view; + stats.maxView = Math.max(stats.maxView, view.byteLength); + if (stalled) return new Promise(() => {}); + const count = Math.min(view.byteLength, length - position); + if (count === 0) { + controller.close(); + controller.byobRequest.respond(0); + } else { + view.fill(key === 'invalid-utf8' ? 255 : 97, 0, count); + position += count; + controller.byobRequest.respond(count); + } + }, + cancel() { + stats.cancels++; + if (key === 'reject-cancel') return Promise.reject(new Error('TOKEN-DO-NOT-LEAK')); + } + }, { highWaterMark: 0 }); + if (key === 'late') return new Promise(resolve => { stats.resolve = () => resolve(stream); }); + return stream; + }, + getWithMetadata() {}, put() {}, list() {}, delete() {} + }; + return { kv, stats }; + ", + ); + let js_length = u32::try_from(length).expect("fixture length fits JS integer"); + let binding = factory + .call2( + &JsValue::NULL, + &JsValue::from(js_length), + &JsValue::from(stalled), + ) + .expect("KV fixture"); + let stats = Reflect::get(&binding, &JsValue::from("stats")).expect("stats"); + let kv = kv::KvBinding::from_this(&binding, "kv").expect("KV binding"); + ( + CloudflareConfigStore { + inner: CloudflareConfigBackend::Kv(kv), + }, + stats, + ) + } + + fn counter(stats: &JsValue, field: &str) -> usize { + Number::from(Reflect::get(stats, &JsValue::from(field)).expect("counter")) + .to_string_with_radix(10) + .expect("decimal counter") + .as_string() + .expect("counter string") + .parse() + .expect("integer counter") + } + + #[wasm_bindgen_test] + async fn bounded_kv_requires_gc_cleanup_support_before_provider_dispatch() { + let (store, stats) = streaming_store(1, false); + let global_object = global(); + let property = JsValue::from("FinalizationRegistry"); + let previous = Reflect::get(&global_object, &property).expect("runtime feature"); + Reflect::set(&global_object, &property, &JsValue::UNDEFINED).expect("disable feature"); + let result = store + .get_bounded( + "value", + &MonotonicClock::default(), + Deadline::after(Duration::from_secs(1)), + 1, + 1, + ) + .await; + Reflect::set(&global_object, &property, &previous).expect("restore feature"); + assert!(matches!(result, Err(ConfigStoreError::Unavailable { .. }))); + assert_eq!(counter(&stats, "gets"), 0); + } + + #[wasm_bindgen_test] + async fn bounded_kv_failed_binding_and_cancellation_never_publish_provider_errors() { + for key in [ + "bad-binding", + "acquisition-error", + "non-byte", + "source-error", + "reject-cancel", + ] { + let (store, stats) = streaming_store(2, false); + let error = store + .get_bounded( + key, + &MonotonicClock::default(), + Deadline::after(Duration::from_secs(1)), + 1, + 1, + ) + .await + .expect_err("typed rejection"); + assert!(!error.to_string().contains("TOKEN-DO-NOT-LEAK")); + assert!(matches!( + error, + ConfigStoreError::Unavailable { .. } | ConfigStoreError::ValueTooLarge + )); + if matches!(key, "non-byte" | "reject-cancel") { + assert_eq!(counter(&stats, "cancels"), 1); + } + // Node's runner fails on unhandled promise rejection. + Delay::from(Duration::from_millis(10)).await; + } + } + + #[wasm_bindgen_test] + async fn bounded_kv_cancels_stream_arriving_after_acquisition_is_dropped() { + let (store, stats) = streaming_store(1, false); + let clock = MonotonicClock::default(); + let mut read = Box::pin(store.get_bounded( + "late", + &clock, + Deadline::after(Duration::from_secs(1)), + 1, + 1, + )); + assert!(futures_util::poll!(read.as_mut()).is_pending()); + drop(read); + let resolve = Reflect::get(&stats, &JsValue::from("resolve")) + .expect("resolver") + .dyn_into::() + .expect("resolve function"); + resolve.call0(&JsValue::NULL).expect("resolve late stream"); + Delay::from(Duration::from_millis(10)).await; + assert_eq!( + counter(&stats, "cancels"), + 1, + "late acquisition still owns cancellation" + ); + assert_eq!( + counter(&stats, "pulls"), + 0, + "late stream must never be read" + ); + } + + #[wasm_bindgen_test] + async fn bounded_kv_ready_reads_check_the_clock_between_pulls() { + let (store, stats) = streaming_store(32 * 1024, false); + let start = edgezero_core::MonotonicInstant::now(); + let end = start.checked_add(Duration::from_secs(1)).expect("end"); + let calls = Arc::new(AtomicUsize::new(0)); + let samples = Arc::clone(&calls); + let clock = MonotonicClock::new(move || { + if samples.fetch_add(1, Ordering::SeqCst) < 2 { + start + } else { + end + } + }); + let error = store + .get_bounded( + "value", + &clock, + Deadline::at_instant(end), + 32 * 1024, + 32 * 1024, + ) + .await + .expect_err("deadline"); + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + assert!( + counter(&stats, "pulls") <= 1, + "ready microtasks must not drain past expiry" + ); + } + + #[wasm_bindgen_test] + async fn bounded_kv_uses_streaming_and_inclusive_caps() { + for length in [0, 1, 0x4000, 0x8001] { + let (store, stats) = streaming_store(length, false); + let limit = u64::try_from(length).expect("limit"); + let read = store + .get_bounded( + "value", + &MonotonicClock::default(), + Deadline::after(Duration::from_secs(1)), + limit, + limit, + ) + .await + .expect("bounded stream"); + assert_eq!(read.backend_bytes, limit); + assert_eq!(read.value.as_deref().map(str::len), Some(length)); + assert_eq!(counter(&stats, "cancels"), 0); + assert!(counter(&stats, "maxView") <= 0x4000); + } + } + + #[wasm_bindgen_test] + async fn bounded_kv_cancels_before_appending_first_excess_byte() { + for (backend, value) in [(4, 5), (5, 4), (0, 0)] { + let (store, stats) = streaming_store(5, false); + let error = store + .get_bounded( + "value", + &MonotonicClock::default(), + Deadline::after(Duration::from_secs(1)), + backend, + value, + ) + .await + .expect_err("overflow"); + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + assert_eq!(counter(&stats, "cancels"), 1); + } + } + + #[wasm_bindgen_test] + async fn bounded_kv_missing_and_invalid_utf8_are_distinct() { + let (store, _) = streaming_store(1, false); + let clock = MonotonicClock::default(); + let deadline = Deadline::after(Duration::from_secs(1)); + let read = store + .get_bounded("missing", &clock, deadline, 1, 1) + .await + .expect("missing"); + assert!(read.value.is_none()); + assert_eq!(read.backend_bytes, 0); + let error = store + .get_bounded("invalid-utf8", &clock, deadline, 1, 1) + .await + .expect_err("UTF-8"); + assert!(matches!(error, ConfigStoreError::Unavailable { .. })); + } + + #[wasm_bindgen_test] + async fn bounded_kv_dropped_read_cancels_pending_reader() { + let (store, stats) = streaming_store(1, true); + let clock = MonotonicClock::default(); + let mut read = Box::pin(store.get_bounded( + "value", + &clock, + Deadline::after(Duration::from_mins(1)), + 1, + 1, + )); + // Allow promise acquisition and the first read to become pending. + for _ in 0_u32..4 { + assert!(futures_util::poll!(read.as_mut()).is_pending()); + JsFuture::from(Promise::resolve(&JsValue::NULL)) + .await + .expect("microtask"); + } + assert_eq!(counter(&stats, "pulls"), 1); + drop(read); + assert_eq!(counter(&stats, "cancels"), 1); + } + + #[wasm_bindgen_test] + async fn bounded_kv_timer_cancels_a_permanently_pending_read() { + let (store, stats) = streaming_store(1, true); + let error = store + .get_bounded( + "value", + &MonotonicClock::default(), + Deadline::after(Duration::from_millis(20)), + 1, + 1, + ) + .await + .expect_err("deadline"); + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + assert_eq!(counter(&stats, "cancels"), 1); + } + } + use super::*; + use std::cell::Cell; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; + use futures::executor::block_on; edgezero_core::config_store_contract_tests!(cloudflare_config_store_contract, { CloudflareConfigStore::from_entries([ @@ -102,4 +561,221 @@ mod tests { ("contract.key.b".to_owned(), "value_b".to_owned()), ]) }); + + struct DropProbe(Arc); + + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + fn clock_reaching_deadline() -> (MonotonicClock, Deadline) { + let start = edgezero_core::MonotonicInstant::now(); + let terminal = start + .checked_add(Duration::from_secs(1)) + .expect("terminal instant"); + let samples = Arc::new(AtomicUsize::new(0)); + let observed_samples = Arc::clone(&samples); + let clock = MonotonicClock::new(move || { + if observed_samples.fetch_add(1, Ordering::SeqCst) == 0 { + start + } else { + terminal + } + }); + (clock, Deadline::at_instant(terminal)) + } + + #[test] + fn bounded_read_timer_drops_never_ready_provider() { + let drops = Arc::new(AtomicUsize::new(0)); + let guard = DropProbe(Arc::clone(&drops)); + let read = async move { + let _guard = guard; + pending::, ConfigStoreError>>().await + }; + let (clock, deadline) = clock_reaching_deadline(); + + let error = block_on(bounded_config_read_with_timer( + read, + &clock, + deadline, + 1, + 1, + |_| ready(()), + )) + .expect_err("timer must terminate a pending config read"); + + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + + #[test] + fn bounded_read_rearms_after_an_early_timer_wake() { + let timer_calls = Arc::new(AtomicUsize::new(0)); + let observed_timer_calls = Arc::clone(&timer_calls); + let clock = MonotonicClock::default(); + let error = block_on(bounded_config_read_with_timer( + ready(Err(ConfigStoreError::unavailable("provider error"))), + &clock, + Deadline::after(Duration::from_secs(1)), + 1, + 1, + move |_| { + let early_wake = observed_timer_calls.fetch_add(1, Ordering::SeqCst) == 0; + async move { + if !early_wake { + pending::<()>().await; + } + } + }, + )) + .expect_err("provider error must win after an early timer wake"); + + assert!(matches!(error, ConfigStoreError::Unavailable { .. })); + assert_eq!(timer_calls.load(Ordering::SeqCst), 2); + } + + #[test] + fn bounded_read_timer_wins_simultaneous_ready_error_at_deadline() { + let (clock, deadline) = clock_reaching_deadline(); + let error = block_on(bounded_config_read_with_timer( + ready(Err(ConfigStoreError::unavailable("provider error"))), + &clock, + deadline, + 1, + 1, + |_| ready(()), + )) + .expect_err("timer readiness at the deadline must win equality"); + + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + } + + #[test] + fn bounded_read_reports_exact_bytes_and_accepts_exact_caps() { + let clock = MonotonicClock::default(); + let result = block_on(bounded_config_read( + async { Ok(Some("value".to_owned())) }, + &clock, + Deadline::after(Duration::from_secs(1)), + 5, + 5, + )) + .expect("exact caps must succeed"); + + assert_eq!(result.backend_bytes, 5); + assert_eq!(result.value.as_deref(), Some("value")); + } + + #[test] + fn bounded_read_rejects_either_exceeded_cap() { + for (max_backend_bytes, max_value_bytes) in [(4, 5), (5, 4)] { + let clock = MonotonicClock::default(); + let error = block_on(bounded_config_read( + async { Ok(Some("value".to_owned())) }, + &clock, + Deadline::after(Duration::from_secs(1)), + max_backend_bytes, + max_value_bytes, + )) + .expect_err("an exceeded cap must fail"); + + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + } + } + + #[test] + fn bounded_read_checks_deadline_before_polling_host_call() { + let polled = Cell::new(false); + let clock = MonotonicClock::default(); + let error = block_on(bounded_config_read( + async { + polled.set(true); + Ok(None) + }, + &clock, + Deadline::after(Duration::ZERO), + 1, + 1, + )) + .expect_err("expired deadline must fail"); + + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + assert!(!polled.get(), "expired reads must not poll the host call"); + } + + #[test] + fn bounded_read_checks_deadline_after_host_call() { + let start = MonotonicInstant::now(); + let terminal = start + .checked_add(Duration::from_secs(1)) + .expect("terminal instant"); + let now = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&now); + let clock = MonotonicClock::new(move || *clock_now.lock().expect("clock lock")); + let error = block_on(bounded_config_read( + async { + *now.lock().expect("clock lock") = terminal; + Ok(None) + }, + &clock, + Deadline::at_instant(terminal), + 1, + 1, + )) + .expect_err("a host call completing after the deadline must fail"); + + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + } + + #[test] + fn bounded_read_deadline_wins_over_late_host_error() { + let start = MonotonicInstant::now(); + let terminal = start + .checked_add(Duration::from_secs(1)) + .expect("terminal instant"); + let now = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&now); + let clock = MonotonicClock::new(move || *clock_now.lock().expect("clock lock")); + let error = block_on(bounded_config_read( + async { + *now.lock().expect("clock lock") = terminal; + Err(ConfigStoreError::unavailable("late host error")) + }, + &clock, + Deadline::at_instant(terminal), + 1, + 1, + )) + .expect_err("the post-call deadline check must run after host errors"); + + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + } + + #[test] + fn bounded_config_read_uses_injected_clock() { + let process_now = MonotonicInstant::now(); + let injected_now = process_now + .checked_sub(Duration::from_mins(1)) + .expect("injected instant"); + let clock = MonotonicClock::new(move || injected_now); + let deadline = Deadline::at_instant( + injected_now + .checked_add(Duration::from_secs(1)) + .expect("deadline instant"), + ); + + let result = block_on(bounded_config_read( + async { Ok(Some("value".to_owned())) }, + &clock, + deadline, + 5, + 5, + )) + .expect("the application clock is still before its deadline"); + + assert_eq!(result.value.as_deref(), Some("value")); + } } diff --git a/crates/edgezero-adapter-cloudflare/src/config_store/kv.rs b/crates/edgezero-adapter-cloudflare/src/config_store/kv.rs new file mode 100644 index 000000000..edf76a485 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/config_store/kv.rs @@ -0,0 +1,226 @@ +//! Workers-only KV acquisition and caller-sized byte reads. + +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use edgezero_core::config_store::ConfigStoreError; +use edgezero_core::time::{Deadline, MonotonicClock}; +use wasm_streams::readable::ReadableStreamBYOBReader; +use worker::Delay; +use worker::js_sys::{Function, Object, Promise, Reflect, Uint8Array, global}; +use worker::kv::KvStore as WorkerKvStore; +use worker::wasm_bindgen::closure::Closure; +use worker::wasm_bindgen::{JsCast as _, JsValue}; +use worker::wasm_bindgen_futures::JsFuture; +use worker::web_sys::ReadableStream; + +const CONFIG_READ_CHUNK_BYTES: usize = 0x4000; + +struct Acquisition(Rc>); + +#[derive(Default)] +struct AcquisitionState { + abandoned: bool, + missing: bool, + stream: Option, +} + +struct ConfigReader<'stream> { + completed: bool, + reader: ReadableStreamBYOBReader<'stream>, +} + +pub(super) struct KvBinding { + get: Function, + namespace: JsValue, + store: WorkerKvStore, +} + +impl Drop for Acquisition { + fn drop(&mut self) { + let acquired = { + let mut state = self.0.borrow_mut(); + state.abandoned = true; + state.stream.take() + }; + if let Some(stream) = acquired { + consume_cancellation(&stream.cancel()); + } + } +} + +impl Drop for ConfigReader<'_> { + fn drop(&mut self) { + if !self.completed { + consume_cancellation(&self.reader.as_raw().cancel()); + } + } +} + +impl KvBinding { + pub(super) fn from_this(env: &JsValue, name: &str) -> Result { + let store = WorkerKvStore::from_this(env, name).map_err(|_error| unavailable())?; + let namespace = Reflect::get(env, &JsValue::from(name)).map_err(|_error| unavailable())?; + let get = Reflect::get(&namespace, &JsValue::from("get")) + .map_err(|_error| unavailable())? + .dyn_into::() + .map_err(|_error| unavailable())?; + Ok(Self { + get, + namespace, + store, + }) + } + + pub(super) async fn materialized_get( + &self, + key: &str, + ) -> Result, ConfigStoreError> { + self.store + .get(key) + .text() + .await + .map_err(|_error| unavailable()) + } + + pub(super) async fn streaming_get( + &self, + key: &str, + max_bytes: u64, + clock: &MonotonicClock, + deadline: Deadline, + ) -> Result, ConfigStoreError> { + // Transferred callbacks require wasm-bindgen's JS-GC cleanup support. + // Older/explicitly disabled Workers profiles must fail before dispatch. + if Reflect::get(&global(), &JsValue::from("FinalizationRegistry")) + .map_err(|_error| unavailable())? + .dyn_into::() + .is_err() + { + return Err(unavailable()); + } + let options = Object::new(); + Reflect::set(&options, &JsValue::from("type"), &JsValue::from("stream")) + .map_err(|_error| unavailable())?; + let promise = self + .get + .call2(&self.namespace, &JsValue::from(key), &options) + .map_err(|_error| unavailable())? + .dyn_into::() + .map_err(|_error| unavailable())?; + let acquisition = Acquisition(Rc::new(RefCell::new(AcquisitionState::default()))); + let state = Rc::clone(&acquisition.0); + // The JS promise retains the callback even if the Rust waiter is dropped. + // Transfer the closure to JS GC ownership; no detached Rust cleanup task. + let resolved = Closure::new(move |value: JsValue| { + if value.is_null() { + state.borrow_mut().missing = true; + return; + } + if let Ok(stream) = value.dyn_into::() { + let mut acquired = state.borrow_mut(); + if acquired.abandoned { + drop(acquired); + consume_cancellation(&stream.cancel()); + } else { + acquired.stream = Some(stream); + } + } + }); + let observed = promise.then(&resolved); + drop(resolved.into_js_value()); + JsFuture::from(observed) + .await + .map_err(|_error| unavailable())?; + if acquisition.0.borrow().missing { + return Ok(None); + } + let raw = acquisition + .0 + .borrow_mut() + .stream + .take() + .ok_or_else(unavailable)?; + drain(raw, max_bytes, clock, deadline).await.map(Some) + } +} + +fn consume_cancellation(promise: &Promise) { + // Both branches must be handled: an ignored rejected cancel promise can + // otherwise publish secret-bearing provider diagnostics as an unhandled rejection. + let ignore = Closure::new(|_value: JsValue| {}); + drop(promise.then2(&ignore, &ignore)); + drop(ignore.into_js_value()); +} + +async fn drain( + raw: ReadableStream, + max_bytes: u64, + clock: &MonotonicClock, + deadline: Deadline, +) -> Result { + let mut stream = wasm_streams::ReadableStream::from_raw(raw.clone().unchecked_into()); + let reader = match stream.try_get_byob_reader() { + Ok(reader) => reader, + Err(_error) => { + consume_cancellation(&raw.cancel()); + return Err(unavailable()); + } + }; + let mut guard = ConfigReader { + completed: false, + reader, + }; + let limit = usize::try_from(max_bytes).unwrap_or(usize::MAX); + let mut bytes = Vec::new(); + let mut scratch = vec![0_u8; CONFIG_READ_CHUNK_BYTES]; + let mut buffer = Uint8Array::new_with_length(0x4000); + let mut pulls = 0_u32; + loop { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + let remaining = limit.saturating_sub(bytes.len()); + let wanted = remaining.saturating_add(1).min(CONFIG_READ_CHUNK_BYTES); + let (count, returned_buffer) = guard + .reader + .read_with_buffer(scratch.get_mut(..wanted).ok_or_else(unavailable)?, buffer) + .await + .map_err(|_error| unavailable())?; + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + buffer = returned_buffer.ok_or_else(unavailable)?; + if count == 0 { + guard.completed = true; + break; + } + if count > remaining { + return Err(ConfigStoreError::ValueTooLarge); + } + let next_len = bytes + .len() + .checked_add(count) + .ok_or(ConfigStoreError::ValueTooLarge)?; + if next_len > bytes.capacity() { + let capacity = next_len.max(bytes.capacity().saturating_mul(2)).min(limit); + bytes + .try_reserve_exact(capacity.saturating_sub(bytes.len())) + .map_err(|_error| ConfigStoreError::ValueTooLarge)?; + } + bytes.extend_from_slice(scratch.get(..count).ok_or_else(unavailable)?); + pulls = pulls.saturating_add(1); + if pulls == 16 { + // Workers may freeze its clock within a task. Yield to the task queue, + // not just the promise microtask queue, so timers can make progress. + Delay::from(Duration::ZERO).await; + pulls = 0; + } + } + String::from_utf8(bytes).map_err(|_error| unavailable()) +} + +fn unavailable() -> ConfigStoreError { + ConfigStoreError::unavailable("config KV byte stream is unavailable") +} diff --git a/crates/edgezero-adapter-cloudflare/src/context.rs b/crates/edgezero-adapter-cloudflare/src/context.rs index a5faf72f9..e03d7a273 100644 --- a/crates/edgezero-adapter-cloudflare/src/context.rs +++ b/crates/edgezero-adapter-cloudflare/src/context.rs @@ -11,6 +11,11 @@ pub struct CloudflareRequestContext { } impl CloudflareRequestContext { + #[inline] + pub(crate) fn context_handle(&self) -> Arc { + Arc::clone(&self.ctx) + } + #[inline] #[must_use] pub fn ctx(&self) -> &Context { diff --git a/crates/edgezero-adapter-cloudflare/src/key_value_store.rs b/crates/edgezero-adapter-cloudflare/src/key_value_store.rs index 651780033..9c2b4b1e0 100644 --- a/crates/edgezero-adapter-cloudflare/src/key_value_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/key_value_store.rs @@ -7,26 +7,19 @@ //! This module is only compiled when the `cloudflare` feature is enabled //! and the target is `wasm32`. -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use async_trait::async_trait; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use bytes::Bytes; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use std::time::Duration; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use worker::kv::KvStore as WorkerKvStore; /// KV store backed by Cloudflare Workers KV. /// /// Wraps a `worker::kv::KvStore` handle obtained via the environment binding. -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub struct CloudflareKvStore { store: WorkerKvStore, } -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] impl CloudflareKvStore { /// Create a new Cloudflare KV store from the environment binding name. /// @@ -45,7 +38,6 @@ impl CloudflareKvStore { } } -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[async_trait(?Send)] impl KvStore for CloudflareKvStore { #[inline] diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index 4431a236b..562824c6f 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -1,6 +1,6 @@ //! Adapter helpers for Cloudflare Workers. -#[cfg(feature = "cli")] +#[cfg(all(feature = "cli", not(target_arch = "wasm32")))] pub mod cli; // `config_store` compiles on host for its `InMemory` test backend; the @@ -11,29 +11,82 @@ pub mod config_store; pub mod context; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub mod key_value_store; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub mod proxy; +#[cfg(any( + test, + feature = "test-utils", + all(feature = "cloudflare", target_arch = "wasm32") +))] +pub mod outbound; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub mod request; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub mod response; #[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] -mod response_headers; +pub mod response; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub mod secret_store; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -use edgezero_core::app::{App, Hooks, StoresMetadata}; +use edgezero_core::app::StoresMetadata; +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +use edgezero_core::app::{App, Hooks}; +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +use edgezero_core::context::RuntimeVariables; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use edgezero_core::env_config::EnvConfig; +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +use edgezero_core::error::EdgeError; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use edgezero_core::manifest::BakedManifest; +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +use edgezero_core::manifest::ResolvedEnvironmentBinding; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use worker::{Context, Env, Error as WorkerError, Request, Response}; +/// Cloudflare Workers' published per-isolate memory limit. +pub const CLOUDFLARE_PLATFORM: edgezero_core::PlatformMetadata = + edgezero_core::PlatformMetadata::new( + edgezero_core::PlatformFact::known( + edgezero_core::MemoryCeiling::new( + 128_000_000, + edgezero_core::MemoryCeilingScope::PerInstance, + None, + ), + edgezero_core::PlatformResourceSource::PlatformLimit { + provider: "Cloudflare Workers", + }, + ), + edgezero_core::PlatformFact::unknown( + edgezero_core::PlatformUnknownReason::ProviderUnpublished, + ), + edgezero_core::PlatformFact::unknown( + edgezero_core::PlatformUnknownReason::ProviderUnpublished, + ), + ); + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +fn build_app_for_dispatch() -> Result { + build_target_app::().map_err(|error| { + log::error!(target: edgezero_core::BOOT_LOG_TARGET, "application configuration failed: {}", error.kind()); + WorkerError::RustError("application configuration failed".to_owned()) + }) +} + +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +fn build_target_app() -> Result { + App::build::(CLOUDFLARE_PLATFORM) +} + +/// Test seam for the production application-assembly error mapping. +#[cfg(all(feature = "test-utils", feature = "cloudflare", target_arch = "wasm32"))] +#[doc(hidden)] +#[inline] +pub fn build_app_for_test() -> Result { + build_app_for_dispatch::() +} + /// # Errors -/// Never; this is currently a no-op on Cloudflare Workers (Workers manages -/// its own logging). The signature still returns [`log::SetLoggerError`] so -/// callers and the non-wasm stub stay drop-in compatible if a real logger -/// is wired in later. +/// Never; this is a no-op. Workers console logging does not install a Rust +/// `log` backend. Applications needing facade output must install a backend +/// and its filters before [`run_app`] and set `Hooks::owns_logging()` to `true`. #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[inline] pub fn init_logger() -> Result<(), log::SetLoggerError> { @@ -86,6 +139,18 @@ fn env_config_from_worker(env: &Env, stores: StoresMetadata) -> EnvConfig { EnvConfig::from_vars(vars) } +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] +fn collect_worker_variables( + bindings: &[ResolvedEnvironmentBinding], + mut lookup: impl FnMut(&str) -> Option, +) -> RuntimeVariables { + RuntimeVariables::from_vars(bindings.iter().filter_map(|binding| { + lookup(&binding.env) + .or_else(|| binding.value.clone()) + .map(|value| (binding.name.clone(), value)) + })) +} + /// Entry point for a Cloudflare Workers application. /// /// Portable store config is baked into `A` by the `app!` macro; adapter-specific @@ -102,27 +167,22 @@ pub async fn run_app( env: Env, ctx: Context, ) -> Result { - // Best-effort: if a logger is already installed, ignore the error rather - // than panicking — every Worker request re-enters this function. Skipped - // entirely when the app owns logging. if !A::owns_logging() { drop(init_logger()); } - let app = A::build_app(); + let app = build_app_for_dispatch::()?; dispatch_app::(&app, req, env, ctx).await } -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -/// Dispatch a caller-owned app using `A::stores()` for fresh request bindings. +/// Dispatch a caller-owned app through canonical admission and response egress. /// -/// This does not build or cache an app or install logging. The caller must pass -/// an app built from `A`; `App` erases its construction type, so this pairing -/// remains a caller precondition. Retain only application-owned values and keep -/// native handles and pending work request-local. Shared state must support -/// overlapping invocations. +/// The app must be built from `A` with [`CLOUDFLARE_PLATFORM`]. This function does not +/// initialize logging or retain an app. Bindings and runtime variables are resolved +/// for every request; only application-owned, concurrency-safe state may be retained. /// /// # Errors -/// Returns conversion or dispatch errors from the existing adapter boundary. +/// Returns category-safe configuration, admission-abort, or adapter delivery errors. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[inline] pub async fn dispatch_app( app: &App, @@ -132,6 +192,20 @@ pub async fn dispatch_app( ) -> Result { let stores = A::stores(); let env_config = env_config_from_worker(&env, stores); + let runtime_variables = match A::manifest() { + BakedManifest::Absent => RuntimeVariables::default(), + BakedManifest::Present(manifest) => { + let resolved = manifest.environment_for("cloudflare"); + collect_worker_variables(&resolved.variables, |key| { + env.var(key).ok().map(|value| value.to_string()) + }) + } + BakedManifest::Malformed(_) | _ => { + return Err(WorkerError::RustError( + "application manifest is invalid".to_owned(), + )); + } + }; request::dispatch_with_registries( app, req, @@ -142,7 +216,73 @@ pub async fn dispatch_app( kv_meta: stores.kv, secret_meta: stores.secrets, env_config: &env_config, + runtime_variables, }, ) .await } + +#[cfg(test)] +mod platform_tests { + use edgezero_core::app::{App, Hooks}; + use edgezero_core::error::EdgeError; + use edgezero_core::manifest::ResolvedEnvironmentBinding; + use edgezero_core::router::RouterService; + + struct PlatformAwareConfiguration; + + #[expect( + clippy::missing_trait_methods, + reason = "test hook exercises only target metadata propagation" + )] + impl Hooks for PlatformAwareConfiguration { + fn configure(app: &mut App) -> Result<(), EdgeError> { + if app.platform() == crate::CLOUDFLARE_PLATFORM { + Ok(()) + } else { + Err(EdgeError::service_unavailable("wrong application platform")) + } + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[test] + fn application_configuration_receives_cloudflare_platform_metadata() { + let app = super::build_target_app::() + .expect("configured application"); + + assert_eq!(app.platform(), crate::CLOUDFLARE_PLATFORM); + } + + #[test] + fn declared_worker_variables_prefer_binding_then_manifest_default() { + let bindings = [ + ResolvedEnvironmentBinding { + description: None, + env: "UPSTREAM_ORIGIN".to_owned(), + name: "API_BASE_URL".to_owned(), + value: Some("https://default.example".to_owned()), + }, + ResolvedEnvironmentBinding { + description: None, + env: "OPTIONAL_BINDING".to_owned(), + name: "OPTIONAL".to_owned(), + value: None, + }, + ]; + let runtime = super::collect_worker_variables(&bindings, |key| { + (key == "UPSTREAM_ORIGIN").then(|| "https://worker.example".to_owned()) + }); + assert_eq!(runtime.get("API_BASE_URL"), Some("https://worker.example")); + assert_eq!(runtime.get("OPTIONAL"), None); + + let defaulted = super::collect_worker_variables(&bindings, |_| None); + assert_eq!( + defaulted.get("API_BASE_URL"), + Some("https://default.example") + ); + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/outbound.rs b/crates/edgezero-adapter-cloudflare/src/outbound.rs new file mode 100644 index 000000000..d1f16da23 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/outbound.rs @@ -0,0 +1,1605 @@ +#![cfg_attr( + any(test, all(feature = "cloudflare", target_arch = "wasm32")), + expect( + clippy::arbitrary_source_item_ordering, + reason = "target and test helper modules stay adjacent to the imports that define their boundaries" + ) +)] +#![cfg_attr( + all(feature = "cloudflare", target_arch = "wasm32"), + expect( + clippy::pub_use, + reason = "the target-gated implementation keeps Workers imports out of native builds" + ) +)] + +use edgezero_core::error::EdgeError; +#[cfg(any( + all(feature = "cloudflare", target_arch = "wasm32"), + feature = "test-utils" +))] +use edgezero_core::error::{BadGatewayReason, BudgetSource}; +#[cfg(any( + all(feature = "cloudflare", target_arch = "wasm32"), + feature = "test-utils" +))] +use edgezero_core::outbound::{OutboundRequest, validate_for_dispatch}; + +#[cfg(any(all(feature = "cloudflare", target_arch = "wasm32"), test))] +mod header_bridge { + use std::str; + + use edgezero_core::error::EdgeError; + use edgezero_core::http::{HeaderMap, HeaderName}; + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub(super) enum ResetContentAction { + Abort, + Disarm, + InspectStream, + } + + pub(crate) trait HeaderSink { + fn append(&mut self, name: &str, value: &str) -> Result<(), EdgeError>; + } + + pub(crate) fn copy_header_values( + source: &HeaderMap, + sink: &mut Sink, + invalid_value: InvalidValue, + ) -> Result<(), EdgeError> + where + Sink: HeaderSink, + InvalidValue: Fn(&HeaderName) -> EdgeError, + { + for (name, value) in source { + let header_value = + str::from_utf8(value.as_bytes()).map_err(|_encoding_error| invalid_value(name))?; + sink.append(name.as_str(), header_value)?; + } + Ok(()) + } + + pub(super) fn reset_content_action( + declared_body: bool, + native_body_is_empty: bool, + ) -> ResetContentAction { + if declared_body { + ResetContentAction::Abort + } else if native_body_is_empty { + ResetContentAction::Disarm + } else { + ResetContentAction::InspectStream + } + } +} + +#[cfg(any(all(feature = "cloudflare", target_arch = "wasm32"), test))] +pub(crate) use header_bridge::copy_header_values; +#[cfg(any(all(feature = "cloudflare", target_arch = "wasm32"), test))] +use header_bridge::{HeaderSink, ResetContentAction, reset_content_action}; + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +impl HeaderSink for worker::Headers { + fn append(&mut self, name: &str, value: &str) -> Result<(), EdgeError> { + worker::Headers::append(self, name, value).map_err(EdgeError::internal) + } +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +mod worker_impl { + use std::cell::Cell; + use std::future::Future; + use std::num::NonZeroU64; + use std::pin::Pin; + use std::rc::Rc; + use std::task::{Context, Poll}; + use std::time::Duration; + + use async_stream::stream; + use async_trait::async_trait; + use bytes::Bytes; + use edgezero_core::body::{Body, BodyStream}; + #[cfg(feature = "test-utils")] + use edgezero_core::error::BudgetSource; + use edgezero_core::error::{BadGatewayReason, EdgeError}; + use edgezero_core::http::header::{ACCEPT_ENCODING, HOST}; + use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode}; + use edgezero_core::outbound::{ + ContentTypeProvenance, OutboundBatch, OutboundCachePolicy, OutboundHttpClient, + OutboundRequest, OutboundRequestParts, OutboundResponse, ResponseBodyDisposition, + ResponseBodyPolicy, ResponseHeaderLimiter, ResponseMode, apply_response_body_policy, + collect_response_stream, insert_proxy_header, normalize_for_dispatch, + normalize_response_headers, validate_for_dispatch, + }; + use edgezero_core::time::{ + Deadline, DispatchBudget, MonotonicClock, MonotonicInstant, dispatch_budget, + }; + use futures_util::StreamExt as _; + use futures_util::future::{Either, FutureExt as _, LocalBoxFuture, select}; + use worker::js_sys::{Function, Reflect, Uint8Array, global}; + use worker::wasm_bindgen::closure::Closure; + use worker::wasm_bindgen::{JsCast as _, JsValue}; + use worker::wasm_bindgen_futures::JsFuture; + use worker::web_sys; + use worker::{ + CacheMode, Delay, Headers, Method as WorkerMethod, Request as WorkerRequest, RequestInit, + RequestRedirect, Response as WorkerResponse, ResponseBody as WorkerResponseBody, + }; + + use super::{ResetContentAction, reset_content_action, timeout_error}; + + const READY_ITEM_YIELD_QUOTA: u32 = 64; + + struct HostEventYield { + awoken: Rc>, + callback: Option>, + timeout_handle: Option, + } + + impl HostEventYield { + fn new() -> Self { + Self { + awoken: Rc::new(Cell::new(false)), + callback: None, + timeout_handle: None, + } + } + } + + impl Future for HostEventYield { + type Output = Result<(), EdgeError>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + if this.awoken.get() { + return Poll::Ready(Ok(())); + } + if this.callback.is_some() { + return Poll::Pending; + } + + let awoken = Rc::clone(&this.awoken); + let waker = cx.waker().clone(); + let callback = Closure::::new(move || { + awoken.set(true); + waker.wake_by_ref(); + }); + let global_scope = global(); + let Some(set_timeout) = Reflect::get(&global_scope, &JsValue::from_str("setTimeout")) + .ok() + .and_then(|value| value.dyn_into::().ok()) + else { + return Poll::Ready(Err(host_event_schedule_error())); + }; + let timeout_handle = match set_timeout.call2( + &global_scope, + callback.as_ref(), + &JsValue::from_f64(0.0), + ) { + Ok(handle) => handle, + Err(_error) => return Poll::Ready(Err(host_event_schedule_error())), + }; + this.callback = Some(callback); + this.timeout_handle = Some(timeout_handle); + Poll::Pending + } + } + + impl Drop for HostEventYield { + fn drop(&mut self) { + if self.awoken.get() { + return; + } + let Some(timeout_handle) = self.timeout_handle.take() else { + return; + }; + let global_scope = global(); + let Ok(clear_timeout) = Reflect::get(&global_scope, &JsValue::from_str("clearTimeout")) + else { + return; + }; + let Some(clear_timeout_function) = clear_timeout.dyn_ref::() else { + return; + }; + let _ignored = clear_timeout_function.call1(&global_scope, &timeout_handle); + } + } + + fn host_event_schedule_error() -> EdgeError { + EdgeError::internal(anyhow::anyhow!("Cloudflare host-event scheduling failed")) + } + + /// Native outbound HTTP implementation for Cloudflare Workers. + #[derive(Clone)] + pub struct CloudflareOutboundClient { + clock: MonotonicClock, + } + + struct AbortGuard { + controller: Option, + } + + struct PreparedRequest { + budget: DispatchBudget, + parts: OutboundRequestParts, + } + + impl AbortGuard { + fn disarm(&mut self) { + self.controller = None; + } + + fn new(controller: web_sys::AbortController) -> Self { + Self { + controller: Some(controller), + } + } + } + + impl CloudflareOutboundClient { + async fn execute(&self, prepared: PreparedRequest) -> Result { + let PreparedRequest { budget, parts } = prepared; + let OutboundRequestParts { + body, + cache_policy, + mut headers, + host_authority_override, + max_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_request_body_bytes, + max_response_header_bytes, + max_response_header_count, + method, + response_mode, + uri, + .. + } = parts; + + if !headers.contains_key(ACCEPT_ENCODING) { + headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity")); + } + if let Some(authority) = host_authority_override { + let value = + HeaderValue::from_str(authority.as_str()).map_err(EdgeError::internal)?; + headers.insert(HOST, value); + } + let worker_body = + request_body(body, max_request_body_bytes, budget, self.clock.clone()).await?; + let url = uri.to_string(); + let request = build_worker_request(&method, &url, &headers, worker_body, cache_policy)?; + let (response, abort_guard) = raw_fetch(request, budget, &self.clock).await?; + + process_response( + response, + abort_guard, + method, + response_mode, + budget, + max_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + self.clock.clone(), + ) + .await + } + + fn prepare( + request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + validate_for_dispatch(&request)?; + Self::prepare_validated(request, started_at, None) + } + + fn prepare_batch( + request: OutboundRequest, + started_at: MonotonicInstant, + observation_cutoff: Deadline, + ) -> Result { + super::validate_batch_request(&request)?; + Self::prepare_validated(request, started_at, Some(observation_cutoff)) + } + + fn prepare_validated( + mut request: OutboundRequest, + started_at: MonotonicInstant, + observation_cutoff: Option, + ) -> Result { + let budget = dispatch_budget(&request, started_at, observation_cutoff)?; + normalize_for_dispatch(&mut request)?; + Ok(PreparedRequest { + budget, + parts: request.into_parts(), + }) + } + + /// Builds a client using the process-default monotonic clock. + #[must_use] + #[inline] + pub fn new() -> Self { + Self::with_clock(MonotonicClock::default()) + } + + /// Builds a client that evaluates every outbound lifetime against `clock`. + #[must_use] + #[inline] + pub fn with_clock(clock: MonotonicClock) -> Self { + Self { clock } + } + } + + impl Default for CloudflareOutboundClient { + #[inline] + fn default() -> Self { + Self::new() + } + } + + impl Drop for AbortGuard { + fn drop(&mut self) { + if let Some(controller) = self.controller.take() { + controller.abort(); + } + } + } + + #[async_trait(?Send)] + impl OutboundHttpClient for CloudflareOutboundClient { + #[inline] + async fn send(&self, request: OutboundRequest) -> Result { + let started_at = self.clock.now(); + let prepared = Self::prepare(request, started_at)?; + self.execute(prepared).await + } + + #[inline] + fn start_batch_until( + &self, + requests: Vec, + observation_cutoff: Deadline, + ) -> OutboundBatch { + let batch_started_at = self.clock.now(); + let slot_count = requests.len(); + if observation_cutoff.is_expired_at(batch_started_at) { + return OutboundBatch::cutoff(slot_count); + } + + let mut completed = Vec::new(); + let mut pending_slots = Vec::>::new(); + for (index, request) in requests.into_iter().enumerate() { + match Self::prepare_batch(request, batch_started_at, observation_cutoff) { + Ok(prepared) => { + let client = self.clone(); + pending_slots.push( + async move { + let outcome = client.execute(prepared).await; + (index, client.clock.now(), outcome) + } + .boxed_local(), + ); + } + Err(error) => completed.push((index, self.clock.now(), Err(error))), + } + } + + OutboundBatch::from_futures( + slot_count, + batch_started_at, + observation_cutoff, + completed, + pending_slots, + ) + } + } + + fn build_headers(headers: &HeaderMap) -> Result { + let mut worker_headers = Headers::new(); + super::copy_header_values(headers, &mut worker_headers, |name| { + EdgeError::bad_request(format!("header value is not valid UTF-8: {name}")) + })?; + Ok(worker_headers) + } + + fn build_worker_request( + method: &Method, + url: &str, + headers: &HeaderMap, + request_body: Option, + cache_policy: OutboundCachePolicy, + ) -> Result { + let mut init = RequestInit::new(); + init.with_headers(build_headers(headers)?) + .with_method(worker_method(method)) + .with_redirect(RequestRedirect::Manual); + if cache_policy == OutboundCachePolicy::Bypass { + init.with_cache(CacheMode::NoStore); + } + if let Some(worker_body) = request_body { + init.with_body(Some(worker_body)); + } + WorkerRequest::new_with_init(url, &init).map_err(EdgeError::internal) + } + + fn deadline_stream( + mut source: BodyStream, + budget: DispatchBudget, + clock: MonotonicClock, + mut abort_guard: AbortGuard, + ) -> BodyStream { + stream! { + let mut ready_items = 0_u32; + loop { + let remaining = match budget_remaining(budget, &clock) { + Ok(remaining) => remaining, + Err(error) => { + yield Err(error); + return; + } + }; + let next = source.next(); + let timer = Delay::from(remaining); + futures_util::pin_mut!(next, timer); + let next_item = match select(next, timer).await { + Either::Left((item, _timer)) => item, + Either::Right(((), _next)) => { + yield Err(timeout_error(budget.cause)); + return; + } + }; + if budget_remaining(budget, &clock).is_err() { + yield Err(timeout_error(budget.cause)); + return; + } + match next_item { + Some(Ok(bytes)) => yield Ok(bytes), + Some(Err(error)) => { + yield Err(terminal_error_after_host_event(error, budget, &clock).await); + return; + } + None => { + if let Err(error) = yield_host_event(budget, &clock).await { + yield Err(error); + return; + } + abort_guard.disarm(); + return; + } + } + ready_items = ready_items.saturating_add(1); + if ready_items >= READY_ITEM_YIELD_QUOTA { + let checkpoint = yield_host_event(budget, &clock).await; + ready_items = 0; + if let Err(error) = checkpoint { + yield Err(error); + return; + } + } + } + } + .boxed_local() + } + + #[expect( + clippy::too_many_arguments, + reason = "the adapter consumes the independent request policy fields without hiding them" + )] + async fn process_response( + mut response: WorkerResponse, + mut abort_guard: AbortGuard, + request_method: Method, + response_mode: ResponseMode, + budget: DispatchBudget, + max_decoder_bytes: u64, + max_brotli_window_bits: u8, + max_chunk_bytes: Option, + max_decoded_response_bytes: Option, + max_encoded_response_bytes: Option, + max_response_header_bytes: Option, + max_response_header_count: Option, + clock: MonotonicClock, + ) -> Result { + budget_remaining(budget, &clock)?; + let response_clock = clock.clone(); + let status = StatusCode::from_u16(response.status_code()).map_err(EdgeError::internal)?; + let mut headers = response_headers(&response)?; + let mut header_limiter = + ResponseHeaderLimiter::new(max_response_header_bytes, max_response_header_count); + header_limiter.observe(&headers)?; + let normalization = normalize_response_headers(&request_method, status, &mut headers)?; + let disposition = normalization.body_disposition; + insert_proxy_header( + &mut headers, + &mut header_limiter, + HeaderValue::from_static("cloudflare"), + )?; + + if disposition == ResponseBodyDisposition::FramingBodyless { + return Ok(OutboundResponse::new( + request_method, + status, + headers, + Body::empty(), + response_clock, + ContentTypeProvenance::Unknown, + )); + } + + if let ResponseBodyDisposition::ResetContent { declared_body } = disposition { + match reset_content_action( + declared_body, + matches!(response.body(), WorkerResponseBody::Empty), + ) { + ResetContentAction::Abort => {} + ResetContentAction::Disarm => abort_guard.disarm(), + ResetContentAction::InspectStream => { + let native = response_stream(&mut response)?; + let remaining = budget_remaining(budget, &clock)?; + let next = native.into_future(); + let timer = Delay::from(remaining); + futures_util::pin_mut!(next, timer); + let ready = match select(next, timer).await { + Either::Left((ready, _timer)) => ready, + Either::Right(((), _next)) => return Err(timeout_error(budget.cause)), + }; + budget_remaining(budget, &clock)?; + yield_host_event(budget, &clock).await?; + match ready { + (Some(item), _rest) => { + item?; + } + (None, _rest) => abort_guard.disarm(), + } + } + } + return Ok(OutboundResponse::new( + request_method, + status, + headers, + Body::empty(), + response_clock, + ContentTypeProvenance::Unknown, + )); + } + + let native = response_stream(&mut response)?; + let shaped = apply_response_body_policy( + native, + &mut headers, + ResponseBodyPolicy { + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_decoder_bytes, + max_encoded_response_bytes, + response_mode, + }, + )?; + let deadline_bound = deadline_stream(shaped, budget, clock, abort_guard); + let body = match response_mode { + ResponseMode::Buffered { max_bytes } => { + Body::from(collect_response_stream(deadline_bound, max_bytes).await?) + } + ResponseMode::Streamed => Body::from_stream(deadline_bound), + }; + Ok(OutboundResponse::new( + request_method, + status, + headers, + body, + response_clock, + ContentTypeProvenance::Unknown, + )) + } + + async fn raw_fetch( + request: WorkerRequest, + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result<(WorkerResponse, AbortGuard), EdgeError> { + let controller = web_sys::AbortController::new().map_err(|_error| { + EdgeError::internal(anyhow::anyhow!("failed to construct abort controller")) + })?; + let signal = controller.signal(); + let abort_guard = AbortGuard::new(controller); + let fetch_init = web_sys::RequestInit::new(); + fetch_init.set_signal(Some(&signal)); + let set = Reflect::set( + fetch_init.as_ref(), + &JsValue::from_str("encodeResponseBody"), + &JsValue::from_str("manual"), + ) + .map_err(|_error| { + EdgeError::internal(anyhow::anyhow!("failed to set manual response encoding")) + })?; + if !set { + return Err(EdgeError::internal(anyhow::anyhow!( + "runtime refused manual response encoding" + ))); + } + + let global: web_sys::WorkerGlobalScope = global().unchecked_into(); + let (promise, remaining) = begin_fetch(budget, clock, || { + global.fetch_with_request_and_init(request.inner(), &fetch_init) + })?; + let fetch = JsFuture::from(promise); + let timer = Delay::from(remaining); + futures_util::pin_mut!(fetch, timer); + let result = match select(fetch, timer).await { + Either::Left((result, _timer)) => result, + Either::Right(((), _fetch)) => return Err(timeout_error(budget.cause)), + }; + budget_remaining(budget, clock)?; + let value = result.map_err(|_error| super::generic_fetch_failure())?; + let web_response: web_sys::Response = value.dyn_into().map_err(|_non_response| { + EdgeError::internal(anyhow::anyhow!("fetch returned a non-response value")) + })?; + Ok((WorkerResponse::from(web_response), abort_guard)) + } + + fn begin_fetch( + budget: DispatchBudget, + clock: &MonotonicClock, + dispatch: impl FnOnce() -> Output, + ) -> Result<(Output, Duration), EdgeError> { + let remaining = budget_remaining(budget, clock)?; + Ok((dispatch(), remaining)) + } + + async fn request_body( + body: Body, + maximum: u64, + budget: DispatchBudget, + clock: MonotonicClock, + ) -> Result, EdgeError> { + match body { + Body::Once(bytes) => { + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + if length > maximum { + return Err(EdgeError::bad_request( + "outbound request body exceeded configured limit", + )); + } + budget_remaining(budget, &clock)?; + if bytes.is_empty() { + Ok(None) + } else { + Ok(Some(Uint8Array::from(bytes.as_ref()).into())) + } + } + Body::Stream(source) => { + let mut bounded = upload_stream(source, maximum, budget, clock); + let mut collected = Vec::new(); + while let Some(item) = bounded.next().await { + collected.extend_from_slice(&item?); + } + if collected.is_empty() { + Ok(None) + } else { + Ok(Some(Uint8Array::from(collected.as_slice()).into())) + } + } + } + } + + #[cfg(feature = "test-utils")] + #[doc(hidden)] + #[inline] + pub async fn response_abort_lifecycle_holds_for_test() -> bool { + async fn run_case( + status: StatusCode, + content_length: Option<&str>, + start: MonotonicInstant, + ) -> Option { + let headers = Headers::new(); + if let Some(value) = content_length { + headers.set("content-length", value).ok()?; + } + let response = WorkerResponse::empty() + .ok()? + .with_status(status.as_u16()) + .with_headers(headers); + let controller = web_sys::AbortController::new().ok()?; + let signal = controller.signal(); + let deadline = start.checked_add(Duration::from_secs(1))?; + let clock = MonotonicClock::new(move || start); + process_response( + response, + AbortGuard::new(controller), + Method::GET, + ResponseMode::Buffered { max_bytes: 64 }, + DispatchBudget { + cause: BudgetSource::PerCallTimeout, + deadline: Deadline::at_instant(deadline), + duration: Duration::from_secs(1), + }, + 1024, + 24, + None, + None, + None, + None, + None, + clock, + ) + .await + .ok()?; + Some(signal.aborted()) + } + + let start = MonotonicInstant::now(); + matches!( + run_case(StatusCode::RESET_CONTENT, None, start).await, + Some(false) + ) && matches!( + run_case(StatusCode::RESET_CONTENT, Some("7"), start).await, + Some(true) + ) && matches!( + run_case(StatusCode::NO_CONTENT, None, start).await, + Some(true) + ) + } + + fn response_headers(response: &WorkerResponse) -> Result { + let mut headers = HeaderMap::new(); + for (raw_name, raw_value) in response.headers().entries() { + let parsed_name = HeaderName::from_bytes(raw_name.as_bytes()).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "upstream response contains an invalid header name", + BadGatewayReason::Protocol, + ) + })?; + let parsed_value = HeaderValue::from_bytes(raw_value.as_bytes()).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "upstream response contains an invalid header value", + BadGatewayReason::Protocol, + ) + })?; + headers.append(parsed_name, parsed_value); + } + Ok(headers) + } + + fn response_stream(response: &mut WorkerResponse) -> Result { + let source = response.stream().map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "upstream response body is unavailable", + BadGatewayReason::Transport, + ) + })?; + Ok(source + .map(|result| { + result.map(Bytes::from).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "upstream response body failed", + BadGatewayReason::Transport, + ) + }) + }) + .boxed_local()) + } + + fn budget_remaining( + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result { + budget + .deadline + .remaining_at(clock.now()) + .map(|remaining| remaining.min(budget.duration)) + .ok_or_else(|| timeout_error(budget.cause)) + } + + async fn yield_host_event( + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result<(), EdgeError> { + HostEventYield::new().await?; + budget_remaining(budget, clock).map(drop) + } + + async fn terminal_error_after_host_event( + error: EdgeError, + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> EdgeError { + yield_host_event(budget, clock).await.err().unwrap_or(error) + } + + fn upload_stream( + mut source: BodyStream, + maximum: u64, + budget: DispatchBudget, + clock: MonotonicClock, + ) -> BodyStream { + stream! { + let mut ready_items = 0_u32; + let mut total = 0_u64; + loop { + let remaining = match budget_remaining(budget, &clock) { + Ok(remaining) => remaining, + Err(error) => { + yield Err(error); + return; + } + }; + let next = source.next(); + let timer = Delay::from(remaining); + futures_util::pin_mut!(next, timer); + let next_item = match select(next, timer).await { + Either::Left((item, _timer)) => item, + Either::Right(((), _next)) => { + yield Err(timeout_error(budget.cause)); + return; + } + }; + if budget_remaining(budget, &clock).is_err() { + yield Err(timeout_error(budget.cause)); + return; + } + let Some(item) = next_item else { + if let Err(error) = yield_host_event(budget, &clock).await { + yield Err(error); + } + return; + }; + let bytes = match item { + Ok(bytes) => bytes, + Err(error) => { + yield Err(terminal_error_after_host_event(error, budget, &clock).await); + return; + } + }; + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + let Some(next_total) = total.checked_add(length) else { + let error = EdgeError::bad_request( + "outbound request body size accounting overflow", + ); + yield Err(terminal_error_after_host_event(error, budget, &clock).await); + return; + }; + if next_total > maximum { + let error = EdgeError::bad_request( + "outbound request body exceeded configured limit", + ); + yield Err(terminal_error_after_host_event(error, budget, &clock).await); + return; + } + total = next_total; + yield Ok(bytes); + ready_items = ready_items.saturating_add(1); + if ready_items >= READY_ITEM_YIELD_QUOTA { + let checkpoint = yield_host_event(budget, &clock).await; + ready_items = 0; + if let Err(error) = checkpoint { + yield Err(error); + return; + } + } + } + } + .boxed_local() + } + + fn worker_method(method: &Method) -> WorkerMethod { + match *method { + Method::DELETE => WorkerMethod::Delete, + Method::HEAD => WorkerMethod::Head, + Method::OPTIONS => WorkerMethod::Options, + Method::PATCH => WorkerMethod::Patch, + Method::POST => WorkerMethod::Post, + Method::PUT => WorkerMethod::Put, + _ => WorkerMethod::Get, + } + } + + /// Runs deferred upload and response stream clock checks in the hosted contract binary. + #[cfg(feature = "test-utils")] + #[doc(hidden)] + #[inline] + pub async fn deferred_clock_paths_hold_for_test() -> bool { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use futures_util::stream::once; + + fn expiring_clock(start: MonotonicInstant, deadline: MonotonicInstant) -> MonotonicClock { + let observations = Arc::new(AtomicUsize::new(0)); + MonotonicClock::new(move || { + if observations.fetch_add(1, Ordering::SeqCst) == 0 { + start + } else { + deadline + } + }) + } + + let start = MonotonicInstant::now(); + let Ok(request) = OutboundRequest::get("https://example.com/") else { + return false; + }; + let Ok(budget) = dispatch_budget(&request.timeout(Duration::from_millis(10)), start, None) + else { + return false; + }; + let upload_source = once(async { Ok(Bytes::from_static(b"body")) }).boxed_local(); + let mut upload = upload_stream( + upload_source, + 16, + budget, + expiring_clock(start, budget.deadline.instant()), + ); + let upload_holds = matches!( + upload.next().await, + Some(Err(EdgeError::GatewayTimeout { .. })) + ); + + let Ok(controller) = web_sys::AbortController::new() else { + return false; + }; + let response_source = once(async { Ok(Bytes::from_static(b"body")) }).boxed_local(); + let mut response = deadline_stream( + response_source, + budget, + expiring_clock(start, budget.deadline.instant()), + AbortGuard::new(controller), + ); + let response_holds = matches!( + response.next().await, + Some(Err(EdgeError::GatewayTimeout { .. })) + ); + + upload_holds && response_holds + } + + #[cfg(test)] + mod clock_tests { + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + use std::task::Poll; + + use edgezero_core::error::BudgetSource; + use edgezero_core::http::header::CONTENT_TYPE; + use edgezero_core::outbound::ContentTypeProvenance; + use edgezero_core::time::Deadline; + use futures_util::stream; + use wasm_bindgen_test::wasm_bindgen_test; + + use super::*; + + #[derive(Debug)] + struct NumericTimeoutGuard { + original: JsValue, + } + + impl NumericTimeoutGuard { + fn install() -> Self { + let scope = global(); + let key = JsValue::from_str("setTimeout"); + let original = Reflect::get(&scope, &key).expect("original timeout function"); + // Node timer handles coerce to numeric IDs accepted by both timer APIs. + let factory = Function::new_with_args( + "original", + "return (...args) => Number(original.apply(globalThis, args));", + ); + let numeric = factory + .call1(&JsValue::UNDEFINED, &original) + .expect("numeric timeout wrapper"); + assert!( + Reflect::set(&scope, &key, &numeric).expect("install numeric timeout wrapper") + ); + Self { original } + } + } + + impl Drop for NumericTimeoutGuard { + fn drop(&mut self) { + drop(Reflect::set( + &global(), + &JsValue::from_str("setTimeout"), + &self.original, + )); + } + } + + fn scripted_clock(script: Vec) -> MonotonicClock { + let observations = Arc::new(Mutex::new(VecDeque::from(script))); + MonotonicClock::new(move || { + observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + }) + } + + fn constant_clock(now: MonotonicInstant) -> MonotonicClock { + MonotonicClock::new(move || now) + } + + fn empty_worker_response( + status: StatusCode, + content_length: Option<&str>, + ) -> WorkerResponse { + let headers = Headers::new(); + if let Some(value) = content_length { + headers + .set("content-length", value) + .expect("content-length"); + } + WorkerResponse::empty() + .expect("empty response") + .with_status(status.as_u16()) + .with_headers(headers) + } + + fn test_budget(start: MonotonicInstant, duration: Duration) -> DispatchBudget { + DispatchBudget { + cause: BudgetSource::PerCallTimeout, + deadline: Deadline::at_instant(start.checked_add(duration).expect("deadline")), + duration, + } + } + + #[wasm_bindgen_test] + fn raw_fetch_timer_arms_from_final_remaining_budget() { + let start = MonotonicInstant::now(); + let observed = start + .checked_add(Duration::from_millis(3)) + .expect("observed instant"); + let budget = test_budget(start, Duration::from_millis(10)); + let sampled = Arc::new(Mutex::new(false)); + let sampled_by_clock = Arc::clone(&sampled); + let clock = MonotonicClock::new(move || { + *sampled_by_clock.lock().expect("sampled lock") = true; + observed + }); + + let (value, remaining) = begin_fetch(budget, &clock, || { + assert!( + *sampled.lock().expect("sampled lock"), + "budget must be sampled before dispatch" + ); + 42_u8 + }) + .expect("live budget"); + + assert_eq!(value, 42); + assert_eq!(remaining, Duration::from_millis(7)); + } + + #[wasm_bindgen_test] + async fn method_entry_and_preflight_elapsed_use_the_injected_clock() { + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(9)) + .expect("completed instant"); + let client = + CloudflareOutboundClient::with_clock(scripted_clock(vec![start, completed])); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = client + .start_batch_until( + vec![request], + Deadline::at_instant( + start + .checked_add(Duration::from_secs(1)) + .expect("cutoff instant"), + ), + ) + .collect() + .await + .expect("valid batch driver"); + + let slot = results.slots[0].as_ref().expect("resolved slot"); + assert_eq!(slot.elapsed, Duration::from_millis(9)); + assert!(matches!(slot.outcome, Err(EdgeError::BadRequest { .. }))); + } + + #[wasm_bindgen_test] + async fn backwards_clock_fails_slot_without_invalid_elapsed() { + let start = MonotonicInstant::now(); + let earlier = start + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let client = CloudflareOutboundClient::with_clock(scripted_clock(vec![start, earlier])); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = client + .start_batch_until( + vec![request], + Deadline::at_instant( + start + .checked_add(Duration::from_secs(1)) + .expect("cutoff instant"), + ), + ) + .collect() + .await + .expect("valid batch driver"); + + let slot = results.slots[0].as_ref().expect("resolved slot"); + assert_eq!(slot.elapsed, Duration::ZERO); + assert!(matches!(slot.outcome, Err(EdgeError::Internal { .. }))); + } + + #[wasm_bindgen_test] + fn backwards_clock_cannot_expand_the_selected_budget() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let earlier = start + .checked_sub(Duration::from_millis(5)) + .expect("earlier instant"); + let clock = scripted_clock(vec![earlier]); + + assert_eq!( + budget_remaining(budget, &clock).expect("remaining budget"), + budget.duration + ); + } + + #[wasm_bindgen_test] + async fn buffered_request_preparation_reduces_the_remaining_injected_budget() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let observed = start + .checked_add(Duration::from_millis(3)) + .expect("observed instant"); + let clock = scripted_clock(vec![observed, observed]); + let body = request_body(Body::from("body"), 16, budget, clock.clone()) + .await + .expect("prepared body"); + let remaining = budget_remaining(budget, &clock).expect("remaining budget"); + + assert!(body.is_some()); + assert_eq!(remaining, Duration::from_millis(7)); + } + + #[wasm_bindgen_test] + async fn streamed_request_body_is_drained_before_fetch_construction() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&polls); + let source = stream::poll_fn(move |_context| { + let poll = observed_polls.fetch_add(1, Ordering::SeqCst); + if poll == 0 { + Poll::Ready(Some(Bytes::from_static(b"body"))) + } else { + Poll::Ready(None) + } + }) + .boxed_local(); + + let body = request_body(Body::stream(source), 16, budget, constant_clock(start)) + .await + .expect("prepared body"); + + assert!(body.is_some()); + assert_eq!(polls.load(Ordering::SeqCst), 2); + } + + #[wasm_bindgen_test] + async fn streamed_upload_checks_injected_clock_after_ready_item() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, budget.deadline.instant()]); + let source = stream::once(async { Ok(Bytes::from_static(b"body")) }).boxed_local(); + let mut body = upload_stream(source, 16, budget, clock); + + let error = body + .next() + .await + .expect("terminal item") + .expect_err("post-ready expiry"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[wasm_bindgen_test] + async fn streamed_upload_cap_error_yields_before_final_precedence() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, start, budget.deadline.instant()]); + let source = stream::once(async { Ok(Bytes::from_static(b"too large")) }).boxed_local(); + let mut body = upload_stream(source, 1, budget, clock); + + let error = body + .next() + .await + .expect("terminal item") + .expect_err("host-event deadline wins over cap error"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[wasm_bindgen_test] + async fn streamed_upload_eof_yields_before_final_precedence() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, start, budget.deadline.instant()]); + let mut body = upload_stream(stream::empty().boxed_local(), 1, budget, clock); + + let error = body + .next() + .await + .expect("deadline after host-event yield") + .expect_err("host-event deadline wins over EOF"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[wasm_bindgen_test] + fn streamed_upload_fairness_quota_is_at_most_sixty_four() { + const { assert!(READY_ITEM_YIELD_QUOTA <= 64) }; + } + + #[wasm_bindgen_test] + async fn streamed_upload_source_error_yields_before_final_precedence() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, start, budget.deadline.instant()]); + let source = + stream::once(async { Err(EdgeError::bad_gateway("source failed")) }).boxed_local(); + let mut body = upload_stream(source, 1, budget, clock); + + let error = body + .next() + .await + .expect("terminal item") + .expect_err("host-event deadline wins over source error"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[wasm_bindgen_test] + async fn response_stream_retains_clock_for_post_ready_expiry() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, budget.deadline.instant()]); + let source = stream::once(async { Ok(Bytes::from_static(b"body")) }).boxed_local(); + let controller = web_sys::AbortController::new().expect("abort controller"); + let mut body = deadline_stream(source, budget, clock, AbortGuard::new(controller)); + + let error = body + .next() + .await + .expect("terminal item") + .expect_err("post-ready expiry"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[wasm_bindgen_test] + async fn converted_response_retains_injected_clock() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_secs(1)); + let observed = Arc::new(Mutex::new(start)); + let clock_observed = Arc::clone(&observed); + let clock = + MonotonicClock::new(move || *clock_observed.lock().expect("clock observation")); + let controller = web_sys::AbortController::new().expect("abort controller"); + let response = process_response( + empty_worker_response(StatusCode::NO_CONTENT, None), + AbortGuard::new(controller), + Method::GET, + ResponseMode::Streamed, + budget, + 32 * 1024 * 1024, + 24, + None, + None, + None, + None, + None, + clock, + ) + .await + .expect("converted response"); + *observed.lock().expect("clock observation") = budget.deadline.instant(); + + let error = response + .into_bytes_bounded_until(1, budget.deadline) + .await + .expect_err("retained clock reached deadline"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[wasm_bindgen_test] + async fn converted_response_content_type_provenance_is_always_unknown() { + let _timer_guard = NumericTimeoutGuard::install(); + let cases: &[&[&str]] = &[ + &[], + &["text/plain"], + &["text/plain", "text/plain"], + &["text/plain", "application/json"], + &["text/plain, application/json"], + ]; + for values in cases { + for status in [ + StatusCode::OK, + StatusCode::NO_CONTENT, + StatusCode::RESET_CONTENT, + ] { + for mode in [ + ResponseMode::Streamed, + ResponseMode::Buffered { max_bytes: 64 }, + ] { + let start = MonotonicInstant::now(); + let headers = Headers::new(); + for value in *values { + headers + .append(CONTENT_TYPE.as_str(), value) + .expect("content-type field"); + } + let native = WorkerResponse::from_stream(stream::empty::< + Result, worker::Error>, + >()) + .expect("streaming response") + .with_status(status.as_u16()) + .with_headers(headers); + let bridged = response_headers(&native).expect("bridged headers"); + assert_eq!( + bridged.get_all(CONTENT_TYPE).iter().count(), + usize::from(!values.is_empty()) + ); + if values.len() > 1 { + assert_eq!( + bridged.get(CONTENT_TYPE).map(HeaderValue::as_bytes), + Some(values.join(", ").as_bytes()) + ); + } + let controller = web_sys::AbortController::new().expect("abort controller"); + let mut response = process_response( + native, + AbortGuard::new(controller), + Method::GET, + mode, + test_budget(start, Duration::from_secs(1)), + 1024, + 24, + None, + None, + None, + None, + None, + constant_clock(start), + ) + .await + .expect("converted response"); + + assert_eq!( + response.content_type_provenance(), + ContentTypeProvenance::Unknown + ); + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + assert_eq!( + response.content_type_provenance(), + ContentTypeProvenance::Unknown + ); + } + } + } + } + + #[wasm_bindgen_test] + async fn null_reset_content_disarms_only_without_a_declared_body() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_secs(1)); + for (content_length, expected_aborted) in + [(None, false), (Some("0"), false), (Some("7"), true)] + { + let controller = web_sys::AbortController::new().expect("abort controller"); + let signal = controller.signal(); + let response = process_response( + empty_worker_response(StatusCode::RESET_CONTENT, content_length), + AbortGuard::new(controller), + Method::GET, + ResponseMode::Buffered { max_bytes: 64 }, + budget, + 1024, + 24, + None, + None, + None, + None, + None, + constant_clock(start), + ) + .await + .expect("empty reset response"); + + assert_eq!(response.status(), StatusCode::RESET_CONTENT); + assert_eq!(response.body().as_bytes(), Some(&[][..])); + assert_eq!(signal.aborted(), expected_aborted); + } + } + + #[wasm_bindgen_test] + async fn framing_bodyless_response_aborts_its_unread_native_body() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_secs(1)); + let controller = web_sys::AbortController::new().expect("abort controller"); + let signal = controller.signal(); + + let response = process_response( + empty_worker_response(StatusCode::NO_CONTENT, None), + AbortGuard::new(controller), + Method::GET, + ResponseMode::Buffered { max_bytes: 64 }, + budget, + 1024, + 24, + None, + None, + None, + None, + None, + constant_clock(start), + ) + .await + .expect("bodyless response"); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!(signal.aborted()); + } + } +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub use worker_impl::CloudflareOutboundClient; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32", feature = "test-utils"))] +pub use worker_impl::deferred_clock_paths_hold_for_test; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32", feature = "test-utils"))] +pub use worker_impl::response_abort_lifecycle_holds_for_test; + +#[cfg(any( + all(feature = "cloudflare", target_arch = "wasm32"), + feature = "test-utils" +))] +fn generic_fetch_failure() -> EdgeError { + // Workers exposes a rejected Fetch promise as an opaque JS exception, without a stable phase + // or connection-failure discriminator. Preserve that limitation as `Unspecified` instead of + // guessing that every rejection happened before a response was reachable. + EdgeError::bad_gateway_with_reason("outbound fetch failed", BadGatewayReason::Unspecified) +} + +#[cfg(any( + all(feature = "cloudflare", target_arch = "wasm32"), + feature = "test-utils" +))] +fn timeout_error(cause: BudgetSource) -> EdgeError { + EdgeError::gateway_timeout_caused("outbound request deadline expired", cause) +} + +#[cfg(any( + all(feature = "cloudflare", target_arch = "wasm32"), + feature = "test-utils" +))] +fn validate_batch_request(request: &OutboundRequest) -> Result<(), EdgeError> { + validate_for_dispatch(request)?; + if request.is_stream_body() { + return Err(EdgeError::bad_request( + "outbound batches require buffered request bodies; use send for a streamed upload", + )); + } + if request.is_stream_response() { + return Err(EdgeError::bad_request( + "outbound batches require buffered responses; use send for a streamed response", + )); + } + Ok(()) +} + +/// Runs the target-neutral Cloudflare batch preflight contract in native tests. +/// +/// # Errors +/// Returns the same portable validation or batch-shape error as production dispatch. +#[cfg(feature = "test-utils")] +#[inline] +pub fn validate_batch_request_for_test(request: &OutboundRequest) -> Result<(), EdgeError> { + validate_batch_request(request) +} + +/// Returns the target-neutral classification for an opaque Workers fetch rejection. +#[cfg(feature = "test-utils")] +#[must_use] +#[inline] +pub fn generic_fetch_failure_for_test() -> EdgeError { + generic_fetch_failure() +} + +/// Returns the same attributed timeout emitted by Workers budget timers. +#[cfg(feature = "test-utils")] +#[must_use] +#[inline] +pub fn timeout_error_for_test(cause: BudgetSource) -> EdgeError { + timeout_error(cause) +} + +#[cfg(test)] +mod header_bridge_tests { + use std::collections::BTreeMap; + + use edgezero_core::http::{HeaderMap, HeaderValue}; + + use super::*; + + #[derive(Default)] + struct AppendSink(BTreeMap>); + + impl HeaderSink for AppendSink { + fn append(&mut self, name: &str, value: &str) -> Result<(), EdgeError> { + self.0 + .entry(name.to_owned()) + .or_default() + .push(value.to_owned()); + Ok(()) + } + } + + #[test] + fn response_header_copy_preserves_duplicate_set_cookie_values() { + let mut source = HeaderMap::new(); + source.append("set-cookie", HeaderValue::from_static("first=1")); + source.append("set-cookie", HeaderValue::from_static("second=2")); + let mut sink = AppendSink::default(); + + copy_header_values(&source, &mut sink, |_name| { + EdgeError::internal(anyhow::anyhow!("invalid response header")) + }) + .expect("copy response headers"); + + assert_eq!( + sink.0.get("set-cookie"), + Some(&vec!["first=1".to_owned(), "second=2".to_owned()]) + ); + } + + #[test] + fn response_header_copy_accepts_non_ascii_utf8() { + let mut source = HeaderMap::new(); + source.append( + "x-label", + HeaderValue::from_bytes("caf\u{e9}".as_bytes()).expect("valid header bytes"), + ); + let mut sink = AppendSink::default(); + + copy_header_values(&source, &mut sink, |_name| { + EdgeError::internal(anyhow::anyhow!("invalid response header")) + }) + .expect("copy UTF-8 response header"); + + assert_eq!(sink.0.get("x-label"), Some(&vec!["caf\u{e9}".to_owned()])); + } + + #[test] + fn reset_content_null_body_selects_abort_or_disarm_without_streaming() { + assert_eq!( + reset_content_action(false, true), + ResetContentAction::Disarm + ); + assert_eq!(reset_content_action(true, true), ResetContentAction::Abort); + assert_eq!( + reset_content_action(false, false), + ResetContentAction::InspectStream + ); + assert_eq!(reset_content_action(true, false), ResetContentAction::Abort); + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/proxy.rs b/crates/edgezero-adapter-cloudflare/src/proxy.rs deleted file mode 100644 index 211d00e42..000000000 --- a/crates/edgezero-adapter-cloudflare/src/proxy.rs +++ /dev/null @@ -1,204 +0,0 @@ -use async_trait::async_trait; -use bytes::Bytes; -use edgezero_core::body::Body; -use edgezero_core::compression::{decode_brotli_stream, decode_gzip_stream}; -use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, header}; -use edgezero_core::proxy::{PROXY_HEADER, ProxyClient, ProxyRequest, ProxyResponse}; -use futures_util::TryStreamExt as _; -use futures_util::stream::{self, LocalBoxStream, StreamExt as _}; -use std::io; -use worker::{ - Body as WorkerBody, Fetch, Headers, Method as CfMethod, Request as CfRequest, RequestInit, - Response as CfResponse, wasm_bindgen::JsValue, -}; - -type ChunkStream = LocalBoxStream<'static, Result, io::Error>>; - -pub struct CloudflareProxyClient; - -#[async_trait(?Send)] -impl ProxyClient for CloudflareProxyClient { - #[inline] - async fn send(&self, request: ProxyRequest) -> Result { - let (method, uri, headers, body, _ext) = request.into_parts(); - let cf_request = build_cf_request(&method, &uri, &headers, body)?; - let mut cf_response = Fetch::Request(cf_request) - .send() - .await - .map_err(EdgeError::internal)?; - - let mut proxy_response = convert_response(&mut cf_response)?; - proxy_response - .headers_mut() - .insert(PROXY_HEADER, HeaderValue::from_static("cloudflare")); - Ok(proxy_response) - } -} - -fn build_cf_request( - method: &Method, - uri: &Uri, - headers: &HeaderMap, - body: Body, -) -> Result { - let mut init = RequestInit::new(); - init.with_method(http_method_to_cf(method)); - - let cf_headers = Headers::from(headers); - init.with_headers(cf_headers); - - attach_body(&mut init, body)?; - - let request = CfRequest::new_with_init(&uri.to_string(), &init).map_err(EdgeError::internal)?; - Ok(request) -} - -fn attach_body(init: &mut RequestInit, body: Body) -> Result<(), EdgeError> { - match body { - Body::Once(bytes) => { - if bytes.is_empty() { - return Ok(()); - } - let chunk = bytes.to_vec(); - let stream = stream::once(async move { Ok::, JsValue>(chunk) }).boxed_local(); - let worker_body = WorkerBody::from_stream(stream).map_err(EdgeError::internal)?; - if let Some(readable) = worker_body.into_inner() { - init.with_body(Some(JsValue::from(readable))); - } - } - Body::Stream(stream) => { - let mapped = stream - .map(|res| match res { - Ok(bytes) => Ok::, JsValue>(bytes.to_vec()), - Err(err) => Err(JsValue::from_str(&err.to_string())), - }) - .boxed_local(); - let worker_body = WorkerBody::from_stream(mapped).map_err(EdgeError::internal)?; - if let Some(readable) = worker_body.into_inner() { - init.with_body(Some(JsValue::from(readable))); - } - } - } - - Ok(()) -} - -fn convert_response(cf_response: &mut CfResponse) -> Result { - let status = StatusCode::from_u16(cf_response.status_code()).map_err(EdgeError::internal)?; - let mut proxy_response = ProxyResponse::new(status, Body::empty()); - - let mut encoding = None; - for (name, value) in cf_response.headers().entries() { - if name.eq_ignore_ascii_case(header::CONTENT_ENCODING.as_str()) { - encoding = Some(value.to_ascii_lowercase()); - } - if let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) - && let Ok(header_value) = HeaderValue::from_str(&value) - { - proxy_response - .headers_mut() - .insert(header_name, header_value); - } - } - - let worker_stream = cf_response.stream().map_err(EdgeError::internal)?; - - let chunk_stream: ChunkStream = worker_stream - .map_err(|err| worker_error_to_io(&err)) - .boxed_local(); - let body_stream = transform_stream(chunk_stream, encoding.as_deref()); - *proxy_response.body_mut() = Body::from_stream(body_stream); - - if encoding.is_some() { - proxy_response - .headers_mut() - .remove(header::CONTENT_ENCODING); - proxy_response.headers_mut().remove(header::CONTENT_LENGTH); - } - - Ok(proxy_response) -} - -fn http_method_to_cf(method: &Method) -> CfMethod { - match *method { - Method::POST => CfMethod::Post, - Method::PUT => CfMethod::Put, - Method::PATCH => CfMethod::Patch, - Method::DELETE => CfMethod::Delete, - Method::HEAD => CfMethod::Head, - Method::OPTIONS => CfMethod::Options, - Method::CONNECT => CfMethod::Connect, - Method::TRACE => CfMethod::Trace, - _ => CfMethod::Get, - } -} - -fn transform_stream( - stream: ChunkStream, - encoding: Option<&str>, -) -> LocalBoxStream<'static, Result> { - match encoding { - Some("gzip") => decode_gzip_stream(stream).boxed_local(), - Some("br") => decode_brotli_stream(stream).boxed_local(), - _ => stream.map(|res| res.map(Bytes::from)).boxed_local(), - } -} - -fn worker_error_to_io(err: &worker::Error) -> io::Error { - io::Error::other(err.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use brotli::CompressorWriter; - use flate2::{Compression, write::GzEncoder}; - use futures::executor::block_on; - use futures_util::stream; - use std::io::Write as _; - - fn collect_body(body: Body) -> Vec { - match body { - Body::Once(bytes) => bytes.to_vec(), - Body::Stream(mut stream) => block_on(async { - let mut out = Vec::new(); - while let Some(item) = stream.next().await { - let chunk = item.expect("chunk"); - out.extend_from_slice(&chunk); - } - out - }), - } - } - - #[test] - fn streaming_identity_preserves_body() { - let chunks = vec![ - Ok::, io::Error>(b"hello".to_vec()), - Ok(b" world".to_vec()), - ]; - let chunk_stream: ChunkStream = Box::pin(stream::iter(chunks)); - let body = Body::from_stream(transform_stream(chunk_stream, None)); - assert_eq!(collect_body(body), b"hello world"); - } - - #[test] - fn streaming_handles_gzip_and_brotli() { - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(b"gzip payload").unwrap(); - let gzip = encoder.finish().unwrap(); - let gzip_stream: ChunkStream = Box::pin(stream::iter(vec![Ok::, io::Error>(gzip)])); - let body = Body::from_stream(transform_stream(gzip_stream, Some("gzip"))); - assert_eq!(collect_body(body), b"gzip payload"); - - let mut brotli_data = Vec::new(); - let mut compressor = CompressorWriter::new(&mut brotli_data, 4096, 5, 21); - compressor.write_all(b"brotli payload").unwrap(); - drop(compressor); - let brotli_stream: ChunkStream = - Box::pin(stream::iter(vec![Ok::, io::Error>(brotli_data)])); - let brotli_body = Body::from_stream(transform_stream(brotli_stream, Some("br"))); - assert_eq!(collect_body(brotli_body), b"brotli payload"); - } -} diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 21c5e1cdc..bb58e0edd 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -1,28 +1,53 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Display; use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; +#[cfg(feature = "test-utils")] +use std::{ + io, + sync::atomic::{AtomicUsize, Ordering}, + task::Poll, +}; +#[cfg(feature = "test-utils")] +use bytes::Bytes; use edgezero_core::app::{App, StoreMetadata}; use edgezero_core::body::Body; -use edgezero_core::config_store::ConfigStoreHandle; +use edgezero_core::config_store::{ConfigStoreHandle, ConfigStoreOpenFailure}; +use edgezero_core::context::RuntimeVariables; use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; use edgezero_core::http::{Method as CoreMethod, Request, Uri, request_builder}; +use edgezero_core::ingress::{ + IngressBeginOutcome, IngressDispatchOutcome, IngressFraming, IngressHeadAccounting, + IngressHeadParts, PreparedIngress, +}; use edgezero_core::key_value_store::KvHandle; -use edgezero_core::proxy::ProxyHandle; +use edgezero_core::outbound::HttpClient; +use edgezero_core::response_egress::ResponseEgressEnvelope; use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, }; +use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; +#[cfg(feature = "test-utils")] +use futures::executor::block_on; +use futures_util::future::{Either, select}; +#[cfg(feature = "test-utils")] +use futures_util::stream::Empty; +#[cfg(feature = "test-utils")] +use futures_util::stream::poll_fn; +use futures_util::stream::{LocalBoxStream, once, unfold}; +use futures_util::{StreamExt as _, TryStreamExt as _}; use worker::{ - Context, Env, Error as WorkerError, Method, Request as CfRequest, Response as CfResponse, + Context, Delay, Env, Error as WorkerError, Method, Request as CfRequest, Response as CfResponse, }; use crate::config_store::CloudflareConfigStore; use crate::context::CloudflareRequestContext; use crate::key_value_store::CloudflareKvStore; -use crate::proxy::CloudflareProxyClient; -use crate::response::from_core_response; +use crate::outbound::CloudflareOutboundClient; +use crate::response::{CloudflareEgressRuntime, from_egress_response}; use crate::secret_store::CloudflareSecretStore; /// Groups the optional per-request store handles injected at dispatch time. @@ -38,6 +63,7 @@ pub(crate) struct Stores { config_store: Option, kv: Option, kv_registry: Option, + runtime_variables: Option, secret_registry: Option, secrets: Option, } @@ -106,8 +132,9 @@ impl<'app> CloudflareService<'app> { env: Env, ctx: Context, ) -> Result { + let request_start = self.app.monotonic_now(); let config_store = match self.config { - ConfigSource::Binding(binding) => open_config_or_warn(&env, &binding), + ConfigSource::Binding(binding) => Some(open_config_handle(&env, &binding)), ConfigSource::Handle(handle) => Some(handle), ConfigSource::None => None, }; @@ -134,6 +161,7 @@ impl<'app> CloudflareService<'app> { secrets, ..Default::default() }, + request_start, ) .await } @@ -175,9 +203,11 @@ impl<'app> CloudflareService<'app> { } /// Open the KV namespace bound as `binding` (per `wrangler.toml`) - /// as a Cloudflare config store and inject its handle. If the - /// binding is absent the dispatcher logs once and proceeds - /// without it. + /// as a Cloudflare config store and inject its handle. If opening + /// fails, the dispatcher logs once and retains a failing handle + /// whose reads return + /// [`ConfigStoreError::Unavailable`](edgezero_core::config_store::ConfigStoreError::Unavailable). + /// Only an unconfigured service has no config-store handle. #[must_use] #[inline] pub fn with_config>(mut self, binding: S) -> Self { @@ -230,9 +260,34 @@ pub(crate) struct RegistryInputs<'env> { pub config_meta: Option, pub env_config: &'env EnvConfig, pub kv_meta: Option, + pub runtime_variables: RuntimeVariables, pub secret_meta: Option, } +#[cfg(feature = "test-utils")] +struct DropSignal(Arc); + +#[cfg(feature = "test-utils")] +impl Drop for DropSignal { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +fn detached_error_envelope( + app: &App, + error: EdgeError, + request_method: CoreMethod, + request_start: MonotonicInstant, +) -> Result { + match app.detached_ingress_error_egress(error, request_method, request_start) { + IngressDispatchOutcome::Response(envelope) => Ok(*envelope), + IngressDispatchOutcome::Aborted | _ => Err(WorkerError::RustError( + "ingress admission aborted".to_owned(), + )), + } +} + /// Convert a Cloudflare Worker request into an `EdgeZero` core request. /// /// # Errors @@ -240,10 +295,24 @@ pub(crate) struct RegistryInputs<'env> { /// and [`EdgeError::internal`] if the body cannot be read or the core /// request cannot be built. #[inline] +#[expect( + clippy::unused_async, + reason = "the public converter retains its established async API while request bodies remain lazy" +)] pub async fn into_core_request( - mut req: CfRequest, + req: CfRequest, env: Env, ctx: Context, +) -> Result { + let request = into_core_request_head(&req, env, ctx, MonotonicClock::default())?; + attach_core_body(req, request, None) +} + +fn into_core_request_head( + req: &CfRequest, + env: Env, + ctx: Context, + outbound_clock: MonotonicClock, ) -> Result { let method = into_core_method(&req.method()); let url = req @@ -260,30 +329,324 @@ pub async fn into_core_request( builder = builder.header(name.as_str(), value); } - let bytes = req.bytes().await.map_err(EdgeError::internal)?; - - let mut request = builder - .body(Body::from(bytes)) - .map_err(EdgeError::internal)?; + let mut request = builder.body(Body::empty()).map_err(EdgeError::internal)?; CloudflareRequestContext::insert(&mut request, env, ctx); request .extensions_mut() - .insert(ProxyHandle::with_client(CloudflareProxyClient)); + .insert(outbound_client(outbound_clock)); Ok(request) } +fn outbound_client(clock: MonotonicClock) -> HttpClient { + HttpClient::with_client(CloudflareOutboundClient::with_clock(clock)) +} + +fn attach_core_body( + req: CfRequest, + mut request: Request, + read_lifetime: Option<(Deadline, MonotonicClock)>, +) -> Result { + let stream = cloudflare_body_stream(req)?; + *request.body_mut() = match read_lifetime { + Some((deadline, monotonic_clock)) => { + cloudflare_deadline_body(stream, deadline, monotonic_clock) + } + None => Body::from_external_stream(stream), + }; + Ok(request) +} + +fn cloudflare_body_stream( + mut req: CfRequest, +) -> Result>, EdgeError> { + if req.inner().body().is_some() { + return Ok(req + .stream() + .map_err(EdgeError::internal)? + .map_ok(bytes::Bytes::from) + .boxed_local()); + } + + // The Workers test runtime and some host-created requests expose an + // ArrayBuffer but no ReadableStream. Keep the fallback read lazy so + // admission still runs before the first body byte is requested. + Ok(once(async move { req.bytes().await.map(bytes::Bytes::from) }).boxed_local()) +} + +fn cloudflare_deadline_body( + source: Source, + deadline: Deadline, + monotonic_clock: MonotonicClock, +) -> Body +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, +{ + let boxed_stream = source.map_err(Into::into).boxed_local(); + let stream = unfold(Some(boxed_stream), move |stream_state| { + let clock = monotonic_clock.clone(); + async move { + let mut body_stream = stream_state?; + if deadline.is_expired_at(clock.now()) { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + } + + Delay::from(Duration::ZERO).await; + + let item = { + let Some(remaining) = deadline.remaining_at(clock.now()) else { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + }; + let timer = Delay::from(remaining); + let next = body_stream.next(); + match select(timer, next).await { + Either::Left(((), _)) => { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + } + Either::Right((item, _)) => item, + } + }; + if deadline.is_expired_at(clock.now()) { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + } + match item { + Some(Ok(bytes)) => Some((Ok(bytes), Some(body_stream))), + Some(Err(error)) => Some((Err(EdgeError::internal(error)), None)), + None => None, + } + } + }); + Body::from_stream(stream) +} + +/// Runs the terminal source-release probe used by the WASM contract suite. +#[cfg(feature = "test-utils")] +#[must_use] +#[inline] +pub fn deadline_body_releases_source_for_test() -> bool { + let dropped = Arc::new(AtomicUsize::new(0)); + let signal = DropSignal(Arc::clone(&dropped)); + let source = poll_fn(move |_cx| { + let _keep_alive = &signal; + Poll::>>::Pending + }); + let start = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || start); + let body = cloudflare_deadline_body(source, Deadline::at_instant(start), clock); + let Some(mut body_stream) = body.into_stream() else { + return false; + }; + let Some(Err(error)) = block_on(body_stream.next()) else { + return false; + }; + matches!(error, EdgeError::RequestTimeout { .. }) && dropped.load(Ordering::SeqCst) == 1 +} + +/// Dispatches an observable source through the production ingress body wrapper. +#[cfg(feature = "test-utils")] +#[doc(hidden)] +#[inline] +pub async fn dispatch_ingress_stream_for_test( + app: &App, + method: CoreMethod, + uri: Uri, + source: Source, +) -> Result +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, +{ + let request_start = app.monotonic_now(); + let mut core_request = request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .map_err(|error| edge_error_to_worker(&EdgeError::internal(error)))?; + core_request + .extensions_mut() + .insert(outbound_client(app.monotonic_clock())); + dispatch_ingress_stream_and( + app, + core_request, + Stores::default(), + request_start, + move || Ok(source), + Ok, + ) + .await +} + +/// Exercises admission ordering with observable source construction and delivery closures. +#[cfg(feature = "test-utils")] +#[doc(hidden)] +#[inline] +pub async fn dispatch_ingress_with_for_test( + app: &App, + method: CoreMethod, + uri: Uri, + make_source: MakeSource, + deliver: Deliver, +) -> Result +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, + MakeSource: FnOnce() -> Result, + Deliver: FnOnce(ResponseEgressEnvelope) -> Result, +{ + let request_start = app.monotonic_now(); + let core_request = request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .map_err(|error| edge_error_to_worker(&EdgeError::internal(error)))?; + dispatch_ingress_stream_and( + app, + core_request, + Stores::default(), + request_start, + make_source, + deliver, + ) + .await +} + +/// Exercises a source-construction failure after admission through the production seam. +#[cfg(feature = "test-utils")] +#[doc(hidden)] +#[inline] +pub async fn dispatch_ingress_source_error_for_test( + app: &App, + method: CoreMethod, + uri: Uri, +) -> Result { + let request_start = app.monotonic_now(); + let core_request = request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .map_err(|error| edge_error_to_worker(&EdgeError::internal(error)))?; + dispatch_ingress_stream_and( + app, + core_request, + Stores::default(), + request_start, + || -> Result>, EdgeError> { + Err(EdgeError::internal(anyhow::anyhow!( + "cloudflare source construction failed" + ))) + }, + Ok, + ) + .await +} + pub(crate) async fn dispatch_with_handles( app: &App, req: CfRequest, env: Env, ctx: Context, stores: Stores, + request_start: MonotonicInstant, ) -> Result { - let core_request = into_core_request(req, env, ctx) - .await - .map_err(|err| edge_error_to_worker(&err))?; - dispatch_core_request(app, core_request, stores).await + let signal = req.inner().signal(); + let head_request = into_core_request_head(&req, env, ctx, app.monotonic_clock()) + .map_err(|error| edge_error_to_worker(&error))?; + let context = CloudflareRequestContext::get(&head_request) + .map(CloudflareRequestContext::context_handle) + .ok_or_else(|| WorkerError::RustError("missing Cloudflare request context".to_owned()))?; + let runtime = CloudflareEgressRuntime::new(signal, context); + dispatch_ingress_stream_and( + app, + head_request, + stores, + request_start, + move || cloudflare_body_stream(req), + move |egress| from_egress_response(egress, runtime), + ) + .await +} + +async fn dispatch_ingress_stream_and( + app: &App, + mut head_request: Request, + stores: Stores, + request_start: MonotonicInstant, + make_source: MakeSource, + deliver: Deliver, +) -> Result +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, + MakeSource: FnOnce() -> Result, + Deliver: FnOnce(ResponseEgressEnvelope) -> Result, +{ + let request_method = head_request.method().clone(); + let head_parts = IngressHeadParts::from_request( + &head_request, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + if let Err(validation_error) = head_parts.validate_normalized(app.ingress_head_limits()) { + let egress = detached_error_envelope(app, validation_error, request_method, request_start)?; + return deliver(egress).map_err(|delivery_error| edge_error_to_worker(&delivery_error)); + } + let prepared = match app.begin_ingress(head_parts, request_start) { + Ok(outcome) => match outcome { + IngressBeginOutcome::Admitted(prepared) => prepared, + IngressBeginOutcome::Refused(response) => { + return deliver(response).map_err(|error| edge_error_to_worker(&error)); + } + IngressBeginOutcome::Aborted => { + return Err(WorkerError::RustError( + "ingress admission aborted".to_owned(), + )); + } + _ => { + let admission_error = + EdgeError::internal(anyhow::anyhow!("unsupported ingress admission outcome")); + let egress = + detached_error_envelope(app, admission_error, request_method, request_start)?; + return deliver(egress) + .map_err(|delivery_error| edge_error_to_worker(&delivery_error)); + } + }, + Err(admission_error) => { + let egress = + detached_error_envelope(app, admission_error, request_method, request_start)?; + return deliver(egress).map_err(|delivery_error| edge_error_to_worker(&delivery_error)); + } + }; + let source = match make_source() { + Ok(source) => source, + Err(source_error) => { + return deliver(app.admitted_error_egress(prepared, source_error)) + .map_err(|delivery_error| edge_error_to_worker(&delivery_error)); + } + }; + *head_request.body_mut() = + cloudflare_deadline_body(source, prepared.read_deadline(), prepared.monotonic_clock()); + dispatch_core_request(app, head_request, stores, prepared, deliver).await } /// Dispatch with per-id store registries built from baked metadata. @@ -302,6 +665,7 @@ pub(crate) async fn dispatch_with_registries( ctx: Context, inputs: RegistryInputs<'_>, ) -> Result { + let request_start = app.monotonic_now(); let kv_registry = build_kv_registry(&env, inputs.kv_meta, inputs.env_config)?; let config_registry = build_config_registry(&env, inputs.config_meta, inputs.env_config); let secret_registry = build_secret_registry(&env, inputs.secret_meta, inputs.env_config); @@ -314,8 +678,10 @@ pub(crate) async fn dispatch_with_registries( config_registry, kv_registry, secret_registry, + runtime_variables: Some(inputs.runtime_variables), ..Default::default() }, + request_start, ) .await } @@ -368,23 +734,15 @@ fn build_config_registry( let mut by_id: BTreeMap = BTreeMap::new(); for id in meta.ids { let binding_name = env_config.store_name("config", id); - if let Some(handle) = open_config_or_warn(env, &binding_name) { - by_id.insert( - (*id).to_owned(), - ConfigStoreBinding { - handle, - default_key: env_config.store_key("config", id), - }, - ); - } - } - let default_id = meta.default.to_owned(); - if !by_id.contains_key(&default_id) { - log::warn!( - "config registry default id `{default_id}` could not be opened; dropping the config registry" + by_id.insert( + (*id).to_owned(), + ConfigStoreBinding { + handle: open_config_handle(env, &binding_name), + default_key: env_config.store_key("config", id), + }, ); } - StoreRegistry::from_parts(by_id, default_id) + StoreRegistry::from_parts(by_id, meta.default.to_owned()) } fn build_kv_registry( @@ -438,15 +796,23 @@ fn build_secret_registry( StoreRegistry::from_parts(by_id, meta.default.to_owned()) } -async fn dispatch_core_request( +async fn dispatch_core_request( app: &App, mut core_request: Request, - stores: Stores, -) -> Result { + mut stores: Stores, + prepared: PreparedIngress, + deliver: Deliver, +) -> Result +where + Deliver: FnOnce(ResponseEgressEnvelope) -> Result, +{ // Hard-cutoff: see fastly's `dispatch_core_request` // for the rationale. Only registries go into extensions — // legacy bare handles are synthesised into a one-id registry // at the dispatch boundary. + if let Some(variables) = stores.runtime_variables.take() { + core_request.extensions_mut().insert(variables); + } let (config_registry, kv_registry, secret_registry) = synthesise_store_registries(stores); if let Some(registry) = config_registry { core_request.extensions_mut().insert(registry); @@ -457,12 +823,8 @@ async fn dispatch_core_request( if let Some(registry) = secret_registry { core_request.extensions_mut().insert(registry); } - let svc = app.router().clone(); - let response = svc - .oneshot(core_request) - .await - .map_err(|err| edge_error_to_worker(&err))?; - from_core_response(response).map_err(|err| edge_error_to_worker(&err)) + let response = app.dispatch_admitted(prepared, core_request).await; + deliver(response).map_err(|err| edge_error_to_worker(&err)) } fn edge_error_to_worker(err: &EdgeError) -> WorkerError { @@ -480,12 +842,12 @@ fn into_core_method(method: &Method) -> CoreMethod { }) } -fn open_config_or_warn(env: &Env, binding_name: &str) -> Option { +fn open_config_handle(env: &Env, binding_name: &str) -> ConfigStoreHandle { match CloudflareConfigStore::from_env(env, binding_name) { - Ok(store) => Some(ConfigStoreHandle::new(Arc::new(store))), + Ok(store) => ConfigStoreHandle::new(Arc::new(store)), Err(err) => { warn_missing_config_binding_once(binding_name, &err.to_string()); - None + ConfigStoreHandle::failed_open(ConfigStoreOpenFailure::Unavailable) } } } @@ -570,8 +932,19 @@ fn warn_missing_kv_binding_once(kv_binding: &str, error: &impl Display) { #[cfg(test)] mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + use super::*; + use edgezero_core::context::RequestContext; + use edgezero_core::http::{Response, StatusCode, response_builder}; + use edgezero_core::outbound::OutboundRequest; + use edgezero_core::router::RouterService; use wasm_bindgen_test::wasm_bindgen_test; + use worker::js_sys::Object; + use worker::wasm_bindgen::JsCast as _; + use worker::worker_sys::Context as WorkerSysContext; #[wasm_bindgen_test] fn into_http_method_defaults_unknown_to_get() { @@ -586,6 +959,73 @@ mod tests { assert_eq!(into_core_method(&Method::Put), CoreMethod::PUT); assert_eq!(into_core_method(&Method::Delete), CoreMethod::DELETE); } + + #[wasm_bindgen_test] + async fn standard_service_installs_the_exact_application_outbound_clock() { + async fn elapsed(ctx: RequestContext) -> Result { + let client = ctx + .http_client() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing HTTP client")))?; + let request = + OutboundRequest::get("https://example.com/")?.body(Body::from("invalid GET body")); + let results = client + .send_all_until(vec![request], Deadline::after(Duration::from_secs(1))) + .await?; + let result = results + .slots + .first() + .and_then(Option::as_ref) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing outbound result")))?; + if !matches!(&result.outcome, Err(EdgeError::BadRequest { .. })) { + return Err(EdgeError::internal(anyhow::anyhow!( + "clock probe did not fail during batch preflight" + ))); + } + response_builder() + .status(StatusCode::NO_CONTENT) + .header( + "x-edgezero-elapsed-ms", + result.elapsed.as_millis().to_string(), + ) + .body(Body::empty()) + .map_err(EdgeError::internal) + } + + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(7)) + .expect("completed instant"); + let observations = Arc::new(AtomicUsize::new(0)); + let clock_observations = Arc::clone(&observations); + let mut app = App::new(RouterService::builder().get("/clock", elapsed).build()); + app.set_monotonic_clock(MonotonicClock::new(move || { + if clock_observations.fetch_add(1, Ordering::SeqCst) < 2 { + start + } else { + completed + } + })); + let init = worker::RequestInit::new(); + let request = CfRequest::new_with_init("https://example.com/clock", &init) + .expect("Cloudflare request"); + let env = Object::new().unchecked_into::(); + let js_context = Object::new().unchecked_into::(); + + let response = CloudflareService::new(&app) + .dispatch(request, env, Context::new(js_context)) + .await + .expect("Cloudflare response"); + + assert_eq!(response.status_code(), StatusCode::NO_CONTENT.as_u16()); + assert_eq!( + response + .headers() + .get("x-edgezero-elapsed-ms") + .expect("elapsed header"), + Some("7".to_owned()) + ); + assert!(observations.load(Ordering::SeqCst) >= 3); + } } #[cfg(test)] @@ -593,7 +1033,10 @@ mod synthesis_tests { use std::collections::BTreeMap; use std::sync::Arc; - use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::config_store::{ + BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigStoreHandle, + finish_bounded_config_read, + }; use edgezero_core::key_value_store::{KvStore, NoopKvStore}; use edgezero_core::secret_store::{NoopSecretStore, SecretHandle}; @@ -602,9 +1045,32 @@ mod synthesis_tests { struct StubConfig; #[async_trait::async_trait(?Send)] impl ConfigStore for StubConfig { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + finish_bounded_config_read( + self.get(key).await, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + } } fn config_handle() -> ConfigStoreHandle { diff --git a/crates/edgezero-adapter-cloudflare/src/response.rs b/crates/edgezero-adapter-cloudflare/src/response.rs index 8e6b575cb..649d84c03 100644 --- a/crates/edgezero-adapter-cloudflare/src/response.rs +++ b/crates/edgezero-adapter-cloudflare/src/response.rs @@ -1,92 +1,1287 @@ -use crate::response_headers::copy_headers; -use edgezero_core::body::Body; +#![cfg_attr( + all(feature = "cloudflare", target_arch = "wasm32"), + expect( + clippy::arbitrary_source_item_ordering, + reason = "the platform module follows runtime, writer, race, and entrypoint lifecycle order" + ) +)] + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::time::Duration; + +use bytes::Bytes; +use edgezero_core::body::{Body, BodyStream}; use edgezero_core::error::EdgeError; -use edgezero_core::http::Response; +use edgezero_core::http::header::CONTENT_ENCODING; +use edgezero_core::http::{HeaderMap, Method, Response, StatusCode}; +use edgezero_core::response_egress::{ + RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET, ResponseEgressAttempt, + ResponseEgressFallbackDisposition, ResponseEgressOutcome, ResponseEgressPolicy, +}; +use edgezero_core::response_egress_framing::{ + PreparedResponseEgress, prepare_response_egress, response_egress_source_error_category, +}; +use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; use futures_util::StreamExt as _; -use worker::{Error as WorkerError, Response as CfResponse}; - -/// Convert an `EdgeZero` `Response` into a Cloudflare Worker `Response`. -/// -/// # Errors -/// Returns an [`EdgeError`] if the response body cannot be materialised -/// into a Workers response (empty body construction failure, byte body -/// conversion failure, stream adoption failure) or the Workers header table -/// rejects a text header. Non-text header values are skipped. -#[inline] -pub fn from_core_response(response: Response) -> Result { - let (parts, body) = response.into_parts(); +use futures_util::future::{FutureExt as _, LocalBoxFuture}; +use futures_util::stream; + +const INTERNAL_FALLBACK_BODY: &[u8] = b"internal server error"; +const TIMEOUT_FALLBACK_BODY: &[u8] = b"response write deadline exceeded"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DeliveryError { + Cancelled, + Conversion, + Deadline, + Source, + Transport, +} + +impl DeliveryError { + const fn from_outcome(outcome: ResponseEgressOutcome) -> Self { + match outcome { + ResponseEgressOutcome::ConversionError => Self::Conversion, + ResponseEgressOutcome::DeadlineExceeded => Self::Deadline, + ResponseEgressOutcome::RequestCancelled => Self::Cancelled, + ResponseEgressOutcome::SourceError => Self::Source, + ResponseEgressOutcome::ClientDisconnected + | ResponseEgressOutcome::Completed + | ResponseEgressOutcome::HostHandoff + | ResponseEgressOutcome::TransportError + | ResponseEgressOutcome::Unspecified + | _ => Self::Transport, + } + } + + const fn outcome(self) -> ResponseEgressOutcome { + match self { + Self::Cancelled => ResponseEgressOutcome::RequestCancelled, + Self::Conversion => ResponseEgressOutcome::ConversionError, + Self::Deadline => ResponseEgressOutcome::DeadlineExceeded, + Self::Source => ResponseEgressOutcome::SourceError, + Self::Transport => ResponseEgressOutcome::TransportError, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EgressKind { + Application, + Fallback, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum IoFailureKind { + Cancelled, + Deadline, + Transport, +} + +impl IoFailureKind { + const fn delivery_error(self) -> DeliveryError { + match self { + Self::Cancelled => DeliveryError::Cancelled, + Self::Deadline => DeliveryError::Deadline, + Self::Transport => DeliveryError::Transport, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct IoFailure { + accepted: usize, + kind: IoFailureKind, +} + +enum StartOutcome { + Precommit { + attempt: Box, + cause: ResponseEgressOutcome, + }, + Started(PlatformResponse), +} + +trait CloudflareEgressCommitter { + type Io: CloudflareEgressIo + 'static; + type Response; + + fn prepare( + &mut self, + status: StatusCode, + headers: &HeaderMap, + declared_length: Option, + transmits_body: bool, + ) -> Result<(Self::Response, Self::Io), DeliveryError>; + + fn spawn(&mut self, task: LocalBoxFuture<'static, ()>); +} - let body_response = match body { - Body::Once(bytes) if bytes.is_empty() => { - CfResponse::empty().map_err(EdgeError::internal)? +#[async_trait::async_trait(?Send)] +trait CloudflareEgressIo { + fn abort(&mut self); + + async fn finish( + &mut self, + deadline: Deadline, + clock: &MonotonicClock, + ) -> Result<(), IoFailureKind>; + + async fn next_body( + &mut self, + source: &mut BodyStream, + deadline: Deadline, + clock: &MonotonicClock, + ) -> Result>, IoFailureKind>; + + fn preflight(&self, deadline: Deadline, clock: &MonotonicClock) -> Result<(), IoFailureKind>; + + async fn write( + &mut self, + bytes: Vec, + deadline: Deadline, + clock: &MonotonicClock, + ) -> Result; +} + +async fn transmit_committed( + body: Body, + policy: ResponseEgressPolicy, + mut attempt: ResponseEgressAttempt, + clock: MonotonicClock, + kind: EgressKind, + mut io: Io, +) -> Result<(), DeliveryError> +where + Io: CloudflareEgressIo, +{ + let result = pump_body(body, policy.write_deadline, &mut attempt, &clock, &mut io).await; + match result { + Ok(()) => { + settle_success(kind, &mut attempt, clock.now()); + Ok(()) } - Body::Once(bytes) => CfResponse::from_bytes(bytes.to_vec()).map_err(EdgeError::internal)?, - Body::Stream(stream) => { - let worker_stream = stream - .map(|res| match res { - Ok(bytes) => Ok::, WorkerError>(bytes.to_vec()), - Err(err) => Err(WorkerError::RustError(err.to_string())), - }) - .boxed_local(); - CfResponse::from_stream(worker_stream).map_err(EdgeError::internal)? + Err(error) => { + abort(&mut io); + let observed_at = terminal_observed_at(error, policy.write_deadline, &clock); + settle_failure(kind, &mut attempt, error.outcome(), observed_at); + Err(error) + } + } +} + +async fn pump_body( + body: Body, + deadline: Deadline, + attempt: &mut ResponseEgressAttempt, + clock: &MonotonicClock, + io: &mut Io, +) -> Result<(), DeliveryError> +where + Io: CloudflareEgressIo, +{ + ensure_deadline(deadline, clock)?; + let mut source = body_source(body); + loop { + let next = io + .next_body(&mut source, deadline, clock) + .await + .map_err(IoFailureKind::delivery_error)?; + ensure_deadline(deadline, clock)?; + let Some(item) = next else { + break; + }; + let chunk = item.map_err(|error| { + log::warn!( + "response-egress Cloudflare body source failed after commit: {}", + response_egress_source_error_category(&error) + ); + DeliveryError::Source + })?; + if chunk.is_empty() { + continue; + } + let requested = chunk.len(); + match io.write(chunk.to_vec(), deadline, clock).await { + Ok(accepted) => { + account_prefix(accepted, requested, attempt, clock.now())?; + if accepted != requested { + return Err(DeliveryError::Transport); + } + } + Err(failure) => { + account_prefix(failure.accepted, requested, attempt, clock.now())?; + return Err(failure.kind.delivery_error()); + } + } + ensure_deadline(deadline, clock)?; + } + io.finish(deadline, clock) + .await + .map_err(IoFailureKind::delivery_error)?; + ensure_deadline(deadline, clock) +} + +fn account_prefix( + accepted: usize, + requested: usize, + attempt: &mut ResponseEgressAttempt, + observed_at: MonotonicInstant, +) -> Result<(), DeliveryError> { + if accepted > requested { + return Err(DeliveryError::Transport); + } + let accepted_u64 = u64::try_from(accepted).map_err(|_error| DeliveryError::Transport)?; + if !attempt.account_bytes(accepted_u64, observed_at) { + return Err(DeliveryError::Transport); + } + Ok(()) +} + +fn abort(io: &mut Io) +where + Io: CloudflareEgressIo, +{ + if catch_unwind(AssertUnwindSafe(|| io.abort())).is_err() { + log::error!("response-egress Cloudflare abort panicked"); + } +} + +fn body_source(body: Body) -> BodyStream { + match body { + Body::Once(bytes) => stream::once(async move { Ok(bytes) }).boxed_local(), + Body::Stream(source) => source, + } +} + +fn ensure_deadline(deadline: Deadline, clock: &MonotonicClock) -> Result<(), DeliveryError> { + if deadline.is_expired_at(clock.now()) { + Err(DeliveryError::Deadline) + } else { + Ok(()) + } +} + +fn terminal_observed_at( + error: DeliveryError, + deadline: Deadline, + clock: &MonotonicClock, +) -> MonotonicInstant { + let observed_at = clock.now(); + if error == DeliveryError::Deadline { + observed_at.max(deadline.instant()) + } else { + observed_at + } +} + +fn settle_failure( + kind: EgressKind, + attempt: &mut ResponseEgressAttempt, + outcome: ResponseEgressOutcome, + observed_at: MonotonicInstant, +) { + match kind { + EgressKind::Application => { + attempt.terminate(outcome, observed_at); + } + EgressKind::Fallback => { + attempt.finish_fallback(ResponseEgressFallbackDisposition::Aborted, observed_at); + } + } +} + +fn settle_success( + kind: EgressKind, + attempt: &mut ResponseEgressAttempt, + observed_at: MonotonicInstant, +) { + match kind { + EgressKind::Application => { + attempt.terminate(ResponseEgressOutcome::HostHandoff, observed_at); + } + EgressKind::Fallback => { + attempt.finish_fallback(ResponseEgressFallbackDisposition::Completed, observed_at); + } + } +} + +fn start_prepared( + prepared: PreparedResponseEgress, + policy: ResponseEgressPolicy, + mut attempt: ResponseEgressAttempt, + clock: MonotonicClock, + kind: EgressKind, + committer: &mut Committer, +) -> StartOutcome +where + Committer: CloudflareEgressCommitter, +{ + if policy.write_deadline.is_expired_at(clock.now()) { + return StartOutcome::Precommit { + attempt: Box::new(attempt), + cause: ResponseEgressOutcome::DeadlineExceeded, + }; + } + let declared_length = prepared.declared_length(); + let transmits_body = prepared.transmits_body(); + let response = prepared.into_response(); + let (parts, body) = response.into_parts(); + let (platform_response, mut io) = match committer.prepare( + parts.status, + &parts.headers, + declared_length, + transmits_body, + ) { + Ok(pair) => pair, + Err(error) => { + return StartOutcome::Precommit { + attempt: Box::new(attempt), + cause: error.outcome(), + }; } }; + if let Err(error) = io.preflight(policy.write_deadline, &clock) { + abort(&mut io); + return StartOutcome::Precommit { + attempt: Box::new(attempt), + cause: error.delivery_error().outcome(), + }; + } + if !attempt.begin_writing() { + abort(&mut io); + return StartOutcome::Precommit { + attempt: Box::new(attempt), + cause: ResponseEgressOutcome::ConversionError, + }; + } + if !transmits_body { + settle_success(kind, &mut attempt, clock.now()); + return StartOutcome::Started(platform_response); + } + let task = AssertUnwindSafe(transmit_committed(body, policy, attempt, clock, kind, io)) + .catch_unwind() + .map(|result| { + if result.is_err() { + log::error!("response-egress Cloudflare coordinator panicked"); + } + }) + .boxed_local(); + committer.spawn(task); + StartOutcome::Started(platform_response) +} - let mut cf_response = body_response.with_status(parts.status.as_u16()); - let headers = cf_response.headers_mut(); - copy_headers(&parts.headers, |name, value, replace| { - if replace { - headers.set(name, value) - } else { - headers.append(name, value) +fn start_fallback( + request_method: &Method, + original_cause: ResponseEgressOutcome, + mut attempt: ResponseEgressAttempt, + clock: &MonotonicClock, + committer: &mut Committer, +) -> Result +where + Committer: CloudflareEgressCommitter, +{ + if !attempt.begin_fallback(original_cause) { + attempt.terminate(original_cause, clock.now()); + return Err(DeliveryError::from_outcome(original_cause)); + } + let started_at = clock.now(); + let fallback_deadline = started_at + .checked_add(RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET) + .unwrap_or(started_at); + let prepared = prepare_response_egress(request_method, fallback_response(original_cause)) + .map_err(|_error| { + attempt.finish_fallback(ResponseEgressFallbackDisposition::Aborted, clock.now()); + DeliveryError::from_outcome(original_cause) + })?; + match start_prepared( + prepared, + ResponseEgressPolicy { + write_deadline: Deadline::at_instant(fallback_deadline), + }, + attempt, + clock.clone(), + EgressKind::Fallback, + committer, + ) { + StartOutcome::Started(response) => Ok(response), + StartOutcome::Precommit { + attempt: mut failed_attempt, + cause: _fallback_cause, + } => { + failed_attempt.finish_fallback(ResponseEgressFallbackDisposition::Aborted, clock.now()); + Err(DeliveryError::from_outcome(original_cause)) } - }) - .map_err(EdgeError::internal)?; - Ok(cf_response) + } +} + +fn fallback_response(cause: ResponseEgressOutcome) -> Response { + let (status, body) = if cause == ResponseEgressOutcome::DeadlineExceeded { + (StatusCode::GATEWAY_TIMEOUT, TIMEOUT_FALLBACK_BODY) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, INTERNAL_FALLBACK_BODY) + }; + let mut response = Response::new(Body::from(body)); + *response.status_mut() = status; + response } +fn manual_response_encoding(headers: &HeaderMap, transmits_body: bool) -> bool { + transmits_body && headers.contains_key(CONTENT_ENCODING) +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +mod platform { + use super::{ + BodyStream, Bytes, CloudflareEgressCommitter, CloudflareEgressIo, Deadline, DeliveryError, + Duration, EdgeError, EgressKind, HeaderMap, IoFailure, IoFailureKind, LocalBoxFuture, + MonotonicClock, StartOutcome, StatusCode, ensure_deadline, manual_response_encoding, + start_fallback, start_prepared, + }; + use std::future::Future; + use std::sync::Arc; + + use edgezero_core::response_egress::ResponseEgressEnvelope; + use futures_util::StreamExt as _; + use futures_util::future::{Either, select}; + use worker::js_sys::{BigInt, Uint8Array}; + use worker::wasm_bindgen_futures::JsFuture; + use worker::worker_sys::FixedLengthStream; + use worker::{AbortSignal, Context, Delay, EncodeBody, Response as CfResponse}; + + use crate::outbound::copy_header_values; + + const ABORT_POLL_INTERVAL: Duration = Duration::from_millis(1); + + const fn public_message(error: DeliveryError) -> &'static str { + match error { + DeliveryError::Cancelled => "request cancelled during response delivery", + DeliveryError::Conversion => "response conversion failed", + DeliveryError::Deadline => "response write deadline exceeded", + DeliveryError::Source => "response body source failed", + DeliveryError::Transport => "response transport failed", + } + } + + pub(crate) struct CloudflareEgressRuntime { + context: Arc, + signal: AbortSignal, + } + + impl CloudflareEgressRuntime { + pub(crate) fn new(signal: web_sys::AbortSignal, context: Arc) -> Self { + Self { + context, + signal: signal.into(), + } + } + } + + struct WorkersCommitter { + context: Arc, + signal: AbortSignal, + } + + impl CloudflareEgressCommitter for WorkersCommitter { + type Io = WorkersEgressIo; + type Response = CfResponse; + + fn prepare( + &mut self, + status: StatusCode, + headers: &HeaderMap, + declared_length: Option, + transmits_body: bool, + ) -> Result<(Self::Response, Self::Io), DeliveryError> { + let mut worker_headers = worker::Headers::new(); + copy_header_values(headers, &mut worker_headers, |_name| { + EdgeError::internal(anyhow::anyhow!( + "response header cannot be represented by Workers" + )) + }) + .map_err(|_error| DeliveryError::Conversion)?; + let mut builder = CfResponse::builder() + .with_status(status.as_u16()) + .with_headers(worker_headers); + if manual_response_encoding(headers, transmits_body) { + builder = builder.with_encode_body(EncodeBody::Manual); + } + if !transmits_body { + return Ok(( + builder.empty(), + WorkersEgressIo { + signal: self.signal.clone(), + writer: None, + }, + )); + } + let (readable, writable) = if let Some(length) = declared_length { + let fixed = if let Ok(length_u32) = u32::try_from(length) { + FixedLengthStream::new(length_u32) + } else { + FixedLengthStream::new_big_int(BigInt::from(length)) + } + .map_err(|_error| DeliveryError::Conversion)?; + (fixed.readable(), fixed.writable()) + } else { + let transform = + web_sys::TransformStream::new().map_err(|_error| DeliveryError::Conversion)?; + (transform.readable(), transform.writable()) + }; + let writer = writable + .get_writer() + .map_err(|_error| DeliveryError::Conversion)?; + Ok(( + builder.stream(readable), + WorkersEgressIo { + signal: self.signal.clone(), + writer: Some(writer), + }, + )) + } + + fn spawn(&mut self, task: LocalBoxFuture<'static, ()>) { + self.context.wait_until(task); + } + } + + struct WorkersEgressIo { + signal: AbortSignal, + writer: Option, + } + + impl Drop for WorkersEgressIo { + fn drop(&mut self) { + self.abort(); + } + } + + enum RaceEvent { + Cancelled, + Deadline, + Operation { + output: Output, + terminal: Option, + }, + } + + async fn wait_for_abort(signal: AbortSignal) { + while !signal.aborted() { + Delay::from(ABORT_POLL_INTERVAL).await; + } + } + + async fn race_operation( + operation: OperationFactory, + deadline: Deadline, + clock: &MonotonicClock, + signal: &AbortSignal, + ) -> RaceEvent + where + OperationFactory: FnOnce() -> Operation, + Operation: Future, + { + if signal.aborted() { + return RaceEvent::Cancelled; + } + let Some(remaining) = deadline.remaining_at(clock.now()) else { + return RaceEvent::Deadline; + }; + let cancellation = wait_for_abort(signal.clone()); + let timer = Delay::from(remaining); + let guard = async move { + futures_util::pin_mut!(cancellation, timer); + match select(cancellation, timer).await { + Either::Left(((), _timer)) => RaceEvent::Cancelled, + Either::Right(((), _cancellation)) => RaceEvent::Deadline, + } + }; + let operation_future = operation(); + futures_util::pin_mut!(guard, operation_future); + match select(guard, operation_future).await { + Either::Left((event, _operation)) => event, + Either::Right((output, _guard)) => { + let terminal = if signal.aborted() { + Some(IoFailureKind::Cancelled) + } else if deadline.is_expired_at(clock.now()) { + Some(IoFailureKind::Deadline) + } else { + None + }; + RaceEvent::Operation { output, terminal } + } + } + } + + #[async_trait::async_trait(?Send)] + impl CloudflareEgressIo for WorkersEgressIo { + fn abort(&mut self) { + if let Some(writer) = self.writer.take() { + drop(writer.abort()); + writer.release_lock(); + } + } + + async fn finish( + &mut self, + deadline: Deadline, + clock: &MonotonicClock, + ) -> Result<(), IoFailureKind> { + let Some(writer) = self.writer.as_ref() else { + return ensure_deadline(deadline, clock).map_err(|_error| IoFailureKind::Deadline); + }; + let event = race_operation( + || JsFuture::from(writer.close()), + deadline, + clock, + &self.signal, + ) + .await; + let result = match event { + RaceEvent::Cancelled => Err(IoFailureKind::Cancelled), + RaceEvent::Deadline => Err(IoFailureKind::Deadline), + RaceEvent::Operation { + terminal: Some(kind), + .. + } => Err(kind), + RaceEvent::Operation { + output: Ok(_value), + terminal: None, + } => Ok(()), + RaceEvent::Operation { + output: Err(_error), + terminal: None, + } => Err(IoFailureKind::Transport), + }; + if result.is_ok() + && let Some(finished_writer) = self.writer.take() + { + finished_writer.release_lock(); + } + result + } + + async fn next_body( + &mut self, + source: &mut BodyStream, + deadline: Deadline, + clock: &MonotonicClock, + ) -> Result>, IoFailureKind> { + Delay::from(Duration::ZERO).await; + match race_operation(|| source.next(), deadline, clock, &self.signal).await { + RaceEvent::Cancelled => Err(IoFailureKind::Cancelled), + RaceEvent::Deadline => Err(IoFailureKind::Deadline), + RaceEvent::Operation { + terminal: Some(kind), + .. + } => Err(kind), + RaceEvent::Operation { + output: item, + terminal: None, + } => Ok(item), + } + } + + fn preflight( + &self, + deadline: Deadline, + clock: &MonotonicClock, + ) -> Result<(), IoFailureKind> { + if self.signal.aborted() { + Err(IoFailureKind::Cancelled) + } else { + ensure_deadline(deadline, clock).map_err(|_error| IoFailureKind::Deadline) + } + } + + async fn write( + &mut self, + bytes: Vec, + deadline: Deadline, + clock: &MonotonicClock, + ) -> Result { + let requested = bytes.len(); + let Some(writer) = self.writer.as_ref() else { + return Err(IoFailure { + accepted: 0, + kind: IoFailureKind::Transport, + }); + }; + let chunk = Uint8Array::from(bytes.as_slice()); + let event = race_operation( + || JsFuture::from(writer.write_with_chunk(&chunk.into())), + deadline, + clock, + &self.signal, + ) + .await; + match event { + RaceEvent::Cancelled => Err(IoFailure { + accepted: 0, + kind: IoFailureKind::Cancelled, + }), + RaceEvent::Deadline => Err(IoFailure { + accepted: 0, + kind: IoFailureKind::Deadline, + }), + RaceEvent::Operation { + output: Ok(_value), + terminal: Some(kind), + } => Err(IoFailure { + accepted: requested, + kind, + }), + RaceEvent::Operation { + output: Ok(_value), + terminal: None, + } => Ok(requested), + RaceEvent::Operation { + output: Err(_error), + terminal, + } => Err(IoFailure { + accepted: 0, + kind: terminal.unwrap_or(IoFailureKind::Transport), + }), + } + } + } + + /// Converts an owned egress envelope into a Workers response backed by one adapter-owned writer. + /// + /// # Errors + /// Returns a category-only error when both the application response and bounded precommit fallback + /// fail before the Workers response is returned. + pub(crate) fn from_egress_response( + egress: ResponseEgressEnvelope, + runtime: CloudflareEgressRuntime, + ) -> Result { + let request_method = egress.request_method().clone(); + let mut committer = WorkersCommitter { + context: runtime.context, + signal: runtime.signal, + }; + let result = match egress.begin() { + Ok((prepared, policy, attempt, clock)) => { + match start_prepared( + prepared, + policy, + attempt, + clock.clone(), + EgressKind::Application, + &mut committer, + ) { + StartOutcome::Started(response) => Ok(response), + StartOutcome::Precommit { + attempt: failed_attempt, + cause, + } => start_fallback( + &request_method, + cause, + *failed_attempt, + &clock, + &mut committer, + ), + } + } + Err(failure) => { + let (attempt, clock, cause) = failure.into_parts(); + start_fallback(&request_method, cause, attempt, &clock, &mut committer) + } + }; + result.map_err(|error| EdgeError::internal(anyhow::anyhow!(public_message(error)))) + } + + #[cfg(test)] + mod race_tests { + use std::cell::Cell; + use std::rc::Rc; + + use edgezero_core::time::MonotonicInstant; + use futures_util::future; + use wasm_bindgen_test::wasm_bindgen_test; + + use super::*; + + #[wasm_bindgen_test] + async fn response_egress_preflight_precedes_writer_operation() { + let controller = web_sys::AbortController::new().expect("abort controller"); + controller.abort(); + let signal: AbortSignal = controller.signal().into(); + let now = MonotonicInstant::now(); + let deadline = + Deadline::at_instant(now.checked_add(Duration::from_secs(1)).expect("deadline")); + let clock = MonotonicClock::new(move || now); + let constructed = Rc::new(Cell::new(false)); + let observed = Rc::clone(&constructed); + + let event = race_operation( + move || { + observed.set(true); + future::ready(()) + }, + deadline, + &clock, + &signal, + ) + .await; + + assert!(matches!(event, RaceEvent::Cancelled)); + assert!(!constructed.get()); + } + } +} + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +pub(crate) use platform::{CloudflareEgressRuntime, from_egress_response}; + #[cfg(test)] mod tests { - use super::*; - use bytes::Bytes; - use edgezero_core::body::Body; - use edgezero_core::http::response_builder; + use std::cell::{Cell, RefCell}; + use std::collections::VecDeque; + use std::mem; + use std::rc::Rc; + use std::sync::{Arc, Mutex}; + use std::task::Poll; + + use edgezero_core::http::header::CONTENT_ENCODING; + use edgezero_core::http::{Version, response_builder}; + use edgezero_core::response_egress::{ + ResponseEgressBodyKind, ResponseEgressCompletion, ResponseEgressHead, + ResponseEgressObserver, ResponseEgressObserverHandle, ResponseEgressReport, + }; use futures::executor::block_on; - use futures_util::stream; + use futures_util::stream::poll_fn; + + use super::*; + + #[derive(Clone, Default)] + struct RecordingObserver(Arc>>); + + impl RecordingObserver { + fn reports(&self) -> Vec { + self.0.lock().expect("reports lock").clone() + } + } + + impl ResponseEgressObserver for RecordingObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.0.lock().expect("reports lock").push(report.clone()); + } + } + + #[derive(Clone, Copy)] + enum MockWrite { + Accept, + Fail { accepted: usize }, + } + + #[derive(Default)] + struct MockState { + aborts: usize, + closes: usize, + writes: Vec>, + } + + struct MockIo { + close_error: Option, + preflight_error: Option, + state: Rc>, + writes: VecDeque, + } + + #[async_trait::async_trait(?Send)] + impl CloudflareEgressIo for MockIo { + fn abort(&mut self) { + let mut state = self.state.borrow_mut(); + state.aborts = state.aborts.saturating_add(1); + } + + async fn finish( + &mut self, + _deadline: Deadline, + _clock: &MonotonicClock, + ) -> Result<(), IoFailureKind> { + self.state.borrow_mut().closes += 1; + self.close_error.map_or(Ok(()), Err) + } + + async fn next_body( + &mut self, + source: &mut BodyStream, + _deadline: Deadline, + _clock: &MonotonicClock, + ) -> Result>, IoFailureKind> { + Ok(source.next().await) + } + + fn preflight( + &self, + _deadline: Deadline, + _clock: &MonotonicClock, + ) -> Result<(), IoFailureKind> { + self.preflight_error.map_or(Ok(()), Err) + } + + async fn write( + &mut self, + bytes: Vec, + _deadline: Deadline, + _clock: &MonotonicClock, + ) -> Result { + self.state.borrow_mut().writes.push(bytes.clone()); + match self.writes.pop_front().unwrap_or(MockWrite::Accept) { + MockWrite::Accept => Ok(bytes.len()), + MockWrite::Fail { accepted } => Err(IoFailure { + accepted, + kind: IoFailureKind::Transport, + }), + } + } + } + + #[derive(Debug)] + struct MockResponse { + declared_length: Option, + status: StatusCode, + transmits_body: bool, + } + + struct MockCommitter { + close_error: Option, + preflight_error: Option, + prepare_error: Option, + state: Rc>, + writes: VecDeque, + } + + impl MockCommitter { + fn new() -> (Self, Rc>) { + let state = Rc::new(RefCell::new(MockState::default())); + ( + Self { + close_error: None, + preflight_error: None, + prepare_error: None, + state: Rc::clone(&state), + writes: VecDeque::new(), + }, + state, + ) + } + } + + impl CloudflareEgressCommitter for MockCommitter { + type Io = MockIo; + type Response = MockResponse; + + fn prepare( + &mut self, + status: StatusCode, + _headers: &HeaderMap, + declared_length: Option, + transmits_body: bool, + ) -> Result<(Self::Response, Self::Io), DeliveryError> { + if let Some(error) = self.prepare_error.take() { + return Err(error); + } + Ok(( + MockResponse { + declared_length, + status, + transmits_body, + }, + MockIo { + close_error: self.close_error, + preflight_error: self.preflight_error, + state: Rc::clone(&self.state), + writes: mem::take(&mut self.writes), + }, + )) + } + + fn spawn(&mut self, task: LocalBoxFuture<'static, ()>) { + block_on(task); + } + } + + fn attempt( + observer: &RecordingObserver, + started_at: MonotonicInstant, + ) -> ResponseEgressAttempt { + let headers = HeaderMap::new(); + let head = ResponseEgressHead::new( + StatusCode::OK, + Version::HTTP_11, + &headers, + None, + started_at, + None, + ); + ResponseEgressAttempt::new( + &head, + started_at, + ResponseEgressCompletion::empty(), + ResponseEgressObserverHandle::new(observer.clone()), + MonotonicClock::default(), + ) + } + + fn prepared(method: &Method, body: Body) -> PreparedResponseEgress { + prepare_response_egress( + method, + response_builder() + .status(StatusCode::OK) + .body(body) + .expect("response"), + ) + .expect("prepared response") + } + + fn policy(deadline: MonotonicInstant) -> ResponseEgressPolicy { + ResponseEgressPolicy { + write_deadline: Deadline::at_instant(deadline), + } + } + + #[test] + fn once_and_multi_chunk_bodies_account_only_accepted_writes() { + for (body, has_declared_length) in [ + (Body::from(Bytes::from_static(b"once")), false), + ( + Body::stream(stream::iter([ + Bytes::from_static(b"one"), + Bytes::from_static(b"two"), + ])), + false, + ), + ] { + let now = MonotonicInstant::now(); + let deadline = now.checked_add(Duration::from_secs(10)).expect("deadline"); + let clock = MonotonicClock::new(move || now); + let observer = RecordingObserver::default(); + let (mut committer, state) = MockCommitter::new(); + let started = start_prepared( + prepared(&Method::GET, body), + policy(deadline), + attempt(&observer, now), + clock, + EgressKind::Application, + &mut committer, + ); + let StartOutcome::Started(response) = started else { + panic!("response must start"); + }; + assert!(response.transmits_body); + assert_eq!(response.declared_length.is_some(), has_declared_length); + assert_eq!(state.borrow().closes, 1); + let expected = state.borrow().writes.iter().map(Vec::len).sum::(); + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!( + reports[0].bytes_written, + u64::try_from(expected).expect("byte count") + ); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::HostHandoff); + } + } #[test] - #[ignore = "requires worker runtime — worker::Response cannot be constructed in unit tests"] - fn propagates_status_and_headers() { - let response = response_builder() - .status(201) - .header("x-test", "value") - .body(Body::text("ok")) - .expect("response"); - let cf = from_core_response(response).expect("cf response"); - assert_eq!(cf.status_code(), 201); - let header = cf.headers().get("x-test").unwrap(); - assert_eq!(header.as_deref(), Some("value")); + fn partial_rejection_accounts_prefix_and_aborts_once() { + let now = MonotonicInstant::now(); + let deadline = now.checked_add(Duration::from_secs(10)).expect("deadline"); + let clock = MonotonicClock::new(move || now); + let observer = RecordingObserver::default(); + let (mut committer, state) = MockCommitter::new(); + committer.writes.push_back(MockWrite::Fail { accepted: 2 }); + let started = start_prepared( + prepared(&Method::GET, Body::from(Bytes::from_static(b"body"))), + policy(deadline), + attempt(&observer, now), + clock.clone(), + EgressKind::Application, + &mut committer, + ); + assert!(matches!(started, StartOutcome::Started(_))); + assert_eq!(state.borrow().aborts, 1); + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].bytes_written, 2); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::TransportError); } #[test] - fn streaming_body_converts_without_buffering() { - let stream = stream::iter(vec![Bytes::from_static(b"foo"), Bytes::from_static(b"bar")]); - let response = response_builder() - .status(200) - .body(Body::stream(stream)) - .expect("response"); - - let mut cf = from_core_response(response).expect("cf response"); - let mut byte_stream = cf.stream().expect("byte stream"); - let collected = block_on(async { - let mut out = Vec::new(); - while let Some(item) = byte_stream.next().await { - let chunk = item.expect("chunk"); - out.extend_from_slice(&chunk); - } - out + fn head_suppresses_source_without_polling() { + let now = MonotonicInstant::now(); + let deadline = now.checked_add(Duration::from_secs(10)).expect("deadline"); + let clock = MonotonicClock::new(move || now); + let observer = RecordingObserver::default(); + let polled = Rc::new(Cell::new(false)); + let observed_source = Rc::clone(&polled); + let source = poll_fn(move |_context| { + observed_source.set(true); + Poll::Ready(Some(Bytes::from_static(b"hidden"))) }); + let (mut committer, state) = MockCommitter::new(); + let started = start_prepared( + prepared(&Method::HEAD, Body::stream(source)), + policy(deadline), + attempt(&observer, now), + clock, + EgressKind::Application, + &mut committer, + ); + let StartOutcome::Started(response) = started else { + panic!("HEAD response must start"); + }; + assert!(!response.transmits_body); + assert!(!polled.get()); + assert!(state.borrow().writes.is_empty()); + assert_eq!(observer.reports()[0].bytes_written, 0); + } + + #[test] + fn bodyless_response_checks_cancel_and_deadline_before_handoff() { + let now = MonotonicInstant::now(); + let deadline = now.checked_add(Duration::from_secs(10)).expect("deadline"); + let clock = MonotonicClock::new(move || now); + let observer = RecordingObserver::default(); + let (mut committer, state) = MockCommitter::new(); + committer.preflight_error = Some(IoFailureKind::Cancelled); + + let StartOutcome::Precommit { attempt, cause } = start_prepared( + prepared(&Method::HEAD, Body::empty()), + policy(deadline), + attempt(&observer, now), + clock.clone(), + EgressKind::Application, + &mut committer, + ) else { + panic!("cancelled request must not be handed off"); + }; - assert_eq!(collected, b"foobar"); + assert_eq!(cause, ResponseEgressOutcome::RequestCancelled); + assert_eq!( + start_fallback(&Method::HEAD, cause, *attempt, &clock, &mut committer) + .expect_err("cancelled request cannot start fallback delivery"), + DeliveryError::Cancelled + ); + assert_eq!(state.borrow().aborts, 1); + assert_eq!(observer.reports()[0].outcome, cause); + } + + #[test] + fn precommit_deadline_uses_fallback_and_preserves_original_cause() { + let now = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || now); + let observer = RecordingObserver::default(); + let (mut committer, _state) = MockCommitter::new(); + let StartOutcome::Precommit { attempt, cause } = start_prepared( + prepared(&Method::GET, Body::from(Bytes::from_static(b"late"))), + policy(now), + attempt(&observer, now), + clock.clone(), + EgressKind::Application, + &mut committer, + ) else { + panic!("deadline must fail before commit"); + }; + assert_eq!(cause, ResponseEgressOutcome::DeadlineExceeded); + let response = start_fallback(&Method::GET, cause, *attempt, &clock, &mut committer) + .expect("fallback response"); + assert_eq!(response.status, StatusCode::GATEWAY_TIMEOUT); + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].body_kind, ResponseEgressBodyKind::Fallback); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + assert_eq!( + reports[0].fallback_disposition, + Some(ResponseEgressFallbackDisposition::Completed) + ); + } + + #[test] + fn fallback_failure_preserves_original_conversion_cause() { + let now = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || now); + let observer = RecordingObserver::default(); + let (mut committer, _state) = MockCommitter::new(); + committer.prepare_error = Some(DeliveryError::Conversion); + let result = start_fallback( + &Method::GET, + ResponseEgressOutcome::ConversionError, + attempt(&observer, now), + &clock, + &mut committer, + ); + assert_eq!( + result.expect_err("fallback must fail"), + DeliveryError::Conversion + ); + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::ConversionError); + assert_eq!( + reports[0].fallback_disposition, + Some(ResponseEgressFallbackDisposition::Aborted) + ); + } + + #[test] + fn source_and_close_failures_have_distinct_terminal_outcomes() { + let cases = [ + ( + Body::from_stream(stream::once(async { + Err(EdgeError::internal(anyhow::anyhow!("source"))) + })), + None, + ResponseEgressOutcome::SourceError, + ), + ( + Body::from(Bytes::new()), + Some(IoFailureKind::Transport), + ResponseEgressOutcome::TransportError, + ), + ( + Body::from(Bytes::new()), + Some(IoFailureKind::Deadline), + ResponseEgressOutcome::DeadlineExceeded, + ), + ( + Body::from(Bytes::new()), + Some(IoFailureKind::Cancelled), + ResponseEgressOutcome::RequestCancelled, + ), + ]; + for (body, close_error, expected) in cases { + let now = MonotonicInstant::now(); + let deadline = now.checked_add(Duration::from_secs(10)).expect("deadline"); + let clock = MonotonicClock::new(move || now); + let observer = RecordingObserver::default(); + let (mut committer, _state) = MockCommitter::new(); + committer.close_error = close_error; + let started = start_prepared( + prepared(&Method::GET, body), + policy(deadline), + attempt(&observer, now), + clock, + EgressKind::Application, + &mut committer, + ); + assert!(matches!(started, StartOutcome::Started(_))); + let reports = observer.reports(); + assert_eq!(reports[0].outcome, expected); + if expected == ResponseEgressOutcome::DeadlineExceeded { + assert_eq!(reports[0].elapsed, Duration::from_secs(10)); + } + } + } + + #[test] + fn preencoded_payload_uses_manual_worker_encoding() { + let mut headers = HeaderMap::new(); + assert!(!manual_response_encoding(&headers, true)); + headers.insert(CONTENT_ENCODING, "zstd".parse().expect("encoding")); + assert!(manual_response_encoding(&headers, true)); + assert!(!manual_response_encoding(&headers, false)); } } diff --git a/crates/edgezero-adapter-cloudflare/src/response_headers.rs b/crates/edgezero-adapter-cloudflare/src/response_headers.rs deleted file mode 100644 index 5345250be..000000000 --- a/crates/edgezero-adapter-cloudflare/src/response_headers.rs +++ /dev/null @@ -1,94 +0,0 @@ -use edgezero_core::http::HeaderMap; - -/// Replace a body-generated default with the first valid application value, -/// then append additional values. Non-text values retain the adapter's skip policy. -pub(crate) fn copy_headers(headers: &HeaderMap, mut write: F) -> Result<(), E> -where - F: FnMut(&str, &str, bool) -> Result<(), E>, -{ - for name in headers.keys() { - let mut replace = true; - for value in headers.get_all(name) { - if let Ok(text) = value.to_str() { - write(name.as_str(), text, replace)?; - replace = false; - } - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::copy_headers; - use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue}; - - #[test] - fn replaces_defaults_and_preserves_separate_cookies() { - let mut source = HeaderMap::new(); - source.append("set-cookie", HeaderValue::from_static("first=1; Path=/")); - source.append("set-cookie", HeaderValue::from_static("second=2; Path=/")); - source.insert("content-type", HeaderValue::from_static("text/plain")); - let mut target = HeaderMap::new(); - target.insert( - "content-type", - HeaderValue::from_static("application/octet-stream"), - ); - target.insert("set-cookie", HeaderValue::from_static("default=discard")); - copy_headers(&source, |name, value, replace| { - let parsed_name = name.parse::().unwrap(); - let parsed_value = HeaderValue::from_str(value).unwrap(); - if replace { - target.insert(parsed_name, parsed_value); - } else { - target.append(parsed_name, parsed_value); - } - Ok::<_, ()>(()) - }) - .unwrap(); - assert_eq!(target, source); - assert_eq!(target.get_all("set-cookie").iter().count(), 2); - } - - #[test] - fn first_valid_value_replaces_even_after_invalid_value() { - let mut source = HeaderMap::new(); - source.append("x-test", HeaderValue::from_bytes(b"\xff").unwrap()); - source.append("x-test", HeaderValue::from_static("valid")); - source.append("x-test", HeaderValue::from_static("next")); - let mut writes = Vec::new(); - copy_headers(&source, |_, value, replace| { - writes.push((value.to_owned(), replace)); - Ok::<_, ()>(()) - }) - .unwrap(); - assert_eq!( - writes, - [("valid".to_owned(), true), ("next".to_owned(), false)] - ); - } - - #[test] - fn invalid_only_values_do_not_touch_generated_defaults() { - let mut source = HeaderMap::new(); - source.append("x-test", HeaderValue::from_bytes(b"\xff").unwrap()); - copy_headers(&source, |_, _, _| -> Result<(), ()> { - panic!("invalid text must not reach the sink") - }) - .unwrap(); - } - - #[test] - fn stops_at_sink_error() { - let mut source = HeaderMap::new(); - source.append("set-cookie", HeaderValue::from_static("first=1")); - source.append("set-cookie", HeaderValue::from_static("second=2")); - let mut calls = 0_u32; - let result = copy_headers(&source, |_, _, _| { - calls += 1; - Err("header rejected") - }); - assert_eq!(result, Err("header rejected")); - assert_eq!(calls, 1); - } -} diff --git a/crates/edgezero-adapter-cloudflare/src/secret_store.rs b/crates/edgezero-adapter-cloudflare/src/secret_store.rs index bfe5c2b16..f00951e77 100644 --- a/crates/edgezero-adapter-cloudflare/src/secret_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/secret_store.rs @@ -9,25 +9,24 @@ //! `[stores.secrets] name` in `edgezero.toml` is used only for Fastly; //! Cloudflare accesses all secrets via this adapter regardless of name. -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use std::future::Future; + use async_trait::async_trait; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use bytes::Bytes; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -use edgezero_core::secret_store::{SecretError, SecretStore}; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use edgezero_core::config_store::BoundedStoreRead; +use edgezero_core::secret_store::SecretError; +use edgezero_core::secret_store::SecretStore; +use edgezero_core::time::{Deadline, MonotonicClock}; use worker::Error as WorkerError; /// Secret store backed by Cloudflare Workers `Env`. /// /// Reads secrets via `env.secret(name)`. Clones the `Env` handle at dispatch /// time so secrets remain accessible throughout the request lifetime. -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub struct CloudflareSecretStore { env: worker::Env, } -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] impl CloudflareSecretStore { /// Create a secret store from a cloned `Env`. #[inline] @@ -37,7 +36,6 @@ impl CloudflareSecretStore { } } -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[async_trait(?Send)] impl SecretStore for CloudflareSecretStore { #[inline] @@ -59,4 +57,61 @@ impl SecretStore for CloudflareSecretStore { ))), } } + + #[inline] + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + bounded_secret_read( + self.get_bytes(store_name, key), + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + .await + } +} + +// Workers Secrets returns a complete value, so these bounds are cooperative +// and apply immediately after host materialization rather than during allocation. +pub(crate) async fn bounded_secret_read( + read: F, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, +) -> Result, SecretError> +where + F: Future, SecretError>>, +{ + if deadline.is_expired_at(clock.now()) { + return Err(SecretError::DeadlineExceeded); + } + + let result = read.await; + if deadline.is_expired_at(clock.now()) { + drop(result); + return Err(SecretError::DeadlineExceeded); + } + let value = result?; + + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.len()).map_err(|_length_error| SecretError::ValueTooLarge) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + drop(value); + return Err(SecretError::ValueTooLarge); + } + + Ok(BoundedStoreRead { + backend_bytes, + value, + }) } diff --git a/crates/edgezero-adapter-cloudflare/src/templates/wrangler.toml.hbs b/crates/edgezero-adapter-cloudflare/src/templates/wrangler.toml.hbs index 4f076e096..766c3d0ec 100644 --- a/crates/edgezero-adapter-cloudflare/src/templates/wrangler.toml.hbs +++ b/crates/edgezero-adapter-cloudflare/src/templates/wrangler.toml.hbs @@ -1,6 +1,7 @@ name = "{{proj_cloudflare}}" main = "build/worker/shim.mjs" compatibility_date = "2023-05-01" +compatibility_flags = ["enable_request_signal", "enable_weak_ref"] [build] -command = "worker-build --release" \ No newline at end of file +command = "worker-build --release" diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index f2646a20b..e957e0cb9 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -1,6 +1,12 @@ -#![cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#![allow( + clippy::expect_used, + clippy::missing_assert_message, + clippy::unwrap_used, + reason = "wasm_bindgen_test functions are tests, but Clippy does not classify the generated wrappers as tests" +)] // Compile-time check: CloudflareSecretStore implements SecretStore. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] mod secret_store_compile_check { use edgezero_adapter_cloudflare::secret_store::CloudflareSecretStore; use edgezero_core::secret_store::SecretStore; @@ -12,24 +18,46 @@ mod secret_store_compile_check { const _: fn() = assert_provider_impl::; } -#[cfg(test)] +#[cfg(all(test, feature = "cloudflare", target_arch = "wasm32"))] +#[cfg_attr( + feature = "test-utils", + expect( + clippy::arbitrary_source_item_ordering, + reason = "ingress contracts are grouped after the provider fixture tests" + ) +)] mod tests { use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; use bytes::Bytes; + #[cfg(feature = "test-utils")] + use edgezero_adapter_cloudflare::build_app_for_test; use edgezero_adapter_cloudflare::context::CloudflareRequestContext; + use edgezero_adapter_cloudflare::outbound::CloudflareOutboundClient; + #[cfg(feature = "test-utils")] + use edgezero_adapter_cloudflare::outbound::{ + deferred_clock_paths_hold_for_test, response_abort_lifecycle_holds_for_test, + }; + #[cfg(feature = "test-utils")] + use edgezero_adapter_cloudflare::request::deadline_body_releases_source_for_test; use edgezero_adapter_cloudflare::request::{CloudflareService, into_core_request}; - use edgezero_adapter_cloudflare::response::from_core_response; use edgezero_core::app::{App, Hooks}; use edgezero_core::body::Body; - use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::config_store::{ + BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigStoreHandle, + finish_bounded_config_read, + }; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{Method, Response, StatusCode, response_builder}; + use edgezero_core::outbound::{OutboundHttpClient as _, OutboundRequest}; use edgezero_core::router::RouterService; + use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; use futures::stream; use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; - use worker::js_sys::Object; + use worker::js_sys::{Function, Object, Reflect}; use worker::wasm_bindgen::{JsCast as _, JsValue}; use worker::worker_sys::Context as WorkerSysContext; use worker::{Context, Env, Method as CfMethod, Request as CfRequest, RequestInit}; @@ -38,16 +66,71 @@ mod tests { struct FixedConfigStore(&'static str); + #[cfg(feature = "test-utils")] + struct FailingConfiguration; + + #[cfg(feature = "test-utils")] + #[expect( + clippy::missing_trait_methods, + reason = "test hook exercises only adapter startup failure" + )] + impl Hooks for FailingConfiguration { + fn configure(_app: &mut App) -> Result<(), EdgeError> { + Err(EdgeError::service_unavailable( + "private configuration detail", + )) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[cfg(feature = "test-utils")] + #[wasm_bindgen_test] + fn failing_configuration() { + let result = build_app_for_test::(); + assert!(result.is_err()); + let error = result.err().expect("configuration must fail"); + assert!(matches!( + error, + worker::Error::RustError(message) if message == "application configuration failed" + )); + } + #[async_trait::async_trait(?Send)] impl ConfigStore for FixedConfigStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.to_owned())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.into())) + } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + finish_bounded_config_read( + self.get(key).await, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) } } fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { - let body = Body::text(ctx.request().uri().to_string()); + let body = Body::text(ctx.uri().to_string()); let response = response_builder() .status(StatusCode::OK) .body(body) @@ -56,7 +139,7 @@ mod tests { } async fn mirror_body(ctx: RequestContext) -> Result { - let bytes = ctx.request().body().as_bytes().expect("buffered").to_vec(); + let bytes = ctx.body_bytes(1024 * 1024).await?.to_vec(); let response = response_builder() .status(StatusCode::OK) .body(Body::from(bytes)) @@ -103,7 +186,7 @@ mod tests { .await .ok() .flatten() - .unwrap_or_else(|| "missing".to_owned()), + .map_or_else(|| "missing".to_owned(), |value| value.to_string()), None => "missing".to_owned(), }; let response = response_builder() @@ -146,10 +229,24 @@ mod tests { fn test_env_ctx() -> (Env, Context) { let env = Object::new().unchecked_into::(); - let js_context = Object::new().unchecked_into::(); + let context_object = Object::new(); + let wait_until = Function::new_with_args("_promise", "return undefined;"); + Reflect::set( + &context_object, + &JsValue::from_str("waitUntil"), + wait_until.as_ref(), + ) + .expect("install test waitUntil"); + let js_context = context_object.unchecked_into::(); (env, Context::new(js_context)) } + #[cfg(feature = "test-utils")] + #[wasm_bindgen_test] + fn deadline_body_releases_source_when_timeout_is_emitted() { + assert!(deadline_body_releases_source_for_test()); + } + #[wasm_bindgen_test] async fn dispatch_app_reuses_router_with_fresh_request_bodies() { struct TestApp; @@ -163,7 +260,8 @@ mod tests { build_test_app().router().clone() } } - let app = TestApp::build_app(); + let app = App::build::(edgezero_adapter_cloudflare::CLOUDFLARE_PLATFORM) + .expect("prebuilt application"); for body in [b"first".as_slice(), b"second".as_slice()] { let (env, ctx) = test_env_ctx(); let request = cf_request(CfMethod::Post, "/mirror", Some(body)); @@ -224,28 +322,6 @@ mod tests { assert_eq!(bytes.as_slice(), b"chunk-1chunk-2"); } - #[wasm_bindgen_test] - async fn from_core_response_translates_status_headers_and_streaming_body() { - let response = response_builder() - .status(StatusCode::CREATED) - .header("x-edgezero-res", "1") - .body(Body::stream(stream::iter(vec![ - Bytes::from_static(b"hello"), - Bytes::from_static(b" "), - Bytes::from_static(b"world"), - ]))) - .expect("response"); - - let mut cf_response = from_core_response(response).expect("cf response"); - - assert_eq!(cf_response.status_code(), StatusCode::CREATED.as_u16()); - let header = cf_response.headers().get("x-edgezero-res").unwrap(); - assert_eq!(header.as_deref(), Some("1")); - - let bytes = cf_response.bytes().await.expect("bytes"); - assert_eq!(bytes.as_slice(), b"hello world"); - } - #[wasm_bindgen_test] async fn into_core_request_preserves_method_uri_headers_body_and_context() { let req = cf_request(CfMethod::Post, "/mirror?foo=bar", Some(b"payload")); @@ -265,12 +341,14 @@ mod tests { .and_then(|value| value.to_str().ok()); assert_eq!(header, Some("1")); - assert_eq!( - core_request.body().as_bytes().expect("buffered"), - b"payload" - ); - assert!(CloudflareRequestContext::get(&core_request).is_some()); + + let body = core_request + .into_body() + .into_bytes_bounded(1024) + .await + .expect("request body"); + assert_eq!(body.as_ref(), b"payload"); } #[wasm_bindgen_test] @@ -292,17 +370,81 @@ mod tests { } #[wasm_bindgen_test] - async fn service_with_config_missing_binding_skips_injection() { - // The test env is an empty JS object; any env.var() call returns None. - // `CloudflareService::with_config(name)` should log a warning and - // dispatch without injecting a config-store handle, so the handler - // sees `ctx.config_store_default()` return `None`. + async fn service_with_config_missing_binding_preserves_typed_failure() { + #[edgezero_core::action] + async fn read_declared_config(ctx: RequestContext) -> Result { + let binding = ctx + .config_store_default_binding() + .expect("declared binding must remain registered"); + let error = binding + .handle + .get(&binding.default_key) + .await + .expect_err("failed opening must not become a missing value"); + assert!(matches!(&error, ConfigStoreError::Unavailable { .. })); + + let clock = ctx.monotonic_clock(); + let bounded_error = binding + .handle + .get_bounded( + &binding.default_key, + &clock, + Deadline::at_instant( + clock + .now() + .checked_add(Duration::from_secs(1)) + .expect("read deadline"), + ), + 1024, + 1024, + ) + .await + .expect_err("bounded reads must preserve the opening failure"); + assert!(matches!( + bounded_error, + ConfigStoreError::Unavailable { .. } + )); + + Err(EdgeError::from(error)) + } + + // The empty environment cannot open this declared binding. It must + // stay registered with a typed error rather than look unconfigured. + let mut app = App::new( + RouterService::builder() + .get("/config-value", read_declared_config) + .build(), + ); + // Browser fixtures lack Workers' FixedLengthStream. A bodyless + // error renderer isolates binding failures from that host-only API. + app.set_error_response_renderer(|error| { + response_builder() + .status(error.status()) + .body(Body::empty()) + .expect("error response") + }); + let req = cf_request(CfMethod::Get, "/config-value", None); + let (env, ctx) = test_env_ctx(); + + let response = CloudflareService::new(&app) + .with_config("nonexistent_binding") + .dispatch(req, env, ctx) + .await + .expect("cf response"); + + assert_eq!( + response.status_code(), + StatusCode::SERVICE_UNAVAILABLE.as_u16() + ); + } + + #[wasm_bindgen_test] + async fn service_without_config_keeps_registry_absent() { let app = build_test_app(); let req = cf_request(CfMethod::Get, "/has-config", None); let (env, ctx) = test_env_ctx(); let mut response = CloudflareService::new(&app) - .with_config("nonexistent_binding") .dispatch(req, env, ctx) .await .expect("cf response"); @@ -311,4 +453,1121 @@ mod tests { let body = response.text().await.expect("text"); assert_eq!(body, "no"); } + + #[wasm_bindgen_test] + async fn outbound_clock_controls_preflight_elapsed_and_backwards_failure() { + use std::collections::VecDeque; + use std::sync::Mutex; + + fn scripted_clock(script: Vec) -> MonotonicClock { + let observations = Arc::new(Mutex::new(VecDeque::from(script))); + MonotonicClock::new(move || { + observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + }) + } + + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(9)) + .expect("completed instant"); + let forward_client = + CloudflareOutboundClient::with_clock(scripted_clock(vec![start, completed])); + let forward_request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + let observation_cutoff = Deadline::at_instant( + start + .checked_add(Duration::from_secs(1)) + .expect("cutoff instant"), + ); + let forward_results = forward_client + .start_batch_until(vec![forward_request], observation_cutoff) + .collect() + .await + .expect("valid batch driver"); + assert_eq!( + forward_results.termination, + edgezero_core::OutboundBatchTermination::Completed + ); + assert_eq!(forward_results.slots.len(), 1); + let forward_result = forward_results + .slots + .first() + .expect("forward result slot") + .as_ref() + .expect("single forward result"); + assert_eq!(forward_result.elapsed, Duration::from_millis(9)); + assert!(matches!( + forward_result.outcome, + Err(EdgeError::BadRequest { .. }) + )); + + let earlier = start + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let backwards_client = + CloudflareOutboundClient::with_clock(scripted_clock(vec![start, earlier])); + let backwards_request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + let backwards_results = backwards_client + .start_batch_until(vec![backwards_request], observation_cutoff) + .collect() + .await + .expect("valid batch driver"); + assert_eq!( + backwards_results.termination, + edgezero_core::OutboundBatchTermination::Completed + ); + assert_eq!(backwards_results.slots.len(), 1); + let backwards_result = backwards_results + .slots + .first() + .expect("backwards result slot") + .as_ref() + .expect("single backwards result"); + assert_eq!(backwards_result.elapsed, Duration::ZERO); + assert!(matches!( + backwards_result.outcome, + Err(EdgeError::Internal { .. }) + )); + } + + #[wasm_bindgen_test] + async fn expired_observation_cutoff_leaves_slot_unresolved() { + let now = MonotonicInstant::now(); + let client = CloudflareOutboundClient::with_clock(MonotonicClock::new(move || now)); + let request = OutboundRequest::get("https://example.com/").expect("request"); + + let results = client + .start_batch_until(vec![request], Deadline::at_instant(now)) + .collect() + .await + .expect("valid batch driver"); + + assert_eq!( + results.termination, + edgezero_core::OutboundBatchTermination::Cutoff + ); + assert!(matches!(results.slots.first(), Some(None))); + } + + #[wasm_bindgen_test] + async fn batch_preserves_three_preflight_slots_in_input_order() { + let now = MonotonicInstant::now(); + let client = CloudflareOutboundClient::with_clock(MonotonicClock::new(move || now)); + let streamed_upload = OutboundRequest::post("https://example.com/upload") + .expect("upload request") + .body(Body::stream(stream::iter([Bytes::from_static(b"body")]))); + let streamed_response = OutboundRequest::get("https://example.com/stream") + .expect("stream request") + .stream_response(); + let method_error = OutboundRequest::get("https://example.com/get") + .expect("GET request") + .body(Body::stream(stream::iter([Bytes::new()]))); + + let results = client + .start_batch_until( + vec![streamed_upload, streamed_response, method_error], + Deadline::at_instant( + now.checked_add(Duration::from_secs(1)) + .expect("cutoff instant"), + ), + ) + .collect() + .await + .expect("valid batch driver"); + assert_eq!( + results.termination, + edgezero_core::OutboundBatchTermination::Completed + ); + let messages: Vec<_> = results + .slots + .iter() + .map( + |slot| match &slot.as_ref().expect("resolved preflight slot").outcome { + Err(EdgeError::BadRequest { message }) => Some(message.as_str()), + _ => None, + }, + ) + .collect(); + + assert_eq!( + messages, + [ + Some( + "outbound batches require buffered request bodies; use send for a streamed upload" + ), + Some( + "outbound batches require buffered responses; use send for a streamed response" + ), + Some( + "GET/HEAD request must not carry a streamed body; emptiness cannot be determined without consuming the stream" + ), + ] + ); + assert!(results.slots.iter().all(|slot| { + slot.as_ref() + .is_some_and(|result| result.elapsed == Duration::ZERO) + })); + } + + #[wasm_bindgen_test] + async fn standard_service_installs_the_application_outbound_clock() { + async fn elapsed(ctx: RequestContext) -> Result { + let client = ctx + .http_client() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing HTTP client")))?; + let request = + OutboundRequest::get("https://example.com/")?.body(Body::from("invalid GET body")); + let results = client + .send_all_until(vec![request], Deadline::after(Duration::from_secs(1))) + .await?; + let result = results + .slots + .first() + .and_then(Option::as_ref) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing outbound result")))?; + if !matches!(&result.outcome, Err(EdgeError::BadRequest { .. })) { + return Err(EdgeError::internal(anyhow::anyhow!( + "clock probe did not fail during batch preflight" + ))); + } + response_builder() + .status(StatusCode::NO_CONTENT) + .header( + "x-edgezero-elapsed-ms", + result.elapsed.as_millis().to_string(), + ) + .body(Body::empty()) + .map_err(EdgeError::internal) + } + + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(7)) + .expect("completed instant"); + let observations = Arc::new(AtomicUsize::new(0)); + let clock_observations = Arc::clone(&observations); + let mut app = App::new(RouterService::builder().get("/clock", elapsed).build()); + app.set_monotonic_clock(MonotonicClock::new(move || { + if clock_observations.fetch_add(1, Ordering::SeqCst) < 2 { + start + } else { + completed + } + })); + let req = cf_request(CfMethod::Get, "/clock", None); + let (env, ctx) = test_env_ctx(); + + let response = CloudflareService::new(&app) + .dispatch(req, env, ctx) + .await + .expect("Cloudflare response"); + + assert_eq!(response.status_code(), StatusCode::NO_CONTENT.as_u16()); + assert_eq!( + response + .headers() + .get("x-edgezero-elapsed-ms") + .expect("elapsed header"), + Some("7".to_owned()) + ); + assert!(observations.load(Ordering::SeqCst) >= 3); + } + + #[cfg(feature = "test-utils")] + #[wasm_bindgen_test] + async fn deferred_upload_and_response_paths_retain_the_injected_clock() { + assert!(deferred_clock_paths_hold_for_test().await); + } + + #[cfg(feature = "test-utils")] + #[wasm_bindgen_test] + async fn response_abort_lifecycle_is_enforced() { + assert!(response_abort_lifecycle_holds_for_test().await); + } + + #[cfg(feature = "test-utils")] + mod ingress_contract { + use std::future::Future as _; + use std::io; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::Poll; + use std::time::Duration; + + use edgezero_adapter_cloudflare::request::{ + dispatch_ingress_source_error_for_test, dispatch_ingress_stream_for_test, + dispatch_ingress_with_for_test, + }; + use edgezero_core::http::{HeaderMap, HeaderValue}; + use edgezero_core::ingress::{ + AdmissionDecision, BufferedIngressResponse, IngressGrant, IngressHeadLimits, + }; + use edgezero_core::middleware::{Middleware, Next}; + use edgezero_core::response_egress::{ + DEFAULT_RESPONSE_WRITE_BUDGET, DetachedResponseEgressDecision, + ResponseEgressCompletion, ResponseEgressEnvelope, ResponseEgressObserver, + ResponseEgressOutcome, ResponseEgressReport, + }; + use edgezero_core::router::{RouteMetadata, RouteResolution}; + use futures::future::poll_fn as poll_future; + use futures::stream::poll_fn; + use worker::{Delay, Response as CfResponse}; + + use super::*; + + #[wasm_bindgen_test] + async fn admission_abort() { + let source_calls = Arc::new(AtomicUsize::new(0)); + let observed_source_calls = Arc::clone(&source_calls); + let delivery_calls = Arc::new(AtomicUsize::new(0)); + let observed_delivery_calls = Arc::clone(&delivery_calls); + let mut app = App::new(RouterService::builder().build()); + app.set_ingress_admission_policy(|_| AdmissionDecision::Abort); + + let error = dispatch_ingress_with_for_test( + &app, + Method::POST, + "/missing".parse().expect("URI"), + move || { + observed_source_calls.fetch_add(1, Ordering::SeqCst); + Ok(stream::empty::>()) + }, + move |_response| { + observed_delivery_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .await + .expect_err("abort must return a platform error"); + + assert!(error.to_string().contains("ingress admission aborted")); + assert_eq!(source_calls.load(Ordering::SeqCst), 0); + assert_eq!(delivery_calls.load(Ordering::SeqCst), 0); + } + + struct DropSignal(Arc); + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + struct CountingMiddleware(Arc); + + #[derive(Clone)] + struct RecordingEgressObserver(Arc>>); + + impl ResponseEgressObserver for RecordingEgressObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.0.lock().expect("reports lock").push(report.clone()); + } + } + + #[async_trait::async_trait(?Send)] + impl Middleware for CountingMiddleware { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } + } + + fn guarded_fallback_app() -> (App, Arc, Arc) { + let handler_calls = Arc::new(AtomicUsize::new(0)); + let handler_counter = Arc::clone(&handler_calls); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .get("/known", move |_ctx: RequestContext| { + let request_handler_calls = Arc::clone(&handler_counter); + async move { + request_handler_calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("handler must not run") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + (App::new(router), handler_calls, middleware_calls) + } + + fn fallback_app( + max_body_bytes: usize, + read_budget: Duration, + grant_drops: &Arc, + ) -> (App, Arc, Arc) { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let observed_grant_drops = Arc::clone(grant_drops); + app.set_ingress_admission_policy(move |head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound + )); + AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes, + read_deadline: head.read_deadline_after(read_budget), + on_exceeded: terminal_response( + StatusCode::UNPROCESSABLE_ENTITY, + "overflow", + b"cloudflare overflow\0response", + ), + on_timeout: terminal_response( + StatusCode::GATEWAY_TIMEOUT, + "timeout", + b"cloudflare timeout response\n", + ), + } + }); + (app, handler_calls, middleware_calls) + } + + fn terminal_headers(marker: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + HeaderValue::from_static("application/octet-stream"), + ); + headers.insert("x-ingress-terminal", HeaderValue::from_static(marker)); + headers + } + + fn terminal_response( + status: StatusCode, + marker: &'static str, + body: &'static [u8], + ) -> BufferedIngressResponse { + BufferedIngressResponse::new(status, terminal_headers(marker), Bytes::from_static(body)) + } + + fn internal_error_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("content-length", HeaderValue::from_static("76")); + headers.insert("content-type", HeaderValue::from_static("application/json")); + headers + } + + fn tracked_stream( + chunks: Vec, + grant_drops: &Arc, + source_drops: &Arc, + body_polls: &Arc, + ) -> impl futures::Stream> + 'static { + let observed_grant_drops = Arc::clone(grant_drops); + let observed_body_polls = Arc::clone(body_polls); + let source_drop = DropSignal(Arc::clone(source_drops)); + let mut pending_chunks = chunks.into_iter(); + poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + observed_body_polls.fetch_add(1, Ordering::SeqCst); + Poll::Ready(pending_chunks.next().map(Ok)) + }) + } + + fn pending_stream( + grant_drops: &Arc, + source_drops: &Arc, + body_polls: &Arc, + ) -> impl futures::Stream> + 'static { + let observed_grant_drops = Arc::clone(grant_drops); + let observed_body_polls = Arc::clone(body_polls); + let source_drop = DropSignal(Arc::clone(source_drops)); + poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + observed_body_polls.fetch_add(1, Ordering::SeqCst); + Poll::Pending + }) + } + + fn error_stream( + grant_drops: &Arc, + source_drops: &Arc, + body_polls: &Arc, + ) -> impl futures::Stream> + 'static { + let observed_grant_drops = Arc::clone(grant_drops); + let observed_body_polls = Arc::clone(body_polls); + let source_drop = DropSignal(Arc::clone(source_drops)); + let mut emitted = false; + poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + observed_body_polls.fetch_add(1, Ordering::SeqCst); + if emitted { + Poll::Ready(None) + } else { + emitted = true; + Poll::Ready(Some(Err(io::Error::other( + "cloudflare ingress source failure", + )))) + } + }) + } + + fn assert_no_route_dispatch(handler_calls: &AtomicUsize, middleware_calls: &AtomicUsize) { + assert_eq!(handler_calls.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + } + + #[wasm_bindgen_test] + async fn normalized_ingress_error_returns_typed_response_without_application_observation() { + let reports = Arc::new(Mutex::new(Vec::new())); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let mut app = App::new(RouterService::builder().build()); + app.set_ingress_head_limits( + IngressHeadLimits::default() + .with_max_request_target_bytes(4) + .expect("target limit"), + ); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let observed_factory_calls = Arc::clone(&factory_calls); + let observed_completion_calls = Arc::clone(&completion_calls); + app.set_detached_response_egress_decision_factory(move |head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + let terminal_calls = Arc::clone(&observed_completion_calls); + DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::new(move |_report| { + terminal_calls.fetch_add(1, Ordering::SeqCst); + }), + deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET), + } + }); + + let egress = dispatch_ingress_stream_for_test( + &app, + Method::GET, + "/too-long".parse().expect("URI"), + stream::empty::>(), + ) + .await + .expect("typed response"); + let response = capture_response(egress); + + assert_eq!(response.status(), StatusCode::URI_TOO_LONG); + assert!(reports.lock().expect("reports lock").is_empty()); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert_eq!(completion_calls.load(Ordering::SeqCst), 1); + } + + #[wasm_bindgen_test] + async fn normalized_ingress_error_decision_abort_uses_provider_error() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + app.set_ingress_head_limits( + IngressHeadLimits::default() + .with_max_request_target_bytes(4) + .expect("target limit"), + ); + let reports = Arc::new(Mutex::new(Vec::new())); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let observed_factory_calls = Arc::clone(&factory_calls); + app.set_detached_response_egress_decision_factory(move |_head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + DetachedResponseEgressDecision::Abort + }); + let source_calls = Arc::new(AtomicUsize::new(0)); + let observed_source_calls = Arc::clone(&source_calls); + let delivery_calls = Arc::new(AtomicUsize::new(0)); + let observed_delivery_calls = Arc::clone(&delivery_calls); + + let error = dispatch_ingress_with_for_test( + &app, + Method::GET, + "/too-long".parse().expect("URI"), + move || { + observed_source_calls.fetch_add(1, Ordering::SeqCst); + Ok(stream::empty::>()) + }, + move |_egress| { + observed_delivery_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .await + .expect_err("detached abort must return a platform error"); + + assert!(error.to_string().contains("ingress admission aborted")); + assert_eq!(source_calls.load(Ordering::SeqCst), 0); + assert_eq!(delivery_calls.load(Ordering::SeqCst), 0); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert!(reports.lock().expect("reports lock").is_empty()); + } + + #[wasm_bindgen_test] + async fn admission_policy_error_decision_send_uses_detached_completion() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let decision_completion_calls = Arc::new(AtomicUsize::new(0)); + let decision_resource_drops = Arc::new(AtomicUsize::new(0)); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_decision_completion_calls = Arc::clone(&decision_completion_calls); + let observed_decision_resource_drops = Arc::clone(&decision_resource_drops); + let observed_grant_drops = Arc::clone(&grant_drops); + app.set_ingress_admission_policy(move |head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::Matched(_) + )); + let decision_resource = DropSignal(Arc::clone(&observed_decision_resource_drops)); + let terminal_calls = Arc::clone(&observed_decision_completion_calls); + AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::new(move |_report| { + let _resource = decision_resource; + terminal_calls.fetch_add(1, Ordering::SeqCst); + }), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes: 1, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text( + StatusCode::PAYLOAD_TOO_LARGE, + "too large", + ), + on_timeout: BufferedIngressResponse::text( + StatusCode::REQUEST_TIMEOUT, + "timeout", + ), + } + }); + let reports = Arc::new(Mutex::new(Vec::new())); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let observed_factory_calls = Arc::clone(&factory_calls); + let observed_completion_calls = Arc::clone(&completion_calls); + app.set_detached_response_egress_decision_factory(move |head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + let terminal_calls = Arc::clone(&observed_completion_calls); + DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::new(move |_report| { + terminal_calls.fetch_add(1, Ordering::SeqCst); + }), + deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET), + } + }); + let source_calls = Arc::new(AtomicUsize::new(0)); + let observed_source_calls = Arc::clone(&source_calls); + + let response = dispatch_ingress_with_for_test( + &app, + Method::GET, + "/known".parse().expect("URI"), + move || { + observed_source_calls.fetch_add(1, Ordering::SeqCst); + Ok(stream::empty::>()) + }, + |egress| Ok(capture_response(egress)), + ) + .await + .expect("typed policy error response"); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(source_calls.load(Ordering::SeqCst), 0); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + assert_eq!(decision_completion_calls.load(Ordering::SeqCst), 0); + assert_eq!(decision_resource_drops.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert_eq!(completion_calls.load(Ordering::SeqCst), 1); + assert!(reports.lock().expect("reports lock").is_empty()); + } + + #[wasm_bindgen_test] + async fn admission_policy_error_decision_abort_uses_provider_error() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let decision_resource_drops = Arc::new(AtomicUsize::new(0)); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_decision_resource_drops = Arc::clone(&decision_resource_drops); + let observed_grant_drops = Arc::clone(&grant_drops); + app.set_ingress_admission_policy(move |head| { + AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::new({ + let resource = DropSignal(Arc::clone(&observed_decision_resource_drops)); + move |_report| drop(resource) + }), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes: 1, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text( + StatusCode::PAYLOAD_TOO_LARGE, + "too large", + ), + on_timeout: BufferedIngressResponse::text( + StatusCode::REQUEST_TIMEOUT, + "timeout", + ), + } + }); + let reports = Arc::new(Mutex::new(Vec::new())); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let observed_factory_calls = Arc::clone(&factory_calls); + app.set_detached_response_egress_decision_factory(move |_head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + DetachedResponseEgressDecision::Abort + }); + let source_calls = Arc::new(AtomicUsize::new(0)); + let observed_source_calls = Arc::clone(&source_calls); + let delivery_calls = Arc::new(AtomicUsize::new(0)); + let observed_delivery_calls = Arc::clone(&delivery_calls); + + let error = dispatch_ingress_with_for_test( + &app, + Method::GET, + "/known".parse().expect("URI"), + move || { + observed_source_calls.fetch_add(1, Ordering::SeqCst); + Ok(stream::empty::>()) + }, + move |_egress| { + observed_delivery_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .await + .expect_err("detached abort must return a platform error"); + + assert!(error.to_string().contains("ingress admission aborted")); + assert_eq!(source_calls.load(Ordering::SeqCst), 0); + assert_eq!(delivery_calls.load(Ordering::SeqCst), 0); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + assert_eq!(decision_resource_drops.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert!(reports.lock().expect("reports lock").is_empty()); + } + + #[wasm_bindgen_test] + async fn post_admission_failure_uses_owned_error_egress() { + let reports = Arc::new(Mutex::new(Vec::new())); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_grant_drops = Arc::clone(&grant_drops); + let mut app = App::new( + RouterService::builder() + .get("/owned", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("handler must not run") + }) + .build(), + ); + app.set_ingress_admission_policy(move |head| AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + }); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + + let egress = dispatch_ingress_source_error_for_test( + &app, + Method::GET, + "/owned".parse().expect("URI"), + ) + .await + .expect("owned error egress"); + let response = capture_response(egress); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + let observed = reports.lock().expect("reports lock"); + assert_eq!(observed.len(), 1); + let report = observed.first().expect("one report"); + assert_eq!(report.outcome, ResponseEgressOutcome::HostHandoff); + assert_eq!( + report.route.as_ref().map(RouteMetadata::pattern), + Some("/owned") + ); + } + + fn capture_response(envelope: ResponseEgressEnvelope) -> Response { + let result = envelope.begin(); + assert!(result.is_ok(), "test egress envelope must prepare"); + let Ok((prepared, _policy, mut attempt, clock)) = result else { + return Response::new(Body::empty()); + }; + assert!(attempt.begin_writing()); + assert!(attempt.terminate(ResponseEgressOutcome::HostHandoff, clock.now())); + prepared.into_response() + } + + async fn assert_terminal_response( + envelope: ResponseEgressEnvelope, + status: StatusCode, + expected_headers: HeaderMap, + body: &[u8], + ) { + let response = capture_response(envelope); + assert_eq!(response.status(), status); + assert_eq!(response.headers(), &expected_headers); + let bytes = response + .into_body() + .into_bytes_bounded(usize::MAX) + .await + .expect("captured body"); + assert_eq!(bytes.as_ref(), body); + } + + async fn assert_worker_response( + mut response: CfResponse, + status: StatusCode, + expected_headers: HeaderMap, + body: &[u8], + ) { + assert_eq!(response.status_code(), status.as_u16()); + let mut actual_headers = response.headers().entries().collect::>(); + actual_headers.sort_unstable(); + let mut expected_provider_headers = expected_headers + .iter() + .map(|(name, value)| { + ( + name.as_str().to_owned(), + value.to_str().expect("UTF-8 test header").to_owned(), + ) + }) + .collect::>(); + expected_provider_headers.sort_unstable(); + assert_eq!(actual_headers, expected_provider_headers); + assert_eq!(response.bytes().await.expect("provider body"), body); + } + + #[wasm_bindgen_test] + async fn exact_cap_preserves_not_found_and_method_not_allowed() { + for (path, expected_status) in [ + ("/missing", StatusCode::NOT_FOUND), + ("/known", StatusCode::METHOD_NOT_ALLOWED), + ] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = tracked_stream( + vec![Bytes::from_static(b"ab"), Bytes::from_static(b"cd")], + &grant_drops, + &source_drops, + &body_polls, + ); + + let envelope = dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + ) + .await + .expect("response"); + let response = capture_response(envelope); + + assert_eq!(response.status(), expected_status); + if expected_status == StatusCode::METHOD_NOT_ALLOWED { + assert_eq!( + response.headers().get("allow").expect("Allow header"), + "GET" + ); + } + assert_eq!(body_polls.load(Ordering::SeqCst), 3); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[wasm_bindgen_test] + async fn cap_plus_one_precedes_not_found_and_method_not_allowed() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = tracked_stream( + vec![Bytes::from_static(b"abcd"), Bytes::from_static(b"e")], + &grant_drops, + &source_drops, + &body_polls, + ); + + let response = dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + ) + .await + .expect("response"); + + assert_terminal_response( + response, + StatusCode::UNPROCESSABLE_ENTITY, + terminal_headers("overflow"), + b"cloudflare overflow\0response", + ) + .await; + assert_eq!(body_polls.load(Ordering::SeqCst), 2); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[wasm_bindgen_test] + async fn standard_service_enforces_fallback_overflow() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let req = cf_request(CfMethod::Post, "/missing", Some(b"abcde")); + let (env, ctx) = test_env_ctx(); + + let response = CloudflareService::new(&app) + .dispatch(req, env, ctx) + .await + .expect("Cloudflare response"); + + assert_worker_response( + response, + StatusCode::UNPROCESSABLE_ENTITY, + terminal_headers("overflow"), + b"cloudflare overflow\0response", + ) + .await; + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + + #[wasm_bindgen_test] + async fn wasm_timer_timeout_preserves_application_response() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_millis(100), &grant_drops); + let source = pending_stream(&grant_drops, &source_drops, &body_polls); + + let response = dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + ) + .await + .expect("response"); + + assert_terminal_response( + response, + StatusCode::GATEWAY_TIMEOUT, + terminal_headers("timeout"), + b"cloudflare timeout response\n", + ) + .await; + assert!(body_polls.load(Ordering::SeqCst) > 0); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[wasm_bindgen_test] + async fn saturated_fallback_refuses_without_polling_body() { + for path in ["/missing", "/known"] { + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let observed_completion_calls = Arc::clone(&completion_calls); + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + app.set_ingress_admission_policy(move |head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound + )); + AdmissionDecision::Refuse { + completion: { + let calls = Arc::clone(&observed_completion_calls); + ResponseEgressCompletion::new(move |_report| { + calls.fetch_add(1, Ordering::SeqCst); + }) + }, + response: response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("x-ingress-refusal", "saturated") + .body(Body::from("cloudflare unavailable\n")) + .expect("refusal response"), + } + }); + let unobserved_grant = Arc::new(AtomicUsize::new(0)); + let source = pending_stream(&unobserved_grant, &source_drops, &body_polls); + + let response = dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + ) + .await + .expect("response"); + + assert_terminal_response( + response, + StatusCode::SERVICE_UNAVAILABLE, + { + let mut headers = HeaderMap::new(); + headers.insert("x-ingress-refusal", HeaderValue::from_static("saturated")); + headers + }, + b"cloudflare unavailable\n", + ) + .await; + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + assert_eq!(unobserved_grant.load(Ordering::SeqCst), 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_eq!(completion_calls.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[wasm_bindgen_test] + async fn source_error_uses_worker_response_boundary_and_releases_lifecycle() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = error_stream(&grant_drops, &source_drops, &body_polls); + + let response = dispatch_ingress_stream_for_test( + &app, + Method::POST, + "/missing".parse().expect("URI"), + source, + ) + .await + .expect("standard error response"); + + assert_terminal_response( + response, + StatusCode::INTERNAL_SERVER_ERROR, + internal_error_headers(), + br#"{"error":{"kind":"internal","message":"internal server error","status":500}}"#, + ) + .await; + assert_eq!(body_polls.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + + #[wasm_bindgen_test] + async fn poll_then_drop_releases_pending_source_and_grant() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = pending_stream(&grant_drops, &source_drops, &body_polls); + let mut dispatch = Box::pin(dispatch_ingress_stream_for_test( + &app, + Method::POST, + "/missing".parse().expect("URI"), + source, + )); + + let first_poll = poll_future(|cx| Poll::Ready(dispatch.as_mut().poll(cx))).await; + assert!(first_poll.is_pending()); + Delay::from(Duration::ZERO).await; + let source_poll = poll_future(|cx| Poll::Ready(dispatch.as_mut().poll(cx))).await; + assert!(source_poll.is_pending()); + assert!(body_polls.load(Ordering::SeqCst) > 0); + assert_eq!(grant_drops.load(Ordering::SeqCst), 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 0); + + drop(dispatch); + + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } +} + +#[cfg(test)] +mod native_tests { + #[cfg(all(feature = "test-utils", not(target_arch = "wasm32")))] + mod enabled { + use bytes::Bytes; + use edgezero_adapter_cloudflare::outbound::{ + generic_fetch_failure_for_test, timeout_error_for_test, validate_batch_request_for_test, + }; + use edgezero_core::body::Body; + use edgezero_core::error::{BadGatewayReason, BudgetSource, EdgeError}; + use edgezero_core::outbound::OutboundRequest; + use futures_util::stream; + + fn message(result: Result<(), EdgeError>) -> Option { + if let Err(EdgeError::BadRequest { message }) = result { + Some(message) + } else { + None + } + } + + #[test] + fn batch_preflight_precedence_and_indices() { + let streamed_upload = OutboundRequest::post("https://example.com/upload") + .expect("request") + .body(Body::stream(stream::iter([Bytes::from_static(b"body")]))); + let streamed_response = OutboundRequest::get("https://example.com/response") + .expect("request") + .stream_response(); + let get_stream = OutboundRequest::get("https://example.com/get") + .expect("request") + .body(Body::stream(stream::iter([Bytes::new()]))); + + assert_eq!( + [streamed_upload, streamed_response, get_stream] + .iter() + .map(|request| message(validate_batch_request_for_test(request))) + .collect::>(), + [ + Some("outbound batches require buffered request bodies; use send for a streamed upload".to_owned()), + Some("outbound batches require buffered responses; use send for a streamed response".to_owned()), + Some("GET/HEAD request must not carry a streamed body; emptiness cannot be determined without consuming the stream".to_owned()), + ] + ); + } + + #[test] + fn opaque_fetch_rejection_does_not_claim_connection_phase_evidence() { + let error = generic_fetch_failure_for_test(); + assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Unspecified, + .. + } + )); + } + + #[test] + fn worker_budget_timeouts_preserve_every_selected_source() { + for selected in [ + BudgetSource::PerCallTimeout, + BudgetSource::RequestDeadline, + BudgetSource::Default, + ] { + assert!(matches!( + timeout_error_for_test(selected), + EdgeError::GatewayTimeout { cause, .. } if cause == selected + )); + } + } + } } diff --git a/crates/edgezero-adapter-cloudflare/tests/secret_store_bounded.rs b/crates/edgezero-adapter-cloudflare/tests/secret_store_bounded.rs new file mode 100644 index 000000000..4500230a1 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/tests/secret_store_bounded.rs @@ -0,0 +1,148 @@ +#![cfg(all(feature = "cloudflare", not(target_arch = "wasm32")))] + +#[path = "../src/secret_store.rs"] +#[expect( + dead_code, + reason = "the source harness exercises the private bounded-read helper without constructing a worker Env" +)] +mod secret_store; + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use bytes::Bytes; + use edgezero_core::secret_store::SecretError; + use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; + use futures::executor::block_on; + + use super::secret_store; + + #[test] + fn bounded_secret_reports_exact_bytes_and_accepts_exact_caps() { + let clock = MonotonicClock::default(); + let result = block_on(secret_store::bounded_secret_read( + async { Ok(Some(Bytes::from_static(b"value"))) }, + &clock, + Deadline::after(Duration::from_secs(1)), + 5, + 5, + )) + .expect("exact caps must succeed"); + + assert_eq!(result.backend_bytes, 5); + assert_eq!(result.value, Some(Bytes::from_static(b"value"))); + } + + #[test] + fn bounded_secret_rejects_either_exceeded_cap() { + for (max_backend_bytes, max_value_bytes) in [(4, 5), (5, 4)] { + let clock = MonotonicClock::default(); + let error = block_on(secret_store::bounded_secret_read( + async { Ok(Some(Bytes::from_static(b"value"))) }, + &clock, + Deadline::after(Duration::from_secs(1)), + max_backend_bytes, + max_value_bytes, + )) + .expect_err("an exceeded cap must fail"); + + assert!(matches!(error, SecretError::ValueTooLarge)); + } + } + + #[test] + fn bounded_secret_checks_deadline_before_polling_host_call() { + let polled = Cell::new(false); + let clock = MonotonicClock::default(); + let error = block_on(secret_store::bounded_secret_read( + async { + polled.set(true); + Ok(None) + }, + &clock, + Deadline::after(Duration::ZERO), + 1, + 1, + )) + .expect_err("expired deadline must fail"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + assert!(!polled.get(), "expired reads must not poll the host call"); + } + + #[test] + fn bounded_secret_checks_deadline_after_host_call() { + let start = MonotonicInstant::now(); + let terminal = start + .checked_add(Duration::from_secs(1)) + .expect("terminal instant"); + let now = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&now); + let clock = MonotonicClock::new(move || *clock_now.lock().expect("clock lock")); + let error = block_on(secret_store::bounded_secret_read( + async { + *now.lock().expect("clock lock") = terminal; + Ok(None) + }, + &clock, + Deadline::at_instant(terminal), + 1, + 1, + )) + .expect_err("a host call completing after the deadline must fail"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + } + + #[test] + fn bounded_secret_deadline_wins_over_late_host_error() { + let start = MonotonicInstant::now(); + let terminal = start + .checked_add(Duration::from_secs(1)) + .expect("terminal instant"); + let now = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&now); + let clock = MonotonicClock::new(move || *clock_now.lock().expect("clock lock")); + let error = block_on(secret_store::bounded_secret_read( + async { + *now.lock().expect("clock lock") = terminal; + Err(SecretError::Unavailable) + }, + &clock, + Deadline::at_instant(terminal), + 1, + 1, + )) + .expect_err("the post-call deadline check must run after host errors"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + } + + #[test] + fn bounded_secret_read_uses_injected_clock() { + let process_now = MonotonicInstant::now(); + let injected_now = process_now + .checked_sub(Duration::from_mins(1)) + .expect("injected instant"); + let clock = MonotonicClock::new(move || injected_now); + let deadline = Deadline::at_instant( + injected_now + .checked_add(Duration::from_secs(1)) + .expect("deadline instant"), + ); + + let result = block_on(secret_store::bounded_secret_read( + async { Ok(Some(Bytes::from_static(b"value"))) }, + &clock, + deadline, + 5, + 5, + )) + .expect("the application clock is still before its deadline"); + + assert_eq!(result.value, Some(Bytes::from_static(b"value"))); + } +} diff --git a/crates/edgezero-adapter-fastly/Cargo.toml b/crates/edgezero-adapter-fastly/Cargo.toml index 02ae5a831..c9b94bd6c 100644 --- a/crates/edgezero-adapter-fastly/Cargo.toml +++ b/crates/edgezero-adapter-fastly/Cargo.toml @@ -19,7 +19,8 @@ cli = [ "dep:toml_edit", "dep:walkdir", ] -fastly = ["dep:fastly", "dep:log-fastly"] +fastly = ["dep:fastly", "dep:fastly-shared", "dep:fastly-sys", "dep:log-fastly"] +test-utils = [] [dependencies] anyhow = { workspace = true } @@ -33,6 +34,8 @@ brotli = { workspace = true } bytes = { workspace = true } ctor = { workspace = true, optional = true } fastly = { workspace = true, optional = true } +fastly-shared = { workspace = true, optional = true } +fastly-sys = { workspace = true, optional = true } flate2 = { workspace = true } futures = { workspace = true } futures-util = { workspace = true } @@ -41,7 +44,7 @@ log-fastly = { workspace = true, optional = true } fern = { workspace = true } chrono = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } +serde_json = { workspace = true, features = ["raw_value"] } sha2 = { workspace = true } thiserror = { workspace = true } toml_edit = { workspace = true, optional = true } @@ -51,3 +54,6 @@ walkdir = { workspace = true, optional = true } edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } handlebars = { workspace = true } tempfile = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +log-fastly = { workspace = true, features = ["native-test-stubs"] } diff --git a/crates/edgezero-adapter-fastly/src/chunked_config.rs b/crates/edgezero-adapter-fastly/src/chunked_config.rs index c497b9f2a..1c8c5c041 100644 --- a/crates/edgezero-adapter-fastly/src/chunked_config.rs +++ b/crates/edgezero-adapter-fastly/src/chunked_config.rs @@ -19,7 +19,15 @@ //! } //! ``` +use serde::de::{IgnoredAny, MapAccess, Visitor}; +use serde_json::value::RawValue; use sha2::{Digest as _, Sha256}; +use std::fmt; + +#[cfg(any(feature = "fastly", test))] +use edgezero_core::config_store::ConfigStoreError; +#[cfg(any(feature = "fastly", test))] +use edgezero_core::{BoundedStoreRead, Deadline, MonotonicClock, MonotonicInstant}; /// Per-entry value limit enforced by Fastly Config Store. Used by the CLI writer /// to gate direct-vs-chunked storage, and by the pointer validator (which both @@ -48,6 +56,17 @@ pub(crate) const CHUNK_KEY_INFIX: &str = ".__edgezero_chunks."; /// (when validating the parsed pointer) -- stays unconditional. pub(crate) const POINTER_KIND: &str = "fastly_config_chunks"; +#[derive(Debug, Eq, PartialEq)] +#[cfg(any(feature = "fastly", test))] +pub(crate) struct FastlyReadTooLarge; + +#[derive(Debug)] +#[cfg(any(feature = "fastly", test))] +pub(crate) enum SyncHostCallError { + Backend(E), + DeadlineExceeded, +} + // --------------------------------------------------------------------------- // Private pointer schema // --------------------------------------------------------------------------- @@ -84,6 +103,17 @@ enum RootValueKind { UnknownKind, } +// Borrow only the two discriminator tokens. Skipped payloads must never become +// a Value tree before the extractor's application-owned structural preflight. +#[derive(Default)] +struct RootMetadata<'raw> { + ambiguous: bool, + edgezero_kind: Option<&'raw RawValue>, + version: Option<&'raw RawValue>, +} + +struct MetadataVisitor; + /// Why resolving a stored root value failed. Keeping the future-format case /// DISTINCT from corruption is the whole point: a newer format must never be /// overwritten (CLI) or read as repairable corruption, whereas genuinely corrupt @@ -91,6 +121,7 @@ enum RootValueKind { /// pointer resolution — a v2 envelope reassembled from v1 chunks is only knowable /// AFTER the chunks are fetched — so it cannot be recovered from the raw value /// alone. +#[derive(Debug)] pub(crate) enum ResolveFailure { /// Corrupt or incomplete state a push can repair by overwriting. Carries a /// redacted, human-readable message. @@ -101,6 +132,17 @@ pub(crate) enum ResolveFailure { FutureFormat(String), } +/// A bounded resolver failure that preserves store boundary errors instead of +/// misclassifying them as corrupt pointer state. +#[derive(Debug)] +#[cfg(any(feature = "fastly", test))] +pub(crate) enum BoundedResolveFailure { + Backend(ConfigStoreError), + DeadlineExceeded, + Resolve(ResolveFailure), + ValueTooLarge, +} + impl ResolveFailure { /// The redacted, human-readable message, discarding the category. pub(crate) fn into_message(self) -> String { @@ -111,7 +153,11 @@ impl ResolveFailure { /// Is this a newer format this build must not overwrite (vs. repairable /// corruption)? - #[cfg(any(feature = "cli", feature = "fastly", test))] + #[cfg(any( + feature = "fastly", + test, + all(feature = "cli", not(target_arch = "wasm32")) + ))] pub(crate) fn is_future_format(&self) -> bool { matches!(self, Self::FutureFormat(_)) } @@ -125,7 +171,7 @@ struct FastlyChunkRef { } /// A chunk reference from a validated pointer, for `config gc`. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) struct GcChunkRef { pub key: String, pub len: usize, @@ -133,7 +179,7 @@ pub(crate) struct GcChunkRef { } /// A validated v1 pointer's contents, for `config gc`. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) struct GcPointer { /// Validated: non-empty, one generation, dense `0..n-1` in order. pub chunks: Vec, @@ -142,7 +188,7 @@ pub(crate) struct GcPointer { } /// What a root's value IS, once classified for `config gc`. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) enum GcRootValue { /// A valid v1 chunk pointer. Its METADATA is validated; its CONTENT must /// still be verified against the store -- see [`gc_verify_generation`]. @@ -155,6 +201,145 @@ pub(crate) enum GcRootValue { // Public helpers // --------------------------------------------------------------------------- +impl<'de> serde::Deserialize<'de> for RootMetadata<'de> { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_map(MetadataVisitor) + } + + fn deserialize_in_place>( + deserializer: D, + place: &mut Self, + ) -> Result<(), D::Error> { + *place = Self::deserialize(deserializer)?; + Ok(()) + } +} + +#[expect( + clippy::missing_trait_methods, + reason = "deserialize_map only dispatches JSON objects to this metadata visitor" +)] +impl<'de> Visitor<'de> for MetadataVisitor { + type Value = RootMetadata<'de>; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a config metadata object") + } + + fn visit_map>(self, mut map: A) -> Result { + let mut metadata = RootMetadata::default(); + while let Some(key) = map.next_key::()? { + match key.as_str() { + "edgezero_kind" => { + metadata.ambiguous |= metadata.edgezero_kind.is_some(); + metadata.edgezero_kind = Some(map.next_value()?); + } + "version" => { + metadata.ambiguous |= metadata.version.is_some(); + metadata.version = Some(map.next_value()?); + } + _ => { + map.next_value::()?; + } + } + } + Ok(metadata) + } +} + +#[cfg(any(feature = "fastly", test))] +fn check_bounded_root( + root_key: &str, + root_value: &str, + remaining_backend: u64, + max_value_bytes: u64, +) -> Result<(), BoundedResolveFailure> { + match classify_root_value(root_value) { + RootValueKind::Pointer + if root_value.len() <= FASTLY_CONFIG_ENTRY_LIMIT + && !value_is_future_format(root_value) => + { + let pointer: FastlyChunkPointer = + serde_json::from_str(root_value).map_err(|_error| { + BoundedResolveFailure::Resolve(ResolveFailure::Corrupt( + "config chunk pointer is malformed".to_owned(), + )) + })?; + validate_pointer_chunks(root_key, &pointer) + .map_err(|error| BoundedResolveFailure::Resolve(ResolveFailure::Corrupt(error)))?; + let envelope_bytes = u64::try_from(pointer.envelope_len) + .map_err(|_error| BoundedResolveFailure::ValueTooLarge)?; + if envelope_bytes > max_value_bytes || envelope_bytes > remaining_backend { + return Err(BoundedResolveFailure::ValueTooLarge); + } + } + RootValueKind::Foreign | RootValueKind::MalformedObject => { + if u64::try_from(root_value.len()).map_or(true, |length| length > max_value_bytes) { + return Err(BoundedResolveFailure::ValueTooLarge); + } + } + RootValueKind::Pointer | RootValueKind::UnknownKind => {} + } + Ok(()) +} + +/// Account for the exact bytes materialized by a Fastly store operation. +#[cfg(any(feature = "fastly", test))] +pub(crate) fn exact_fastly_read( + value: Option, + max_backend_bytes: u64, +) -> Result, FastlyReadTooLarge> +where + T: AsRef<[u8]>, +{ + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.as_ref().len()).map_err(|_length_error| FastlyReadTooLarge) + })?; + if backend_bytes > max_backend_bytes { + return Err(FastlyReadTooLarge); + } + Ok(BoundedStoreRead { + backend_bytes, + value, + }) +} + +/// Run one synchronous Fastly operation under cooperative deadline checks. +/// +/// The host call itself is not preemptible. An overrun is observed immediately +/// after the call returns and its result is discarded. +#[cfg(any(feature = "fastly", test))] +pub(crate) fn run_sync_host_call( + clock: &MonotonicClock, + deadline: Deadline, + call: F, +) -> Result> +where + F: FnOnce() -> Result, +{ + run_sync_host_call_at(deadline, || clock.now(), call) +} + +#[cfg(any(feature = "fastly", test))] +fn run_sync_host_call_at( + deadline: Deadline, + mut now: N, + call: F, +) -> Result> +where + N: FnMut() -> MonotonicInstant, + F: FnOnce() -> Result, +{ + if deadline.instant() <= now() { + return Err(SyncHostCallError::DeadlineExceeded); + } + let result = call(); + if deadline.instant() <= now() { + return Err(SyncHostCallError::DeadlineExceeded); + } + result.map_err(SyncHostCallError::Backend) +} + /// Compute the lowercase-hex SHA-256 of `bytes`. pub(crate) fn sha256_hex(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) @@ -175,7 +360,7 @@ pub(crate) fn sha256_hex(bytes: &[u8]) -> String { /// characters (extremely unlikely in practice; recommends restructuring). /// Reject a physical Config Store key that exceeds the store's key limit, /// before any write is attempted. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] fn check_config_key_len(key: &str) -> Result<(), String> { // CHARACTERS, not bytes: Fastly's limit is a character count, so a non-ASCII // `--key` must be measured by `chars().count()`, not `len()` (UTF-8 bytes). @@ -192,7 +377,7 @@ fn check_config_key_len(key: &str) -> Result<(), String> { Ok(()) } -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) fn prepare_fastly_config_entries( root_key: &str, envelope_json: &str, @@ -386,7 +571,7 @@ pub(crate) fn verify_writer_split_layout( /// Only the GC path (`cli.rs`) needs it; the runtime resolver inlines the same /// mapping, so this is gated to the `cli` feature to stay dead-code-free in the /// guest build. -#[cfg(feature = "cli")] +#[cfg(all(feature = "cli", not(target_arch = "wasm32")))] pub(crate) fn chunk_lengths(chunks: &[GcChunkRef]) -> Vec { chunks.iter().map(|chunk| chunk.len).collect() } @@ -417,9 +602,21 @@ pub(crate) fn chunk_lengths(chunks: &[GcChunkRef]) -> Vec { /// not be overwritten) from repairable corruption. [`resolve_fastly_config_value`] /// is the string-erased wrapper for callers that do not need the distinction. pub(crate) fn resolve_fastly_config_value_typed( + root_key: &str, + root_value: String, + fetch: F, +) -> Result +where + F: FnMut(&str) -> Result, String>, +{ + resolve_fastly_config_value_inner(root_key, root_value, fetch, true) +} + +fn resolve_fastly_config_value_inner( root_key: &str, root_value: String, mut fetch: F, + verify_inner_envelope: bool, ) -> Result where F: FnMut(&str) -> Result, String>, @@ -499,11 +696,14 @@ where // Fetch, verify, and concatenate all chunks. // - // NOT `with_capacity(pointer.envelope_len)`: that length is untrusted stored - // metadata, and this runs in the edge guest. A pointer declaring `usize::MAX` - // would abort the worker on a capacity overflow before any of the checks - // below could reject it. Grow from the bytes we actually fetch instead. + // Metadata has already passed checked length summation and per-chunk caps. + // The bounded caller also checks both aggregate allowances before this point. let mut reconstructed = String::new(); + reconstructed + .try_reserve_exact(pointer.envelope_len) + .map_err(|_error| { + ResolveFailure::Corrupt("config reassembly allocation failed".to_owned()) + })?; for (position, chunk_ref) in pointer.chunks.iter().enumerate() { let chunk_value = fetch(&chunk_ref.key) .map_err(|_err| { @@ -553,8 +753,7 @@ where } // Verify total envelope SHA. - let actual_env_sha = sha256_hex(reconstructed.as_bytes()); - if actual_env_sha != pointer.envelope_sha256 { + if sha256_hex(reconstructed.as_bytes()) != pointer.envelope_sha256 { // Neither SHA is echoed: the expected one is `pointer.envelope_sha256`, // a stored, value-controlled string that a malformed pointer can set to // anything (a secret), and this runs on the read path where diagnostics @@ -569,7 +768,145 @@ where verify_writer_split_layout(root_key, &reconstructed, &declared_lens) .map_err(ResolveFailure::Corrupt)?; - finalize_reconstructed_envelope(root_key, reconstructed) + if verify_inner_envelope { + finalize_reconstructed_envelope(root_key, reconstructed) + } else { + // This path verifies storage transport integrity, not typed config. + // The extractor owns envelope verification after its structural scan, + // exactly as for a directly stored envelope. + Ok(reconstructed) + } +} + +/// Resolve a Fastly value while accounting for every materialized store byte. +/// +/// `fetch` receives the backend allowance left after the root pointer and all +/// prior chunks. Deadline checks are cooperative: they bracket each callback, +/// but cannot interrupt a synchronous host call already in progress. +#[cfg(any(feature = "fastly", test))] +#[expect( + clippy::too_many_arguments, + reason = "root accounting, the shared clock/deadline, both byte caps, and the chunk callback are independent contract inputs" +)] +pub(crate) fn resolve_fastly_config_value_typed_bounded( + root_key: &str, + root_value: String, + root_backend_bytes: u64, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + mut fetch: F, +) -> Result, BoundedResolveFailure> +where + F: FnMut(&str, u64) -> Result, ConfigStoreError>, +{ + if deadline.is_expired_at(clock.now()) { + return Err(BoundedResolveFailure::DeadlineExceeded); + } + + let root_value_bytes = u64::try_from(root_value.len()) + .map_err(|_length_error| BoundedResolveFailure::ValueTooLarge)?; + if root_backend_bytes != root_value_bytes || root_backend_bytes > max_backend_bytes { + return Err(BoundedResolveFailure::ValueTooLarge); + } + + let preflight = check_bounded_root( + root_key, + &root_value, + max_backend_bytes.saturating_sub(root_backend_bytes), + max_value_bytes, + ); + if deadline.is_expired_at(clock.now()) { + return Err(BoundedResolveFailure::DeadlineExceeded); + } + preflight?; + + let mut backend_bytes = root_backend_bytes; + let mut chunk_value_bytes = 0_u64; + let mut boundary_error = None; + let resolve_outcome = resolve_fastly_config_value_inner( + root_key, + root_value, + |chunk_key| { + if deadline.is_expired_at(clock.now()) { + boundary_error = Some(BoundedResolveFailure::DeadlineExceeded); + return Err("bounded config read failed".to_owned()); + } + + let Some(remaining_backend_bytes) = max_backend_bytes.checked_sub(backend_bytes) else { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + }; + let remaining_value_bytes = max_value_bytes.saturating_sub(chunk_value_bytes); + let read_limit = remaining_backend_bytes + .min(remaining_value_bytes) + .min(7_000); + let read = match fetch(chunk_key, read_limit) { + Ok(read) => read, + Err(error) => { + boundary_error = Some(BoundedResolveFailure::Backend(error)); + return Err("bounded config read failed".to_owned()); + } + }; + + if deadline.is_expired_at(clock.now()) { + boundary_error = Some(BoundedResolveFailure::DeadlineExceeded); + return Err("bounded config read failed".to_owned()); + } + + let value_bytes = match read.value.as_ref() { + Some(value) => { + let Ok(value_bytes) = u64::try_from(value.len()) else { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + }; + value_bytes + } + None => 0, + }; + if read.backend_bytes != value_bytes || read.backend_bytes > read_limit { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + } + + let Some(next_backend_bytes) = backend_bytes.checked_add(read.backend_bytes) else { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + }; + let Some(next_chunk_value_bytes) = chunk_value_bytes.checked_add(value_bytes) else { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + }; + if next_backend_bytes > max_backend_bytes || next_chunk_value_bytes > max_value_bytes { + boundary_error = Some(BoundedResolveFailure::ValueTooLarge); + return Err("bounded config read failed".to_owned()); + } + + backend_bytes = next_backend_bytes; + chunk_value_bytes = next_chunk_value_bytes; + Ok(read.value) + }, + false, + ); + + if deadline.is_expired_at(clock.now()) { + return Err(BoundedResolveFailure::DeadlineExceeded); + } + if let Some(error) = boundary_error { + return Err(error); + } + let resolved_value = resolve_outcome.map_err(BoundedResolveFailure::Resolve)?; + let resolved_bytes = u64::try_from(resolved_value.len()) + .map_err(|_length_error| BoundedResolveFailure::ValueTooLarge)?; + if resolved_bytes > max_value_bytes { + return Err(BoundedResolveFailure::ValueTooLarge); + } + + Ok(BoundedStoreRead { + backend_bytes, + value: Some(resolved_value), + }) } /// The final gate the chunked path shares with the direct one: reject a NEWER @@ -647,7 +984,7 @@ where /// but fails validation: malformed, unsupported `version`, or a /// referenced key falls outside this root's chunk prefix. Callers log /// `msg` as a warning and skip GC for this root (delete nothing). -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) fn prior_chunk_keys(root_key: &str, raw: &str) -> Result, String> { // 1. Parse loosely. Not-JSON, or not our pointer kind => silent. let value: serde_json::Value = match serde_json::from_str(raw) { @@ -852,17 +1189,25 @@ fn classify_root_value(raw: &str) -> RootValueKind { } // It LOOKS like an object. If it does not parse, it is a malformed object -- // possibly a truncated/corrupt pointer -- NOT a value GC may assume is inert. - let Ok(value) = serde_json::from_str::(raw) else { + let Ok(value) = serde_json::from_str::>(raw) else { return RootValueKind::MalformedObject; }; + if value.ambiguous { + return RootValueKind::UnknownKind; + } // Discriminate on the PRESENCE of `edgezero_kind`, not just a string match. A // field of ANY type claims our reserved namespace, so a non-string value // (`edgezero_kind: 7`, an object, null) is `UnknownKind`, never `Foreign` -- // otherwise GC would wrongly treat it as an inert value and reclaim chunks a // future/foreign format still references. - match value.get("edgezero_kind") { + match value.edgezero_kind { None => RootValueKind::Foreign, - Some(serde_json::Value::String(kind)) if kind == POINTER_KIND => RootValueKind::Pointer, + Some(kind) + if serde_json::from_str::(kind.get()) + .is_ok_and(|token| token == POINTER_KIND) => + { + RootValueKind::Pointer + } Some(_) => RootValueKind::UnknownKind, } } @@ -885,7 +1230,7 @@ pub(crate) fn value_is_pointer_kind(raw: &str) -> bool { /// a malformed object (a possible truncated/corrupt pointer whose chunk /// references we cannot read), an unknown-kind value, and a pointer: those must /// fail closed, because assuming they reference no chunks could orphan live ones. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) fn value_is_inert_foreign(raw: &str) -> bool { matches!(classify_root_value(raw), RootValueKind::Foreign) } @@ -898,7 +1243,7 @@ pub(crate) fn value_is_inert_foreign(raw: &str) -> bool { /// envelope fragment that announces nothing, so anything that DOES announce is /// suspicious (a parked pointer, a future-format value) and must be classified, /// not blindly treated as a deletable fragment. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) fn value_announces_our_kind(raw: &str) -> bool { matches!( classify_root_value(raw), @@ -940,19 +1285,27 @@ pub(crate) fn value_is_unknown_kind(raw: &str) -> bool { /// the resolver is compiled regardless of feature set. pub(crate) fn value_is_future_format(raw: &str) -> bool { use edgezero_core::blob_envelope::ENVELOPE_VERSION_V1; - let Ok(serde_json::Value::Object(obj)) = serde_json::from_str::(raw) else { + let Ok(obj) = serde_json::from_str::>(raw) else { return false; }; + if obj.ambiguous { + return true; + } // A `version` field that is PRESENT but not EXACTLY the JSON integer 1 is a // newer format. Keying on `as_u64()` alone fails open: `"2"`, `-1`, `2.5`, or // `1.0` all yield `None` and would slip through as repairable corruption. Any // present-and-not-integer-1 version fails closed. - let version_is_not_one = obj - .get("version") - .is_some_and(|version| version.as_u64() != Some(u64::from(ENVELOPE_VERSION_V1))); - match obj.get("edgezero_kind") { + let version_is_not_one = obj.version.is_some_and(|version| { + serde_json::from_str::(version.get()).ok() != Some(u64::from(ENVELOPE_VERSION_V1)) + }); + match obj.edgezero_kind { // Our pointer kind, but a newer POINTER version. - Some(serde_json::Value::String(kind)) if kind == POINTER_KIND => version_is_not_one, + Some(kind) + if serde_json::from_str::(kind.get()) + .is_ok_and(|token| token == POINTER_KIND) => + { + version_is_not_one + } // A discriminator we do not recognise (unknown string, or non-string). Some(_) => true, // No discriminator: any object carrying a bumped `version` is a newer @@ -980,7 +1333,7 @@ pub(crate) fn value_is_future_format(raw: &str) -> bool { /// every metadata check still passes while the dropped chunk silently leaves the /// live set. Callers MUST reconstruct the referenced chunks and put them through /// [`gc_verify_generation`] before trusting the live set. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) fn gc_classify_root(root_key: &str, raw: &str) -> Result { use edgezero_core::blob_envelope::BlobEnvelope; @@ -1078,7 +1431,7 @@ pub(crate) fn gc_classify_root(root_key: &str, raw: &str) -> Result Result<(), String> { use edgezero_core::blob_envelope::BlobEnvelope; @@ -1119,7 +1472,7 @@ pub(crate) fn gc_verify_generation(generation_sha: &str, assembled: &str) -> Res /// This is how reclamation groups the store's ACTUAL keys into generations. It /// validates the shape (`.__edgezero_chunks..`) rather /// than trusting it: a hand-edited or foreign key never becomes a delete target. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) fn chunk_key_generation(root_key: &str, key: &str) -> Option { chunk_key_parts(root_key, key).map(|(generation, _)| generation) } @@ -1130,7 +1483,7 @@ pub(crate) fn chunk_key_generation(root_key: &str, key: &str) -> Option /// `config gc` uses this to order a proven generation's chunks by writer index /// before deleting, so preview and failure-recovery order do not depend on the /// remote listing order. -#[cfg(any(feature = "cli", test))] +#[cfg(any(test, all(feature = "cli", not(target_arch = "wasm32"))))] pub(crate) fn chunk_key_index(root_key: &str, key: &str) -> Option { chunk_key_parts(root_key, key).map(|(_, index)| index) } @@ -1192,8 +1545,407 @@ fn canonical_index(index: &str) -> Option { mod tests { use std::collections::HashMap; + use std::time::Duration; use super::*; + use edgezero_core::blob_envelope::BlobEnvelope; + use edgezero_core::{BoundedStoreRead, Deadline, MonotonicInstant}; + + fn unexpected_chunk_fetch( + _key: &str, + _remaining_backend_bytes: u64, + ) -> Result, ConfigStoreError> { + Err(ConfigStoreError::unavailable("unexpected chunk fetch")) + } + + #[test] + fn exact_fastly_read_accepts_exact_bytes_and_rejects_one_over() { + let exact = exact_fastly_read(Some(bytes::Bytes::from_static(b"secret")), 6) + .expect("an exact-sized value must succeed"); + assert_eq!(exact.backend_bytes, 6); + assert_eq!(exact.value.as_deref(), Some(b"secret".as_slice())); + + let error = exact_fastly_read(Some(bytes::Bytes::from_static(b"secret")), 5) + .expect_err("one byte over the cap must fail"); + assert_eq!(error, FastlyReadTooLarge); + } + + #[test] + fn synchronous_host_call_checks_deadline_before_and_after_operation() { + let start = MonotonicInstant::now(); + let deadline = Deadline::at_instant( + start + .checked_add(Duration::from_secs(1)) + .expect("deadline instant"), + ); + let after_deadline = start + .checked_add(Duration::from_secs(2)) + .expect("post-deadline instant"); + let mut observations = [start, after_deadline].into_iter(); + let mut calls = 0_usize; + + let error = run_sync_host_call_at( + deadline, + || observations.next().expect("clock observation"), + || { + calls += 1; + Ok::<_, ()>("materialized") + }, + ) + .expect_err("an operation completing after the deadline must fail"); + assert!(matches!(error, SyncHostCallError::DeadlineExceeded)); + assert_eq!( + calls, 1, + "the deadline elapsed while the call was in flight" + ); + + let mut backend_observations = [start, after_deadline].into_iter(); + let backend_error = run_sync_host_call_at( + deadline, + || backend_observations.next().expect("clock observation"), + || Err::<(), _>("backend failed"), + ) + .expect_err("post-call deadline must also be checked after backend failure"); + assert!(matches!(backend_error, SyncHostCallError::DeadlineExceeded)); + + let mut called_after_expiry = false; + let preflight_error = run_sync_host_call_at( + deadline, + || after_deadline, + || { + called_after_expiry = true; + Ok::<_, ()>("unreachable") + }, + ) + .expect_err("an already-expired operation must fail"); + assert!(matches!( + preflight_error, + SyncHostCallError::DeadlineExceeded + )); + assert!(!called_after_expiry, "an expired call must not be started"); + } + + #[test] + fn bounded_failures_preserve_backend_and_resolver_payloads() { + let backend_failure = BoundedResolveFailure::Backend(ConfigStoreError::unavailable( + "temporary store failure", + )); + match backend_failure { + BoundedResolveFailure::Backend(error) => drop(error), + BoundedResolveFailure::DeadlineExceeded + | BoundedResolveFailure::Resolve(_) + | BoundedResolveFailure::ValueTooLarge => { + panic!("backend failure variant changed"); + } + } + + let resolve_failure = + BoundedResolveFailure::Resolve(ResolveFailure::Corrupt("invalid pointer".to_owned())); + match resolve_failure { + BoundedResolveFailure::Resolve(error) => { + assert_eq!(error.into_message(), "invalid pointer"); + } + BoundedResolveFailure::Backend(_) + | BoundedResolveFailure::DeadlineExceeded + | BoundedResolveFailure::ValueTooLarge => { + panic!("resolver failure variant changed"); + } + } + + let start = MonotonicInstant::now(); + let deadline = Deadline::at_instant( + start + .checked_add(Duration::from_secs(1)) + .expect("deadline instant"), + ); + let mut observations = [start, start].into_iter(); + let backend_error = run_sync_host_call_at( + deadline, + || observations.next().expect("clock observation"), + || Err::<(), _>("backend failed"), + ) + .expect_err("backend failure must remain inspectable"); + match backend_error { + SyncHostCallError::Backend(message) => assert_eq!(message, "backend failed"), + SyncHostCallError::DeadlineExceeded => panic!("deadline must not win"), + } + + let clock = MonotonicClock::default(); + let wrapper_result = + run_sync_host_call(&clock, Deadline::after(Duration::from_secs(1)), || { + Ok::<_, ()>("materialized") + }) + .expect("wrapper call before its deadline must succeed"); + assert_eq!(wrapper_result, "materialized"); + } + + #[test] + fn metadata_classification_rejects_duplicate_reserved_fields_and_arrays() { + // Duplicate JSON members intentionally cannot be constructed with json!. + for raw in [ + r#"{"version":1,"version":2}"#, + r#"{"version":1,"version":1}"#, + r#"{"edgezero_kind":"fastly_config_chunks","edgezero_kind":"future"}"#, + ] { + assert!( + value_is_future_format(raw), + "ambiguous metadata must never be overwrite-safe" + ); + } + assert!( + !value_is_future_format("[null,2]"), + "arrays are not envelope metadata objects" + ); + } + + #[test] + fn bounded_resolver_rejects_declared_overflow_before_fetching() { + let root_key = "app_config"; + let envelope = serde_json::to_string(&BlobEnvelope::new( + serde_json::json!({ "pad": "x".repeat(9_000) }), + "2026-01-01T00:00:00Z".to_owned(), + )) + .expect("envelope"); + let mut entries = prepare_fastly_config_entries(root_key, &envelope).expect("entries"); + let (_, pointer) = entries.pop().expect("pointer"); + let pointer_bytes = u64::try_from(pointer.len()).expect("pointer bytes"); + let envelope_bytes = u64::try_from(envelope.len()).expect("envelope bytes"); + for (backend_limit, value_limit) in [ + (u64::MAX, envelope_bytes - 1), + (pointer_bytes + envelope_bytes - 1, envelope_bytes), + ] { + let mut calls = 0_u32; + let error = resolve_fastly_config_value_typed_bounded( + root_key, + pointer.clone(), + pointer_bytes, + &MonotonicClock::default(), + Deadline::after(Duration::from_secs(1)), + backend_limit, + value_limit, + |_key, _remaining| { + calls += 1; + Ok(BoundedStoreRead { + backend_bytes: 0, + value: None, + }) + }, + ) + .expect_err("declared overflow"); + assert!(matches!(error, BoundedResolveFailure::ValueTooLarge)); + assert_eq!(calls, 0, "reject metadata before fetching or assembling"); + } + } + + #[test] + fn bounded_resolver_accepts_exact_direct_caps_and_rejects_over_cap() { + let clock = MonotonicClock::default(); + let exact = resolve_fastly_config_value_typed_bounded( + "root", + "value".to_owned(), + 5, + &clock, + Deadline::after(Duration::from_secs(1)), + 5, + 5, + unexpected_chunk_fetch, + ) + .expect("a direct value at both exact caps must succeed"); + + assert_eq!(exact.backend_bytes, 5); + assert_eq!(exact.value.as_deref(), Some("value")); + + let over_backend = resolve_fastly_config_value_typed_bounded( + "root", + "value".to_owned(), + 5, + &clock, + Deadline::after(Duration::from_secs(1)), + 4, + 5, + unexpected_chunk_fetch, + ) + .expect_err("a direct value over the backend cap must fail"); + assert!(matches!(over_backend, BoundedResolveFailure::ValueTooLarge)); + + let over_value = resolve_fastly_config_value_typed_bounded( + "root", + "value".to_owned(), + 5, + &clock, + Deadline::after(Duration::from_secs(1)), + 5, + 4, + unexpected_chunk_fetch, + ) + .expect_err("a direct value over the value cap must fail"); + assert!(matches!(over_value, BoundedResolveFailure::ValueTooLarge)); + } + + #[test] + fn bounded_resolver_deadline_wins_metadata_preflight_errors() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let start = edgezero_core::MonotonicInstant::now(); + let end = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let samples = Arc::new(AtomicUsize::new(0)); + let clock = MonotonicClock::new(move || { + if samples.fetch_add(1, Ordering::SeqCst) == 0 { + start + } else { + end + } + }); + let error = resolve_fastly_config_value_typed_bounded( + "root", + "value".to_owned(), + 5, + &clock, + Deadline::at_instant(end), + 5, + 4, + unexpected_chunk_fetch, + ) + .expect_err("deadline must win a simultaneously observed size failure"); + assert!(matches!(error, BoundedResolveFailure::DeadlineExceeded)); + } + + #[test] + fn bounded_resolver_rejects_expired_deadline_without_fetching() { + let mut fetched = false; + let clock = MonotonicClock::default(); + let error = resolve_fastly_config_value_typed_bounded( + "root", + "value".to_owned(), + 5, + &clock, + Deadline::after(Duration::ZERO), + 5, + 5, + |_key, _remaining_backend_bytes| { + fetched = true; + Ok(BoundedStoreRead { + backend_bytes: 0, + value: None, + }) + }, + ) + .expect_err("an expired deadline must fail"); + + assert!(matches!(error, BoundedResolveFailure::DeadlineExceeded)); + assert!(!fetched, "an expired read must not fetch chunks"); + } + + #[test] + fn bounded_resolver_counts_pointer_and_chunks_with_decreasing_allowance() { + let clock = MonotonicClock::default(); + let root_key = "app_config"; + let envelope = serde_json::to_string(&BlobEnvelope::new( + serde_json::json!({ "pad": "x".repeat(9_000) }), + "2026-01-01T00:00:00Z".to_owned(), + )) + .expect("envelope"); + let mut entries = prepare_fastly_config_entries(root_key, &envelope).expect("entries"); + let (_, pointer) = entries.pop().expect("root pointer"); + let chunks: HashMap<_, _> = entries.into_iter().collect(); + let pointer_bytes = u64::try_from(pointer.len()).expect("pointer length"); + let maybe_chunk_bytes = chunks.values().try_fold(0_u64, |total, chunk| { + total.checked_add(u64::try_from(chunk.len()).ok()?) + }); + let chunk_bytes = maybe_chunk_bytes.expect("chunk byte sum"); + let total_backend_bytes = pointer_bytes + .checked_add(chunk_bytes) + .expect("backend byte sum"); + let mut remaining_allowances = Vec::new(); + + let resolved = resolve_fastly_config_value_typed_bounded( + root_key, + pointer.clone(), + pointer_bytes, + &clock, + Deadline::after(Duration::from_secs(1)), + total_backend_bytes, + u64::try_from(envelope.len()).expect("envelope length"), + |chunk_key, remaining_backend_bytes| { + remaining_allowances.push(remaining_backend_bytes); + let value = chunks.get(chunk_key).cloned(); + Ok(BoundedStoreRead { + backend_bytes: value + .as_ref() + .map_or(0, |chunk| u64::try_from(chunk.len()).expect("chunk length")), + value, + }) + }, + ) + .expect("exact pointer and chunk caps must succeed"); + + assert_eq!(resolved.backend_bytes, total_backend_bytes); + assert_eq!(resolved.value.as_deref(), Some(envelope.as_str())); + assert_eq!(remaining_allowances.first(), Some(&chunk_bytes.min(7_000))); + assert!( + remaining_allowances + .windows(2) + .all(|pair| pair[1] < pair[0]), + "each chunk must receive a smaller backend allowance" + ); + + let error = resolve_fastly_config_value_typed_bounded( + root_key, + pointer, + pointer_bytes, + &clock, + Deadline::after(Duration::from_secs(1)), + total_backend_bytes - 1, + u64::try_from(envelope.len()).expect("envelope length"), + |chunk_key, _remaining_backend_bytes| { + let value = chunks.get(chunk_key).cloned(); + Ok(BoundedStoreRead { + backend_bytes: value + .as_ref() + .map_or(0, |chunk| u64::try_from(chunk.len()).expect("chunk length")), + value, + }) + }, + ) + .expect_err("one byte below the aggregate backend cap must fail"); + assert!(matches!(error, BoundedResolveFailure::ValueTooLarge)); + } + + #[test] + fn bounded_resolver_rejects_inconsistent_chunk_reports() { + let clock = MonotonicClock::default(); + let root_key = "app_config"; + let envelope = serde_json::to_string(&BlobEnvelope::new( + serde_json::json!({ "pad": "x".repeat(9_000) }), + "2026-01-01T00:00:00Z".to_owned(), + )) + .expect("envelope"); + let mut entries = prepare_fastly_config_entries(root_key, &envelope).expect("entries"); + let (_, pointer) = entries.pop().expect("root pointer"); + let chunks: HashMap<_, _> = entries.into_iter().collect(); + let pointer_bytes = u64::try_from(pointer.len()).expect("pointer length"); + + let error = resolve_fastly_config_value_typed_bounded( + root_key, + pointer, + pointer_bytes, + &clock, + Deadline::after(Duration::from_secs(1)), + u64::MAX, + u64::try_from(envelope.len()).expect("envelope length"), + |chunk_key, _remaining_backend_bytes| { + let value = chunks.get(chunk_key).cloned(); + Ok(BoundedStoreRead { + backend_bytes: 0, + value, + }) + }, + ) + .expect_err("a provider report smaller than its result must fail closed"); + + assert!(matches!(error, BoundedResolveFailure::ValueTooLarge)); + } /// the index must be one this writer could emit. The /// chunk loop counts in `usize`, so a digit run that overflows it is not @@ -1317,8 +2069,11 @@ mod tests { ("empty", String::new()), ("whitespace", " ".to_owned()), ("not json", "not-json-at-all".to_owned()), - ("unrelated json", r#"{"some":"value"}"#.to_owned()), - ("json scalar", "42".to_owned()), + ( + "unrelated json", + serde_json::json!({"some": "value"}).to_string(), + ), + ("json scalar", serde_json::json!(42_u32).to_string()), ("valid-JSON partial pointer", reserialise(&partial)), ]; for (label, raw) in cases { @@ -1400,9 +2155,15 @@ mod tests { let root = "app_config"; // `version` is typed `u8`; a string there makes serde quote it: // `invalid type: string "s3cr3t-do-not-log", expected u8`. - let malformed = format!( - r#"{{"edgezero_kind":"{POINTER_KIND}","version":"{SENTINEL}","chunks":[],"data_sha256":"","envelope_len":0,"envelope_sha256":""}}"# - ); + let malformed = serde_json::json!({ + "edgezero_kind": POINTER_KIND, + "version": SENTINEL, + "chunks": [], + "data_sha256": "", + "envelope_len": 0_u32, + "envelope_sha256": "", + }) + .to_string(); let Err(gc_err) = gc_classify_root(root, &malformed) else { panic!("a malformed pointer must fail closed on the gc path"); }; @@ -1509,7 +2270,10 @@ mod tests { for (label, value) in [ ("plain text", "just some plain text"), - ("unrelated json", r#"{"some":"value"}"#), + ( + "unrelated json", + &serde_json::json!({"some": "value"}).to_string(), + ), ("someone's real config", make_envelope_json(200).as_str()), ] { assert!( @@ -1533,10 +2297,15 @@ mod tests { fn pointer_key_does_not_leak_into_diagnostics() { const SENTINEL: &str = "prod-db-password=hunter2"; let root = "app_config"; - let malformed = format!( - r#"{{"edgezero_kind":"{POINTER_KIND}","version":1,"chunks":[{{"key":"{SENTINEL}","len":10,"sha256":"x"}}],"data_sha256":"","envelope_len":10,"envelope_sha256":"{}"}}"#, - "a".repeat(64) - ); + let malformed = serde_json::json!({ + "edgezero_kind": POINTER_KIND, + "version": 1_u32, + "chunks": [{"key": SENTINEL, "len": 10_u32, "sha256": "x"}], + "data_sha256": "", + "envelope_len": 10_u32, + "envelope_sha256": "a".repeat(64), + }) + .to_string(); let err = prior_chunk_keys(root, &malformed).expect_err("must warn"); assert!( !err.contains(SENTINEL), @@ -1553,18 +2322,32 @@ mod tests { let root = "app_config"; let sha = "a".repeat(64); let huge = usize::MAX; - let overflowing = format!( - r#"{{"edgezero_kind":"{POINTER_KIND}","version":1,"chunks":[{{"key":"{root}{CHUNK_KEY_INFIX}{sha}.0","len":{huge},"sha256":"x"}},{{"key":"{root}{CHUNK_KEY_INFIX}{sha}.1","len":{huge},"sha256":"y"}}],"data_sha256":"","envelope_len":{huge},"envelope_sha256":"{sha}"}}"# - ); + let overflowing = serde_json::json!({ + "edgezero_kind": POINTER_KIND, + "version": 1_u32, + "chunks": [ + {"key": format!("{root}{CHUNK_KEY_INFIX}{sha}.0"), "len": huge, "sha256": "x"}, + {"key": format!("{root}{CHUNK_KEY_INFIX}{sha}.1"), "len": huge, "sha256": "y"}, + ], + "data_sha256": "", + "envelope_len": huge, + "envelope_sha256": sha, + }) + .to_string(); assert!( prior_chunk_keys(root, &overflowing).is_err(), "chunk lengths that overflow when summed must be rejected" ); // A single absurd chunk: no overflow, but far beyond what we emit. - let oversized = format!( - r#"{{"edgezero_kind":"{POINTER_KIND}","version":1,"chunks":[{{"key":"{root}{CHUNK_KEY_INFIX}{sha}.0","len":{huge},"sha256":"x"}}],"data_sha256":"","envelope_len":{huge},"envelope_sha256":"{sha}"}}"# - ); + let oversized = serde_json::json!({ + "edgezero_kind": POINTER_KIND, + "version": 1_u32, + "chunks": [{"key": format!("{root}{CHUNK_KEY_INFIX}{sha}.0"), "len": huge, "sha256": "x"}], + "data_sha256": "", + "envelope_len": huge, + "envelope_sha256": sha, + }).to_string(); assert!( prior_chunk_keys(root, &oversized).is_err(), "a chunk larger than the writer's payload target must be rejected" @@ -1591,7 +2374,9 @@ mod tests { ); // Direct envelopes, unrelated JSON and non-JSON are not pointers either. assert!(!value_is_pointer_kind(&make_envelope_json(200))); - assert!(!value_is_pointer_kind(r#"{"some":"value"}"#)); + assert!(!value_is_pointer_kind( + &serde_json::json!({"some": "value"}).to_string() + )); assert!(!value_is_pointer_kind("not json at all")); } @@ -1605,7 +2390,7 @@ mod tests { // (raw, is_pointer, is_inert_foreign, announces_our_kind, is_unknown_kind) let cases: &[(&str, bool, bool, bool, bool)] = &[ ( - r#"{"edgezero_kind":"fastly_config_chunks"}"#, + &serde_json::json!({"edgezero_kind": "fastly_config_chunks"}).to_string(), true, false, true, @@ -1613,24 +2398,42 @@ mod tests { ), // Unknown STRING kind: claims our namespace, not inert, not our pointer. ( - r#"{"edgezero_kind":"fastly_config_chunks_v2"}"#, + &serde_json::json!({"edgezero_kind": "fastly_config_chunks_v2"}).to_string(), false, false, true, true, ), // NON-STRING kind: still claims the namespace -- must NOT be inert. - (r#"{"edgezero_kind":7}"#, false, false, true, true), - (r#"{"edgezero_kind":null}"#, false, false, true, true), ( - r#"{"edgezero_kind":{"nested":true}}"#, + &serde_json::json!({"edgezero_kind": 7_u64}).to_string(), + false, + false, + true, + true, + ), + ( + &serde_json::json!({"edgezero_kind": null}).to_string(), + false, + false, + true, + true, + ), + ( + &serde_json::json!({"edgezero_kind": {"nested": true}}).to_string(), false, false, true, true, ), // Genuinely foreign: no discriminator. - (r#"{"unrelated":"json"}"#, false, true, false, false), + ( + &serde_json::json!({"unrelated": "json"}).to_string(), + false, + true, + false, + false, + ), ("value_a", false, true, false, false), ("", false, true, false, false), // Object-shaped but unparseable (a possible truncated pointer): none. @@ -1678,38 +2481,48 @@ mod tests { // SCHEMA-CHANGING v2: a future shape that DROPS the v1 fields must still be // future -- detection keys on `version` alone, not the four v1 fields. assert!( - value_is_future_format(r#"{"version":2,"payload":{"k":"v"}}"#), + value_is_future_format( + &serde_json::json!({"version": 2_u64, "payload": {"k": "v"}}).to_string() + ), "schema-changing v2 (no v1 fields)" ); // A `version` PRESENT but not EXACTLY the JSON integer 1 is future, // whatever its JSON type -- `as_u64()` alone would let these slip through. for raw in [ - r#"{"version":"2"}"#, // string - r#"{"version":-1}"#, // negative integer - r#"{"version":2.5}"#, // float - r#"{"version":1.0}"#, // float, not the integer 1 - r#"{"version":null}"#, // present but null + json!({"version": "2"}).to_string(), + json!({"version": -1_i32}).to_string(), + json!({"version": 2.5_f64}).to_string(), + json!({"version": 1.0_f64}).to_string(), // float, not the integer 1 + json!({"version": null}).to_string(), ] { assert!( - value_is_future_format(raw), + value_is_future_format(&raw), "a non-integer-1 version must be future: {raw}" ); } // The exact JSON integer 1 (and an absent version) is NOT future. - assert!(!value_is_future_format(r#"{"version":1,"greeting":"hi"}"#)); - assert!(!value_is_future_format(r#"{"greeting":"hi"}"#)); + assert!(!value_is_future_format( + &json!({"version": 1_u32, "greeting": "hi"}).to_string() + )); + assert!(!value_is_future_format( + &serde_json::json!({"greeting": "hi"}).to_string() + )); // A v2 POINTER (our kind, bumped pointer version) -> future. assert!( value_is_future_format( - r#"{"edgezero_kind":"fastly_config_chunks","version":2,"chunks":[]}"# + &serde_json::json!({"edgezero_kind": "fastly_config_chunks", "version": 2_u64, "chunks": []}).to_string() ), "v2 pointer" ); // Unknown/non-string kind -> future. - assert!(value_is_future_format(r#"{"edgezero_kind":"future"}"#)); - assert!(value_is_future_format(r#"{"edgezero_kind":9}"#)); + assert!(value_is_future_format( + &serde_json::json!({"edgezero_kind": "future"}).to_string() + )); + assert!(value_is_future_format( + &serde_json::json!({"edgezero_kind": 9_u64}).to_string() + )); // A valid v1 envelope is NOT future. assert!(!value_is_future_format(&v1), "v1 envelope"); @@ -1720,10 +2533,12 @@ mod tests { assert!(!value_is_future_format(&bad_sha.to_string()), "v1 bad sha"); // A malformed v1 pointer MISSING its version is NOT future (repairable). assert!(!value_is_future_format( - r#"{"edgezero_kind":"fastly_config_chunks","chunks":[]}"# + &serde_json::json!({"edgezero_kind": "fastly_config_chunks", "chunks": []}).to_string() )); // A plain foreign value is not future. - assert!(!value_is_future_format(r#"{"unrelated":"json"}"#)); + assert!(!value_is_future_format( + &serde_json::json!({"unrelated": "json"}).to_string() + )); assert!(!value_is_future_format("not json")); } @@ -1768,9 +2583,17 @@ mod tests { // A metadata-consistent-looking pointer whose per-chunk length is far // over the writer's payload target (and whose envelope is too small to // have been chunked at all). - let bogus = format!( - r#"{{"edgezero_kind":"{POINTER_KIND}","version":1,"chunks":[{{"key":"{root}{CHUNK_KEY_INFIX}{sha}.0","len":99999,"sha256":"x"}},{{"key":"{root}{CHUNK_KEY_INFIX}{sha}.1","len":1,"sha256":"y"}}],"data_sha256":"","envelope_len":100000,"envelope_sha256":"{sha}"}}"# - ); + let bogus = serde_json::json!({ + "edgezero_kind": POINTER_KIND, + "version": 1_u32, + "chunks": [ + {"key": format!("{root}{CHUNK_KEY_INFIX}{sha}.0"), "len": 99_999_u32, "sha256": "x"}, + {"key": format!("{root}{CHUNK_KEY_INFIX}{sha}.1"), "len": 1_u32, "sha256": "y"}, + ], + "data_sha256": "", + "envelope_len": 100_000_u32, + "envelope_sha256": sha, + }).to_string(); // No fetch should even be attempted: fail on metadata alone. let mut fetched = false; let err = resolve_fastly_config_value(root, bogus, |_key| { @@ -1804,24 +2627,27 @@ mod tests { /// fetch fan-out. #[test] fn pointer_with_many_tiny_chunks_is_rejected() { - use std::fmt::Write as _; let root = "app_config"; let sha = "a".repeat(64); // 100 chunks of 100 bytes each = 10_000 bytes (> entry limit, dense). - let mut chunks = String::new(); - for idx in 0..100_u32 { - if idx > 0 { - chunks.push(','); - } - write!( - chunks, - r#"{{"key":"{root}{CHUNK_KEY_INFIX}{sha}.{idx}","len":100,"sha256":"x"}}"# - ) - .expect("write to String"); - } - let bogus = format!( - r#"{{"edgezero_kind":"{POINTER_KIND}","version":1,"chunks":[{chunks}],"data_sha256":"","envelope_len":10000,"envelope_sha256":"{sha}"}}"# - ); + let chunks: Vec<_> = (0..100_u32) + .map(|idx| { + serde_json::json!({ + "key": format!("{root}{CHUNK_KEY_INFIX}{sha}.{idx}"), + "len": 100_u32, + "sha256": "x", + }) + }) + .collect(); + let bogus = serde_json::json!({ + "edgezero_kind": POINTER_KIND, + "version": 1_u32, + "chunks": chunks, + "data_sha256": "", + "envelope_len": 10_000_u32, + "envelope_sha256": sha, + }) + .to_string(); let mut fetched = false; let err = resolve_fastly_config_value(root, bogus, |_key| { fetched = true; @@ -2216,7 +3042,7 @@ mod tests { #[test] fn empty_root_key_is_rejected() { assert!( - prepare_fastly_config_entries("", "{}").is_err(), + prepare_fastly_config_entries("", &serde_json::json!({}).to_string()).is_err(), "an empty key must be rejected on the direct path" ); let big = make_envelope_json(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); @@ -2244,7 +3070,8 @@ mod tests { // A root over the limit is rejected even for a DIRECT (unchunked) value. let over_limit_root = "r".repeat(FASTLY_CONFIG_KEY_LIMIT.saturating_add(1)); assert!( - prepare_fastly_config_entries(&over_limit_root, "{}").is_err(), + prepare_fastly_config_entries(&over_limit_root, &serde_json::json!({}).to_string()) + .is_err(), "a root key over the limit must be rejected on the direct path too" ); @@ -2260,7 +3087,7 @@ mod tests { multibyte_root.len() > FASTLY_CONFIG_KEY_LIMIT, "fixture must be multi-byte" ); - prepare_fastly_config_entries(&multibyte_root, "{}") + prepare_fastly_config_entries(&multibyte_root, &serde_json::json!({}).to_string()) .expect("a key at the CHARACTER limit must be accepted regardless of byte length"); } @@ -2438,9 +3265,9 @@ mod tests { // it is our reserved field, so an unrecognised value in it is an error, // never an ordinary entry. for raw in [ - r#"{"edgezero_kind":"fastly_config_chunks_v2"}"#, - r#"{"edgezero_kind":null}"#, - r#"{"edgezero_kind":7}"#, + &serde_json::json!({"edgezero_kind": "fastly_config_chunks_v2"}).to_string(), + &serde_json::json!({"edgezero_kind": null}).to_string(), + &serde_json::json!({"edgezero_kind": 7_u64}).to_string(), ] { let err = resolve_fastly_config_value("k", raw.to_owned(), |_chunk_key| { Err("fetch must not be called".to_owned()) @@ -2688,7 +3515,7 @@ mod tests { // A value that ANNOUNCES our pointer kind but is missing every other // required field. It claims to be ours, so it must error rather than // pass through as an ordinary value. - let malformed = format!(r#"{{"edgezero_kind":"{POINTER_KIND}"}}"#); + let malformed = serde_json::json!({"edgezero_kind": POINTER_KIND}).to_string(); let err = resolve_fastly_config_value("my_key", malformed, |_| Ok(None)) .expect_err("malformed pointer must error"); assert!( @@ -2766,14 +3593,18 @@ mod tests { #[test] fn prior_chunk_keys_returns_empty_for_unrelated_json() { assert_eq!( - prior_chunk_keys("app_config", r#"{"hello":"world"}"#).unwrap(), + prior_chunk_keys( + "app_config", + &serde_json::json!({"hello": "world"}).to_string() + ) + .unwrap(), Vec::::new() ); } #[test] fn prior_chunk_keys_returns_empty_for_wrong_kind() { - let raw = r#"{"edgezero_kind":"other","version":1,"chunks":[],"data_sha256":"","envelope_len":0,"envelope_sha256":""}"#; + let raw = &serde_json::json!({"edgezero_kind": "other", "version": 1_u64, "chunks": [], "data_sha256": "", "envelope_len": 0_u64, "envelope_sha256": ""}).to_string(); assert_eq!( prior_chunk_keys("app_config", raw).unwrap(), Vec::::new() @@ -2889,7 +3720,8 @@ mod tests { // be silently dropped by a failed struct deserialize. #[test] fn prior_chunk_keys_warns_on_pointer_kind_with_missing_fields() { - let raw = r#"{"edgezero_kind":"fastly_config_chunks","version":2}"#; + let raw = &serde_json::json!({"edgezero_kind": "fastly_config_chunks", "version": 2_u64}) + .to_string(); let err = prior_chunk_keys("app_config", raw) .expect_err("pointer-kind but malformed must warn, not Ok([])"); assert!(!err.is_empty(), "{err}"); diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index efbcccbee..bc17e9488 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -29,8 +29,9 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterDeployContext, AdapterPushContext, DeployOwnership, - DeployStoreIds, ProvisionStores, ReadConfigEntry, ResolvedStoreId, register_adapter, + Adapter, AdapterAction, AdapterDeployContext, AdapterExecutionTarget, AdapterPushContext, + DeployOwnership, DeployStoreIds, ProvisionStores, ReadConfigEntry, ResolvedStoreId, + register_adapter, }; use edgezero_adapter::release::{VerifiedApplicationRelease, verify_application_release}; use edgezero_adapter::scaffold::{ @@ -38,6 +39,7 @@ use edgezero_adapter::scaffold::{ ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, }; use edgezero_core::env_config::{EnvConfig, merge_env_defaults}; +use edgezero_core::{Capability, CapabilitySupport, PlatformMetadata}; use walkdir::WalkDir; static FASTLY_ADAPTER: FastlyCliAdapter = FastlyCliAdapter; @@ -2034,6 +2036,35 @@ fn owns_managed_deploy(context: &AdapterDeployContext) -> bool { reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`)." )] impl Adapter for FastlyCliAdapter { + fn capability(&self, capability: Capability) -> CapabilitySupport { + match capability { + Capability::IngressAdmission + | Capability::OutboundAuthorityOverride + | Capability::OutboundCacheBypass + | Capability::OutboundHeaderFidelity => CapabilitySupport::Native, + Capability::ConfigReadDeadlines + | Capability::InboundReadDeadlines + | Capability::IngressAdmissionAbort + | Capability::LazyStreamedResponsePassthrough + | Capability::OutboundBatchCancellation + | Capability::OutboundBatchCompletionOrder + | Capability::OutboundBatchSlotIsolation + | Capability::OutboundDeadlines + | Capability::OutboundFlexiblePhaseBudget + | Capability::OutboundHttp + | Capability::ResponseEgressAbort + | Capability::ResponseEgressBackpressure + | Capability::ResponseEgressCompletion + | Capability::ResponseWriteDeadlines + | Capability::StreamedUploadDeadlines => CapabilitySupport::BestEffort, + Capability::ConfigReadAllocationBounds + | Capability::OutboundCompleteResourceAccounting + | Capability::RawIngressFramingValidation + | Capability::RawIngressHeadLimits + | _ => CapabilitySupport::Unsupported, + } + } + fn deploy(&self, context: &AdapterDeployContext, args: &[String]) -> Result<(), String> { if owns_managed_deploy(context) { deploy_managed_with_context(context, args) @@ -2063,7 +2094,9 @@ impl Adapter for FastlyCliAdapter { log::info!("[edgezero] Fastly build complete -> {}", artifact.display()); Ok(()) } - AdapterAction::Deploy => deploy(args), + AdapterAction::Deploy => { + Err("Fastly deployment requires a typed deployment context".to_owned()) + } AdapterAction::Serve => serve(args), AdapterAction::DeployStaging => Err( "Fastly staging requires typed deploy context and --application-release".to_owned(), @@ -2075,6 +2108,38 @@ impl Adapter for FastlyCliAdapter { } } + fn execute_target( + &self, + action: AdapterAction, + target: &AdapterExecutionTarget, + args: &[String], + ) -> Result<(), String> { + let manifest = target_manifest(target)?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "pinned fastly manifest has no parent directory".to_owned())?; + match action { + AdapterAction::Build => { + let artifact = build_from_manifest(&manifest, args)?; + log::info!("[edgezero] Fastly build complete -> {}", artifact.display()); + Ok(()) + } + AdapterAction::Deploy | AdapterAction::DeployStaging => { + Err("Fastly deployment requires a typed deployment context".to_owned()) + } + AdapterAction::Serve => serve_from_dir(manifest_dir, args), + AdapterAction::AuthLogin + | AdapterAction::AuthLogout + | AdapterAction::AuthStatus + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback + | _ => Err(format!( + "fastly adapter cannot execute operational action {action:?} against a pinned runtime target" + )), + } + } + fn finalize_deploy( &self, context: &AdapterDeployContext, @@ -2123,6 +2188,10 @@ impl Adapter for FastlyCliAdapter { "fastly" } + fn platform_metadata(&self) -> PlatformMetadata { + crate::FASTLY_PLATFORM + } + fn preflight_config_write(&self, key: &str, body: &str) -> Result<(), String> { // Reject an infeasible push here, BEFORE the CLI's remote read, so it // fails offline rather than after a list/describe. The write path @@ -5290,6 +5359,10 @@ fn no_matching_store_error(name: &str) -> String { pub fn build(extra_args: &[String]) -> Result { let manifest = find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + build_from_manifest(&manifest, extra_args) +} + +fn build_from_manifest(manifest: &Path, extra_args: &[String]) -> Result { let manifest_dir = manifest .parent() .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; @@ -5348,17 +5421,6 @@ fn build_compute_deploy_args(extra_args: &[String]) -> Vec { argv } -/// Legacy direct entry point for callers using [`AdapterAction::Deploy`]. -/// `EdgeZero`'s main deploy path uses the typed [`Adapter::deploy`] hook. -/// -/// # Errors -/// Returns an error when the Fastly CLI cannot deploy the package. -#[inline] -pub fn deploy(extra_args: &[String]) -> Result<(), String> { - let context = legacy_deploy_context(extra_args, false); - deploy_with_context(&context, extra_args) -} - fn deploy_with_context( context: &AdapterDeployContext, extra_args: &[String], @@ -5484,7 +5546,10 @@ pub fn serve(extra_args: &[String]) -> Result<(), String> { let manifest_dir = manifest .parent() .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; + serve_from_dir(manifest_dir, extra_args) +} +fn serve_from_dir(manifest_dir: &Path, extra_args: &[String]) -> Result<(), String> { let status = Command::new("fastly") .args(["compute", "serve"]) .args(extra_args) @@ -6414,6 +6479,19 @@ fn fastly_api_put(path: &str, token: &str) -> Result { } } +fn target_manifest(target: &AdapterExecutionTarget) -> Result { + let manifest = target + .platform_manifest() + .map_or_else(|| target.app_root().join("fastly.toml"), Path::to_path_buf); + if !manifest.is_file() { + return Err(format!( + "pinned fastly manifest {} is not a regular file", + manifest.display() + )); + } + Ok(manifest) +} + /// `PUT https://api.fastly.com` and return a non-empty response body. /// This is used for mutations, such as cloning a version, whose response /// identifies the newly-created provider object needed for recovery. @@ -6442,25 +6520,16 @@ fn fastly_api_put_capture(path: &str, token: &str) -> Result { Ok(body.to_owned()) } -fn legacy_deploy_context(args: &[String], staging: bool) -> AdapterDeployContext { - AdapterDeployContext { - adapter_manifest_path: arg_value(args, "--manifest-path").map(PathBuf::from), - application_manifest_path: None, - application_release_root: None, - service_id: arg_value(args, "--service-id").map(str::to_owned), - stores: DeployStoreIds::default(), - staging, - variable_defaults: BTreeMap::default(), - } -} - -/// Resolve the directory containing the Fastly manifest selected by the -/// application manifest. Fall back to discovery for direct adapter callers. +/// Resolve only within the already-selected application, never by cwd discovery. fn resolve_deploy_manifest_path(context: &AdapterDeployContext) -> Result { if let Some(path) = context.adapter_manifest_path.as_deref() { return Ok(path.to_path_buf()); } - find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path()) + let target = context + .execution_target + .as_ref() + .ok_or_else(|| "Fastly deployment requires a pinned execution target".to_owned())?; + target_manifest(target) } /// Production companion to `deploy`: resolve the active service version via the @@ -6768,27 +6837,161 @@ mod tests { use edgezero_core::env_config::EnvConfig; #[cfg(unix)] use edgezero_core::test_env::{EnvOverride, PathPrepend}; - #[cfg(unix)] - use std::collections::BTreeMap; - use std::collections::HashSet; - #[cfg(unix)] - use std::iter::once; + use std::collections::{BTreeMap, HashSet}; + use std::num::NonZeroU32; #[cfg(unix)] use std::os::unix::fs::PermissionsExt as _; + #[cfg(unix)] use std::sync::Mutex; use tempfile::tempdir; - // Shared fixture names. Pinning these as consts (instead of - // inline `"sessions"` / `"app_config"` per call site) keeps the - // setup-vs-assertion pair in sync -- a typo in one place no - // longer silently divorces from the other, because both reference - // the same const. Also names the intent: these are the LOGICAL - // store ids the fastly adapter operates on, not arbitrary strings. - const TEST_KV_ID: &str = "sessions"; + // Logical store ids shared by setup and assertions. const TEST_CONFIG_ID: &str = "app_config"; + const TEST_KV_ID: &str = "sessions"; const TEST_SECRET_ID: &str = "default"; + #[test] + fn adapter_capability_matrix_matches_contracts() { + adapter_capability_matrix_matches_outbound_spec(); + } + + #[test] + fn adapter_capability_matrix_matches_outbound_spec() { + let expected = [ + ( + Capability::ConfigReadAllocationBounds, + CapabilitySupport::Unsupported, + ), + ( + Capability::ConfigReadDeadlines, + CapabilitySupport::BestEffort, + ), + ( + Capability::InboundReadDeadlines, + CapabilitySupport::BestEffort, + ), + (Capability::IngressAdmission, CapabilitySupport::Native), + ( + Capability::IngressAdmissionAbort, + CapabilitySupport::BestEffort, + ), + ( + Capability::RawIngressFramingValidation, + CapabilitySupport::Unsupported, + ), + ( + Capability::RawIngressHeadLimits, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressAbort, + CapabilitySupport::BestEffort, + ), + ( + Capability::ResponseEgressBackpressure, + CapabilitySupport::BestEffort, + ), + ( + Capability::ResponseEgressCompletion, + CapabilitySupport::BestEffort, + ), + ( + Capability::ResponseWriteDeadlines, + CapabilitySupport::BestEffort, + ), + (Capability::OutboundHttp, CapabilitySupport::BestEffort), + ( + Capability::OutboundCompleteResourceAccounting, + CapabilitySupport::Unsupported, + ), + ( + Capability::OutboundHeaderFidelity, + CapabilitySupport::Native, + ), + (Capability::OutboundDeadlines, CapabilitySupport::BestEffort), + ( + Capability::OutboundFlexiblePhaseBudget, + CapabilitySupport::BestEffort, + ), + ( + Capability::OutboundAuthorityOverride, + CapabilitySupport::Native, + ), + ( + Capability::OutboundBatchCancellation, + CapabilitySupport::BestEffort, + ), + ( + Capability::OutboundBatchCompletionOrder, + CapabilitySupport::BestEffort, + ), + ( + Capability::OutboundBatchSlotIsolation, + CapabilitySupport::BestEffort, + ), + (Capability::OutboundCacheBypass, CapabilitySupport::Native), + ( + Capability::StreamedUploadDeadlines, + CapabilitySupport::BestEffort, + ), + ( + Capability::LazyStreamedResponsePassthrough, + CapabilitySupport::BestEffort, + ), + ]; + + for (capability, support) in expected { + assert_eq!( + FASTLY_ADAPTER.capability(capability), + support, + "{capability:?}" + ); + } + } + + #[test] + fn adapter_platform_metadata_matches_runtime_metadata() { + let metadata = FASTLY_ADAPTER.platform_metadata(); + let source = edgezero_core::PlatformResourceSource::PlatformLimit { + provider: "Fastly Compute", + }; + + assert_eq!(metadata, crate::FASTLY_PLATFORM); + assert_eq!( + metadata.memory_ceiling(), + edgezero_core::PlatformFact::known( + edgezero_core::MemoryCeiling::new( + 128_000_000, + edgezero_core::MemoryCeilingScope::PerExecution, + Some(1_000_000), + ), + source, + ) + ); + assert_eq!( + metadata.inbound_request_population_bound(), + edgezero_core::PlatformFact::known( + edgezero_core::InboundRequestPopulationBound::new(NonZeroU32::MIN), + source, + ) + ); + assert_eq!( + metadata + .inbound_request_population_bound() + .value() + .expect("Fastly publishes the per-execution population") + .max_live_requests(), + NonZeroU32::MIN + ); + assert_eq!( + metadata.host_ingress_memory_accounting(), + edgezero_core::PlatformFact::unknown( + edgezero_core::PlatformUnknownReason::ProviderUnpublished + ) + ); + } + // `PathPrepend` (RAII $PATH guard) is the shared helper imported above from // `edgezero_core::test_env`; the merge with edition-2024 main replaced our // local copy with it (its `set_var` calls are wrapped for 2024's unsafe-env). @@ -7301,6 +7504,7 @@ mod tests { adapter_manifest_path: Some(manifest), application_manifest_path: None, application_release_root: None, + execution_target: None, service_id: Some("SVC1".to_owned()), staging: true, stores: DeployStoreIds { @@ -7519,18 +7723,37 @@ mod tests { let marker = app.path().join("provider.log"); let fake = fake_provider_invocation_marker(&marker); let _path = PathPrepend::new(fake.path()); - let args = owned(&[ - "--manifest-path", - manifest.to_str().expect("utf8 manifest"), - "--service-id", - "SVC-1", - ]); - - let error = deploy(&args).expect_err("invalid service id must fail deploy"); + let context = AdapterDeployContext { + adapter_manifest_path: Some(manifest), + service_id: Some("SVC-1".to_owned()), + ..AdapterDeployContext::default() + }; + let error = FastlyCliAdapter + .deploy(&context, &[]) + .expect_err("invalid service id must fail deploy"); assert_service_id_error(&error); assert_provider_not_invoked(&marker); } + #[test] + fn deployment_requires_a_pinned_manifest_without_cwd_discovery() { + let app = tempdir().expect("selected app"); + let selected = app.path().canonicalize().expect("selected root"); + let context = AdapterDeployContext { + execution_target: Some(AdapterExecutionTarget::new(selected.clone(), None, None)), + ..AdapterDeployContext::default() + }; + let error = resolve_deploy_manifest_path(&context) + .expect_err("missing selected manifest must not discover another application"); + assert!(error.contains(&selected.display().to_string()), "{error}"); + resolve_deploy_manifest_path(&AdapterDeployContext::default()) + .expect_err("missing target must refuse discovery"); + let legacy_error = FastlyCliAdapter + .execute(AdapterAction::Deploy, &[]) + .expect_err("legacy execution must require typed context"); + assert!(legacy_error.contains("typed deployment context")); + } + #[cfg(unix)] #[test] fn active_version_rejects_invalid_service_id_before_provider_api() { @@ -7646,23 +7869,9 @@ mod tests { #[cfg(unix)] #[test] - fn adapter_deploy_discovers_and_rejects_invalid_service_id_before_provider_cli() { - const CHILD_ENV: &str = "EDGEZERO_FASTLY_DISCOVERY_TEST_CHILD"; - const MARKER_ENV: &str = "EDGEZERO_FASTLY_DISCOVERY_TEST_MARKER"; - - if env::var_os(CHILD_ENV).is_some() { - let marker = PathBuf::from(env::var_os(MARKER_ENV).expect("child marker path")); - let error = FastlyCliAdapter - .deploy(&AdapterDeployContext::default(), &[]) - .expect_err("discovered invalid service id must fail direct adapter deploy"); - assert_service_id_error(&error); - assert_provider_not_invoked(&marker); - return; - } - - let dir = tempdir().expect("tempdir"); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"app\"\n") - .expect("Cargo manifest"); + fn adapter_deploy_rejects_invalid_pinned_manifest_service_id_before_provider_cli() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("selected app"); fs::write( dir.path().join("fastly.toml"), "name = \"app\"\nservice_id = \"SVC-1\"\n", @@ -7670,31 +7879,20 @@ mod tests { .expect("manifest"); let marker = dir.path().join("provider.log"); let fake = fake_provider_invocation_marker(&marker); - let path = env::join_paths( - once(fake.path().to_path_buf()) - .chain(env::split_paths(&env::var_os("PATH").unwrap_or_default())), - ) - .expect("test PATH"); - let output = Command::new(env::current_exe().expect("current test binary")) - .args([ - "--exact", - "cli::tests::adapter_deploy_discovers_and_rejects_invalid_service_id_before_provider_cli", - "--nocapture", - ]) - .current_dir(dir.path()) - .env(CHILD_ENV, "1") - .env(MARKER_ENV, &marker) - .env("PATH", path) - .env_remove(FASTLY_SERVICE_ID_ENV) - .output() - .expect("run isolated discovery regression"); - - assert!( - output.status.success(), - "isolated discovery regression failed:\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); + let _path = PathPrepend::new(fake.path()); + let _service_id = EnvOverride::remove(FASTLY_SERVICE_ID_ENV); + let context = AdapterDeployContext { + execution_target: Some(AdapterExecutionTarget::new( + dir.path().canonicalize().expect("selected root"), + None, + None, + )), + ..AdapterDeployContext::default() + }; + let error = FastlyCliAdapter + .deploy(&context, &[]) + .expect_err("invalid pinned service id must fail before provider work"); + assert_service_id_error(&error); assert_provider_not_invoked(&marker); } @@ -7828,11 +8026,11 @@ mod tests { #[test] fn parse_active_version_finds_active_entry() { - let json = r#"[ - {"number":1,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[]}, - {"number":2,"active":true,"locked":true,"staging":false,"deployed":true,"environments":[]}, - {"number":3,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]} - ]"#; + let json = &serde_json::json!([ + {"number":1_u64,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[]}, + {"number":2_u64,"active":true,"locked":true,"staging":false,"deployed":true,"environments":[]}, + {"number":3_u64,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]} + ]).to_string(); assert_eq!(resolve_active_version(json), Ok(Some(2))); } @@ -8419,7 +8617,7 @@ mod tests { fn parse_active_version_none_when_no_active() { // A parsed list with no active version is `Ok(None)` — confirmed // no active version (first deploy), NOT an operational failure. - let json = r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]}]"#; + let json = &serde_json::json!([{"number":1_u64,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]}]).to_string(); assert_eq!(resolve_active_version(json), Ok(None)); } @@ -8428,7 +8626,7 @@ mod tests { // A truncated / non-array body is an operational failure, distinct from // "no active version" — the caller must fail closed, not record empty. resolve_active_version("not json").expect_err("non-JSON must be an operational error"); - resolve_active_version(r#"{"error":"unauthorized"}"#) + resolve_active_version(&serde_json::json!({"error": "unauthorized"}).to_string()) .expect_err("a non-array body must be an operational error"); } @@ -8437,38 +8635,48 @@ mod tests { // A garbled ACTIVE entry must fail closed, not read as "no active // version" — otherwise a production deploy proceeds with no rollback // target. Each of these is malformed and must be an operational error. - resolve_active_version(r#"[{"active":true}]"#) + resolve_active_version(&serde_json::json!([{"active": true}]).to_string()) .expect_err("active entry with no `number` must error"); - resolve_active_version(r#"[{"active":true,"number":"7"}]"#) + resolve_active_version(&serde_json::json!([{"active": true, "number": "7"}]).to_string()) .expect_err("active entry with a string `number` must error"); - resolve_active_version(r#"[{"active":"true","number":7}]"#) - .expect_err("a non-boolean `active` must error"); + resolve_active_version( + &serde_json::json!([{"active": "true", "number": 7_u64}]).to_string(), + ) + .expect_err("a non-boolean `active` must error"); // A non-boolean `active` ANYWHERE is schema drift — the whole list is // scanned, so it is caught even AFTER a valid active entry (a naive // first-match parser would miss this one). - resolve_active_version(r#"[{"active":"false"},{"active":true,"number":9}]"#) - .expect_err("a non-boolean `active` before the active entry is schema drift"); - resolve_active_version(r#"[{"active":true,"number":9},{"active":"nope"}]"#) - .expect_err("a non-boolean `active` AFTER the active entry is still schema drift"); + resolve_active_version( + &serde_json::json!([{"active": "false"}, {"active": true, "number": 9_u64}]) + .to_string(), + ) + .expect_err("a non-boolean `active` before the active entry is schema drift"); + resolve_active_version( + &serde_json::json!([{"active": true, "number": 9_u64}, {"active": "nope"}]).to_string(), + ) + .expect_err("a non-boolean `active` AFTER the active entry is still schema drift"); // More than one active version is ambiguous — refuse rather than pick one. - resolve_active_version(r#"[{"active":true,"number":9,"locked":true,"staging":false,"deployed":true,"environments":[]},{"active":true,"number":10,"locked":true,"staging":false,"deployed":true,"environments":[]}]"#) + resolve_active_version(&serde_json::json!([{"active":true,"number":9_u64,"locked":true,"staging":false,"deployed":true,"environments":[]},{"active":true,"number":10_u64,"locked":true,"staging":false,"deployed":true,"environments":[]}]).to_string()) .expect_err("two active versions must error as ambiguous"); // EVERY element must be a version object with a numeric `number` — a // garbled entry must fail closed, not be skipped as "not active". - resolve_active_version("[]").expect_err("an empty version list is an invalid response"); - resolve_active_version("[null]").expect_err("a null element must error"); - resolve_active_version("[{}]").expect_err("an entry with no `number` must error"); - resolve_active_version(r#"[{"number":"invalid"}]"#) + resolve_active_version(&serde_json::json!([]).to_string()) + .expect_err("an empty version list is an invalid response"); + resolve_active_version(&serde_json::json!([null]).to_string()) + .expect_err("a null element must error"); + resolve_active_version(&serde_json::json!([{}]).to_string()) + .expect_err("an entry with no `number` must error"); + resolve_active_version(&serde_json::json!([{"number": "invalid"}]).to_string()) .expect_err("a non-numeric `number` must error"); // Every safety field is mandatory; omission is schema drift. resolve_active_version( - r#"[{"number":42,"locked":false,"staging":false,"deployed":false,"environments":[]}]"#, + &serde_json::json!([{"number":42_u64,"locked":false,"staging":false,"deployed":false,"environments":[]}]).to_string(), ) .expect_err("an omitted active field must fail closed"); // Sanity: a well-formed list still resolves. assert_eq!( resolve_active_version( - r#"[{"active":false,"number":1,"locked":true,"staging":false,"deployed":true,"environments":[]},{"active":true,"number":2,"locked":true,"staging":false,"deployed":true,"environments":[]}]"# + &serde_json::json!([{"active":false,"number":1_u64,"locked":true,"staging":false,"deployed":true,"environments":[]},{"active":true,"number":2_u64,"locked":true,"staging":false,"deployed":true,"environments":[]}]).to_string() ), Ok(Some(2)) ); @@ -8541,8 +8749,8 @@ mod tests { #[test] fn active_version_or_require_enforces_require_active() { - let active = r#"[{"active":true,"number":5,"locked":true,"staging":false,"deployed":true,"environments":[]}]"#; - let none = r#"[{"active":false,"number":5,"locked":false,"staging":false,"deployed":false,"environments":[]}]"#; + let active = &serde_json::json!([{"active":true,"number":5_u64,"locked":true,"staging":false,"deployed":true,"environments":[]}]).to_string(); + let none = &serde_json::json!([{"active":false,"number":5_u64,"locked":false,"staging":false,"deployed":false,"environments":[]}]).to_string(); // A resolvable active version is returned regardless of the flag. assert_eq!(active_version_or_require(active, false, "svc"), Ok(Some(5))); @@ -8589,10 +8797,11 @@ mod tests { #[test] fn parse_staging_ip_selects_the_requested_domain() { - let json = r#"[ + let json = &serde_json::json!([ {"name":"other.example.com","staging_ip":"151.101.1.10"}, {"name":"example.com","staging_ip":"151.101.2.10"} - ]"#; + ]) + .to_string(); assert_eq!( parse_staging_ip(json, "example.com"), Ok("151.101.2.10".to_owned()) @@ -8602,11 +8811,11 @@ mod tests { #[test] fn parse_staging_ip_rejects_missing_duplicate_or_malformed_domain_records() { for json in [ - r#"[{"name":"other.example.com","staging_ip":"151.101.1.10"}]"#, - r#"[{"name":"example.com","staging_ip":null}]"#, - r#"[{"name":"example.com","staging_ips":["151.101.2.10"]}]"#, - r#"[{"name":"example.com","staging_ip":"151.101.2.10"},{"name":"example.com","staging_ip":"151.101.2.11"}]"#, - r#"{"name":"example.com","staging_ip":"151.101.2.10"}"#, + &serde_json::json!([{"name":"other.example.com","staging_ip":"151.101.1.10"}]).to_string(), + &serde_json::json!([{"name":"example.com","staging_ip":null}]).to_string(), + &serde_json::json!([{"name":"example.com","staging_ips":["151.101.2.10"]}]).to_string(), + &serde_json::json!([{"name":"example.com","staging_ip":"151.101.2.10"},{"name":"example.com","staging_ip":"151.101.2.11"}]).to_string(), + &serde_json::json!({"name":"example.com","staging_ip":"151.101.2.10"}).to_string(), ] { parse_staging_ip(json, "example.com") .expect_err("ambiguous or malformed domain inventory must fail closed"); @@ -9679,12 +9888,11 @@ build = \"cargo build --release\" #[test] fn find_config_store_id_matches_bare_array_by_name() { - let stdout = format!( - r#"[ - {{"id": "abc123", "name": "{TEST_CONFIG_ID}"}}, - {{"id": "def456", "name": "other_store"}} - ]"# - ); + let stdout = serde_json::json!([ + {"id": "abc123", "name": TEST_CONFIG_ID}, + {"id": "def456", "name": "other_store"}, + ]) + .to_string(); match find_config_store_id(&stdout, TEST_CONFIG_ID) { ConfigStoreLookup::Found(id) => assert_eq!(id, "abc123"), ConfigStoreLookup::NotFound => panic!("expected Found, got NotFound"), @@ -9696,11 +9904,8 @@ build = \"cargo build --release\" #[test] fn find_config_store_id_tolerates_items_envelope() { - let stdout = format!( - r#"{{"items": [ - {{"id": "xyz789", "name": "{TEST_CONFIG_ID}"}} - ]}}"# - ); + let stdout = + serde_json::json!({"items": [{"id": "xyz789", "name": TEST_CONFIG_ID}]}).to_string(); match find_config_store_id(&stdout, TEST_CONFIG_ID) { ConfigStoreLookup::Found(id) => assert_eq!(id, "xyz789"), ConfigStoreLookup::NotFound => panic!("expected Found, got NotFound"), @@ -9715,7 +9920,7 @@ build = \"cargo build --release\" // JSON parses cleanly, entries are well-formed // (`name` + `id` strings present), but no entry matches // → NotFound. Operator likely needs to run `provision`. - let stdout = r#"[{"id": "abc", "name": "other"}]"#; + let stdout = &serde_json::json!([{"id": "abc", "name": "other"}]).to_string(); assert!(matches!( find_config_store_id(stdout, "missing"), ConfigStoreLookup::NotFound @@ -9744,7 +9949,7 @@ build = \"cargo build --release\" fn find_config_store_id_flags_schema_drift_when_shape_unexpected() { // JSON parses but the top-level is neither a bare array // nor an `{items: [...]}` envelope. - let stdout = r#"{"namespace": "fastly", "list": []}"#; + let stdout = &serde_json::json!({"namespace": "fastly", "list": []}).to_string(); match find_config_store_id(stdout, "any") { ConfigStoreLookup::SchemaDrift(detail) => { assert!( @@ -9762,7 +9967,7 @@ build = \"cargo build --release\" // Array of objects but none have BOTH string `name` and // string `id` fields — suggests schema rename (e.g. // fastly renamed `name` → `title`). - let stdout = format!(r#"[{{"title": "{TEST_CONFIG_ID}", "uid": "abc"}}]"#); + let stdout = serde_json::json!([{"title": TEST_CONFIG_ID, "uid": "abc"}]).to_string(); let drift = find_config_store_id(&stdout, TEST_CONFIG_ID); assert!( matches!(drift, ConfigStoreLookup::SchemaDrift(_)), @@ -9777,9 +9982,11 @@ build = \"cargo build --release\" // definite NotFound would authorise an overwrite of a store that exists, // so a malformed row must be SchemaDrift (a hard error), not NotFound -- // even when another row is well-formed. - let stdout = format!( - r#"[{{"name": "", "id": "abc"}}, {{"name": "{TEST_CONFIG_ID}", "id": "store-1"}}]"# - ); + let stdout = serde_json::json!([ + {"name": "", "id": "abc"}, + {"name": TEST_CONFIG_ID, "id": "store-1"}, + ]) + .to_string(); let drift = find_config_store_id(&stdout, "some-other-store"); assert!( matches!(drift, ConfigStoreLookup::SchemaDrift(_)), @@ -9791,9 +9998,11 @@ build = \"cargo build --release\" fn find_config_store_id_rejects_duplicate_names() { // A duplicate name means we are not reading one consistent view of the // store, so resolving an id off it is ambiguous -> fail closed. - let stdout = format!( - r#"[{{"name": "{TEST_CONFIG_ID}", "id": "a"}}, {{"name": "{TEST_CONFIG_ID}", "id": "b"}}]"# - ); + let stdout = serde_json::json!([ + {"name": TEST_CONFIG_ID, "id": "a"}, + {"name": TEST_CONFIG_ID, "id": "b"}, + ]) + .to_string(); let drift = find_config_store_id(&stdout, TEST_CONFIG_ID); assert!( matches!(drift, ConfigStoreLookup::SchemaDrift(_)), @@ -9806,7 +10015,7 @@ build = \"cargo build --release\" // Empty array IS a valid "store doesn't exist yet" signal, // not schema drift — fastly CLI legitimately returns `[]` // when no config-stores exist. - let drift = find_config_store_id("[]", "any"); + let drift = find_config_store_id(&serde_json::json!([]).to_string(), "any"); assert!( matches!(drift, ConfigStoreLookup::NotFound), "empty array must be NotFound, got {drift:?}" @@ -9819,10 +10028,9 @@ build = \"cargo build --release\" // malformed entry could be the store we're looking for (hidden behind a // missing name/id). Deciding NotFound here would fail OPEN — staging // would then mirror no production overrides. Any malformed entry is drift. - let stdout = r#"[ - {"id": "abc123", "name": "some_other_store"}, - {"id": "def456"} - ]"#; + let stdout = + &serde_json::json!([{"id": "abc123", "name": "some_other_store"}, {"id": "def456"}]) + .to_string(); let drift = find_config_store_id(stdout, "app_config"); assert!( matches!(drift, ConfigStoreLookup::SchemaDrift(_)), @@ -9834,10 +10042,9 @@ build = \"cargo build --release\" fn find_config_store_id_scans_past_a_match() { // The full list is scanned: a malformed entry AFTER the match must still // be caught (no short-circuit on the first Found). - let stdout = r#"[ - {"id": "abc123", "name": "app_config"}, - {"name": "broken"} - ]"#; + let stdout = + &serde_json::json!([{"id": "abc123", "name": "app_config"}, {"name": "broken"}]) + .to_string(); let drift = find_config_store_id(stdout, "app_config"); assert!( matches!(drift, ConfigStoreLookup::SchemaDrift(_)), @@ -9847,10 +10054,7 @@ build = \"cargo build --release\" #[test] fn find_config_store_id_flags_duplicate_names_as_ambiguous() { - let stdout = r#"[ - {"id": "abc123", "name": "app_config"}, - {"id": "def456", "name": "app_config"} - ]"#; + let stdout = &serde_json::json!([{"id": "abc123", "name": "app_config"}, {"id": "def456", "name": "app_config"}]).to_string(); let drift = find_config_store_id(stdout, "app_config"); assert!( matches!(drift, ConfigStoreLookup::SchemaDrift(_)), @@ -10158,7 +10362,7 @@ build = \"cargo build --release\" let entry_list = dir.path().join("entries.json"); fs::write( &store_list, - format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#), + serde_json::json!([{"name": TEST_CONFIG_ID, "id": "store-abc123"}]).to_string(), ) .expect("store list"); let entries = current @@ -10239,13 +10443,14 @@ exit 1 let list_file = dir.path().join("list_payload.txt"); let entry_list_file = dir.path().join("entry_list_payload.txt"); // Store-list JSON: bare array with one entry matching TEST_CONFIG_ID. - let list_json = format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#); + let list_json = + serde_json::json!([{"name": TEST_CONFIG_ID, "id": "store-abc123"}]).to_string(); let entry_list_json = { - let items: Vec = entry_list_keys + let items: Vec<_> = entry_list_keys .iter() - .map(|key| format!(r#"{{"item_key":{}}}"#, serde_json::to_string(key).unwrap())) + .map(|key| serde_json::json!({"item_key": key})) .collect(); - format!("[{}]", items.join(",")) + serde_json::json!(items).to_string() }; fs::write(&stdout_file, stdout_body).expect("write stdout payload"); fs::write(&stderr_file, stderr_body).expect("write stderr payload"); @@ -10276,17 +10481,16 @@ exit 1 let script_path = dir.path().join("fastly"); let list_file = dir.path().join("list_payload.txt"); let entry_file = dir.path().join("entry_payload.txt"); - let list_json = format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#); + let list_json = + serde_json::json!([{"name": TEST_CONFIG_ID, "id": "store-abc123"}]).to_string(); // item_value must be a valid BlobEnvelope JSON so the resolver accepts it. let envelope_json = serde_json::to_string(&BlobEnvelope::new( json!({"v": "logged"}), "2026-06-22T00:00:00Z".into(), )) .expect("serialize"); - let entry_json = format!( - r#"{{"item_value":{},"store_id":"store-abc123"}}"#, - serde_json::to_string(&envelope_json).expect("escape") - ); + let entry_json = + json!({"item_value": envelope_json, "store_id": "store-abc123"}).to_string(); fs::write(&list_file, list_json).expect("write list payload"); fs::write(&entry_file, &entry_json).expect("write entry payload"); let script = format!( @@ -10326,10 +10530,7 @@ exit 1 "2026-06-22T00:00:00Z".into(), )) .expect("serialize"); - let entry_json = format!( - r#"{{"item_value":{},"store_id":"store-abc123"}}"#, - serde_json::to_string(&envelope).expect("escape") - ); + let entry_json = json!({"item_value": envelope, "store_id": "store-abc123"}).to_string(); let fake = fake_fastly_returning(&entry_json, "", 0); let _path = PathPrepend::new(fake.path()); let result = FastlyCliAdapter @@ -10529,7 +10730,8 @@ exit 1 use std::os::unix::fs::PermissionsExt as _; let fake_dir = tempdir().expect("tempdir"); let list_file = fake_dir.path().join("list.json"); - let list_json = format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#); + let list_json = + serde_json::json!([{"name": TEST_CONFIG_ID, "id": "store-abc123"}]).to_string(); fs::write(&list_file, list_json).expect("write list"); // The `config-store-entry list` response: a bare array of the keys present // in `key_responses`. Absence confirmation lists the store and checks @@ -10537,13 +10739,11 @@ exit 1 // `item_key` is needed (the keys-only listing is value-tolerant). let entry_list_file = fake_dir.path().join("entry_list.json"); let entries_json = { - let items: Vec = key_responses + let items: Vec<_> = key_responses .iter() - .map(|(key, _)| { - format!(r#"{{"item_key":{}}}"#, serde_json::to_string(key).unwrap()) - }) + .map(|(key, _)| serde_json::json!({"item_key": key})) .collect(); - format!("[{}]", items.join(",")) + serde_json::json!(items).to_string() }; fs::write(&entry_list_file, entries_json).expect("write entry list"); // Write each key response to a named file. @@ -10624,16 +10824,13 @@ echo 'unexpected' >&2; exit 1 let list_file = dir.path().join("list.json"); fs::write( &list_file, - format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#), + serde_json::json!([{"name": TEST_CONFIG_ID, "id": "store-abc123"}]).to_string(), ) .expect("list"); let entries_file = dir.path().join("entries.json"); fs::write(&entries_file, entry_list_json(entry_list)).expect("entries"); for (index, value) in root_describe_seq.iter().enumerate() { - let wrapped = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(value).expect("escape") - ); + let wrapped = serde_json::json!({"item_value": value}).to_string(); let nth = index.saturating_add(1); fs::write( dir.path().join(format!("resp_{root_key}_{nth}.json")), @@ -10809,7 +11006,7 @@ echo 'unexpected' >&2; exit 1 let dir = tempdir().expect("tempdir"); // The sentinel is an OBJECT KEY (not a value): the earlier redactor joined // keys into the diagnostic, so this is what pins the key-disclosure fix. - let drift = format!(r#"{{"{SENTINEL}":"x"}}"#); + let drift = serde_json::json!({(SENTINEL): "x"}).to_string(); let fake = fake_fastly_returning(&drift, "", 0); let _path = PathPrepend::new(fake.path()); @@ -11213,10 +11410,7 @@ echo 'unexpected' >&2; exit 1 let envelope = BlobEnvelope::new(json!({"hello": "world"}), "2026-06-22T00:00:00Z".into()); let json_str = serde_json::to_string(&envelope).unwrap(); - let item_json = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(&json_str).unwrap() - ); + let item_json = json!({"item_value": json_str}).to_string(); let fake = fake_fastly_returning(&item_json, "", 0); let _path = PathPrepend::new(fake.path()); @@ -11249,14 +11443,11 @@ echo 'unexpected' >&2; exit 1 // Build a key→response map for every physical entry. let mut key_responses: Vec<(String, String)> = Vec::new(); for (pk, pv) in &physical { - let resp = format!(r#"{{"item_value":{}}}"#, serde_json::to_string(pv).unwrap()); + let resp = serde_json::json!({"item_value": pv}).to_string(); key_responses.push((pk.clone(), resp)); } // The root key should return the pointer. - let ptr_resp = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(pointer_json).unwrap() - ); + let ptr_resp = serde_json::json!({"item_value": pointer_json}).to_string(); key_responses.push((TEST_CONFIG_ID.to_owned(), ptr_resp)); let fake = fake_fastly_with_key_dispatch(dir.path(), &key_responses); @@ -11293,10 +11484,7 @@ echo 'unexpected' >&2; exit 1 let (_, pointer_json) = physical.last().unwrap(); // Only provide the root pointer; omit chunk responses so the chunk fetch // gets a CLEAN not-found (`Error: item not found`, no operational marker). - let ptr_resp = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(pointer_json).unwrap() - ); + let ptr_resp = serde_json::json!({"item_value": pointer_json}).to_string(); let key_responses = vec![(TEST_CONFIG_ID.to_owned(), ptr_resp)]; let fake = fake_fastly_with_key_dispatch(dir.path(), &key_responses); let _path = PathPrepend::new(fake.path()); @@ -11336,10 +11524,7 @@ echo 'unexpected' >&2; exit 1 // Corrupt first chunk's content. let (first_chunk_key, first_chunk_val) = &physical[0]; let corrupted: String = first_chunk_val.chars().map(|_| 'Z').collect(); - let corrupt_resp = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(&corrupted).unwrap() - ); + let corrupt_resp = serde_json::json!({"item_value": corrupted}).to_string(); key_responses.push((first_chunk_key.clone(), corrupt_resp)); // Remaining chunks as normal. for (pk, pv) in physical @@ -11349,15 +11534,12 @@ echo 'unexpected' >&2; exit 1 { key_responses.push(( pk.clone(), - format!(r#"{{"item_value":{}}}"#, serde_json::to_string(pv).unwrap()), + serde_json::json!({"item_value": pv}).to_string(), )); } key_responses.push(( TEST_CONFIG_ID.to_owned(), - format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(pointer_json).unwrap() - ), + serde_json::json!({"item_value": pointer_json}).to_string(), )); let fake = fake_fastly_with_key_dispatch(dir.path(), &key_responses); let _path = PathPrepend::new(fake.path()); @@ -11388,11 +11570,10 @@ echo 'unexpected' >&2; exit 1 // `describe` SUCCEEDS (the entry exists), so a resolve failure is CORRUPT // stored state, not an IO error: the read must report `Corrupt` so a push // can overwrite it (in-band repair), NOT hard-error and block recovery. - let bad_json = r#"{"edgezero_kind":"fastly_config_chunks","some_field":"x"}"#; - let item_json = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(bad_json).unwrap() - ); + let bad_json = + &serde_json::json!({"edgezero_kind": "fastly_config_chunks", "some_field": "x"}) + .to_string(); + let item_json = serde_json::json!({"item_value": bad_json}).to_string(); let fake = fake_fastly_returning(&item_json, "", 0); let _path = PathPrepend::new(fake.path()); @@ -11450,7 +11631,7 @@ echo 'unexpected' >&2; exit 1 // error: the read was incomplete, so a push must not overwrite. let infra = classify_resolved_read( Err(ResolveFailure::Corrupt("boom".to_owned())), - "{\"edgezero_kind\":\"fastly_config_chunks\"}", + &json!({"edgezero_kind": "fastly_config_chunks"}).to_string(), true, ); assert!( @@ -11464,7 +11645,7 @@ echo 'unexpected' >&2; exit 1 // never offered for overwrite. let unknown = classify_resolved_read( Err(ResolveFailure::FutureFormat("x".to_owned())), - r#"{"edgezero_kind":"fastly_config_chunks_v2"}"#, + &serde_json::json!({"edgezero_kind": "fastly_config_chunks_v2"}).to_string(), false, ); assert!( @@ -11483,7 +11664,7 @@ echo 'unexpected' >&2; exit 1 Err(ResolveFailure::FutureFormat( "newer inner envelope".to_owned(), )), - r#"{"edgezero_kind":"fastly_config_chunks","version":1,"chunks":[]}"#, + &serde_json::json!({"edgezero_kind": "fastly_config_chunks", "version": 1_u64, "chunks": []}).to_string(), false, ); assert!( @@ -11497,7 +11678,8 @@ echo 'unexpected' >&2; exit 1 assert!(matches!( classify_resolved_read( Err(ResolveFailure::Corrupt("bad chunk".to_owned())), - r#"{"edgezero_kind":"fastly_config_chunks","chunks":[]}"#, + &serde_json::json!({"edgezero_kind": "fastly_config_chunks", "chunks": []}) + .to_string(), false ), Ok(ReadConfigEntry::Corrupt(_)) @@ -11517,7 +11699,7 @@ echo 'unexpected' >&2; exit 1 // A future POINTER version (resolve fails on the version check) is a hard // error too -- the pointer kind is ours, but the version is newer. - let v2_ptr = r#"{"edgezero_kind":"fastly_config_chunks","version":2,"chunks":[]}"#; + let v2_ptr = &serde_json::json!({"edgezero_kind": "fastly_config_chunks", "version": 2_u64, "chunks": []}).to_string(); assert!( classify_resolved_read( Err(ResolveFailure::FutureFormat( @@ -11614,10 +11796,15 @@ echo 'unexpected' >&2; exit 1 // A pointer-KIND value that is invalid (missing the chunks it needs). // The resolver would error on this; the local read must NOT propagate // that as `Err`. - let broken_pointer = format!( - r#"{{"edgezero_kind":"{POINTER_KIND}","version":1,"chunks":[{{"key":"cfg{CHUNK_KEY_INFIX}{sha}.0","len":10,"sha256":"x"}}],"data_sha256":"","envelope_len":10,"envelope_sha256":"{sha}"}}"#, - sha = "a".repeat(64), - ); + let sha = "a".repeat(64); + let broken_pointer = serde_json::json!({ + "edgezero_kind": POINTER_KIND, + "version": 1_u32, + "chunks": [{"key": format!("cfg{CHUNK_KEY_INFIX}{sha}.0"), "len": 10_u32, "sha256": "x"}], + "data_sha256": "", + "envelope_len": 10_u32, + "envelope_sha256": sha, + }).to_string(); write_fastly_local_config_store( &fastly_toml, TEST_CONFIG_ID, @@ -12903,10 +13090,9 @@ echo 'unexpected' >&2; exit 1 let live = gen_envelope("live"); let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 999_999)]; listing.extend(listed_generation(TEST_CONFIG_ID, &live, 999_999)); - let enveloped = format!( - r#"{{"items":{},"next_cursor":"abc"}}"#, - entry_list_json(&listing) - ); + let items: serde_json::Value = + serde_json::from_str(&entry_list_json(&listing)).expect("entry list"); + let enveloped = serde_json::json!({"items": items, "next_cursor": "abc"}).to_string(); let fake = fake_fastly_gc_raw_list(TEST_CONFIG_ID, &enveloped, &oplog); let _path = PathPrepend::new(fake.path()); @@ -13092,13 +13278,7 @@ echo 'unexpected' >&2; exit 1 #[cfg(unix)] #[test] - fn deploy_honours_threaded_manifest_path_and_strips_it_from_the_fastly_argv() { - // Production deploys used to ignore the CLI-threaded - // `--manifest-path` and fall back to `find_fastly_manifest(cwd)`, - // which in a monorepo picks the CLOSEST fastly.toml — the wrong - // app. The threaded path must select the app directory, and must - // be STRIPPED from the argv (`fastly compute deploy` has no such - // flag and would exit non-zero). + fn deploy_uses_pinned_root_when_adapter_manifest_is_not_explicit() { let _lock = path_mutation_guard().lock().expect("guard"); let (fake, record) = fake_fastly_cwd_recorder(); let _path = PathPrepend::new(fake.path()); @@ -13107,13 +13287,18 @@ echo 'unexpected' >&2; exit 1 let manifest = app.path().join("fastly.toml"); fs::write(&manifest, "name = \"app\"\n").expect("write fastly.toml"); - let args = vec![ - "--manifest-path".to_owned(), - manifest.display().to_string(), - "--service-id".to_owned(), - "SVC1".to_owned(), - ]; - deploy(&args).expect("deploy must run against the threaded manifest"); + let context = AdapterDeployContext { + execution_target: Some(AdapterExecutionTarget::new( + app.path().canonicalize().expect("app root"), + None, + None, + )), + service_id: Some("SVC1".to_owned()), + ..AdapterDeployContext::default() + }; + FastlyCliAdapter + .deploy(&context, &[]) + .expect("deploy must run against the pinned root"); let recorded = fs::read_to_string(&record).expect("fastly was invoked"); let (cwd, recorded_argv) = recorded @@ -13220,7 +13405,7 @@ echo 'unexpected' >&2; exit 1 let dead = gen_envelope("dead"); // Envelope-shaped, no discriminator, VERSION 2 -> a newer direct envelope. - let future = r#"{"data":{"x":1},"sha256":"0000000000000000000000000000000000000000000000000000000000000000","generated_at":"2026-01-01T00:00:00Z","version":2}"#; + let future = &serde_json::json!({"data": {"x": 1_u64}, "sha256": "0000000000000000000000000000000000000000000000000000000000000000", "generated_at": "2026-01-01T00:00:00Z", "version": 2_u64}).to_string(); let mut listing = vec![( "app_config".to_owned(), stamp_secs_ago(172_800), @@ -13366,7 +13551,8 @@ echo 'unexpected' >&2; exit 1 let dead = gen_envelope("dead"); // Root value is pointer-kind but invalid. - let bad = r#"{"edgezero_kind":"fastly_config_chunks","version":2}"#.to_owned(); + let bad = serde_json::json!({"edgezero_kind": "fastly_config_chunks", "version": 2_u64}) + .to_string(); let mut listing = vec![(TEST_CONFIG_ID.to_owned(), stamp_secs_ago(3_600), bad)]; listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); @@ -13818,7 +14004,10 @@ echo 'unexpected' >&2; exit 1 .expect("a chunk key"); contents.insert( &victim, - toml_edit::value(r#"{"edgezero_kind":"fastly_config_chunks_v2","new":true}"#), + toml_edit::value( + serde_json::json!({"edgezero_kind": "fastly_config_chunks_v2", "new": true}) + .to_string(), + ), ); fs::write(&fastly_toml, doc.to_string()).expect("write"); @@ -14001,13 +14190,19 @@ echo 'unexpected' >&2; exit 1 let fastly_toml = dir.path().join("fastly.toml"); // Seed the root with a pointer-kind-but-invalid value AND a real // chunk-like key so "no deletes" is non-vacuous. - let seed = concat!( - "name = \"demo\"\n\n", - "[local_server.config_stores.app_config]\n", - "format = \"inline-toml\"\n\n", - "[local_server.config_stores.app_config.contents]\n", - "app_config = \"{\\\"edgezero_kind\\\":\\\"fastly_config_chunks\\\",\\\"version\\\":1}\"\n", - "\"app_config.__edgezero_chunks.deadbeef.0\" = \"seeded-chunk-payload\"\n", + let seed = format!( + concat!( + "name = \"demo\"\n\n", + "[local_server.config_stores.app_config]\n", + "format = \"inline-toml\"\n\n", + "[local_server.config_stores.app_config.contents]\n", + "app_config = {prior}\n", + "\"app_config.__edgezero_chunks.deadbeef.0\" = \"seeded-chunk-payload\"\n", + ), + prior = toml_edit::value( + serde_json::json!({"edgezero_kind": "fastly_config_chunks", "version": 1_u32}) + .to_string() + ), ); fs::write(&fastly_toml, seed).expect("seed"); @@ -14064,14 +14259,17 @@ echo 'unexpected' >&2; exit 1 let dir = tempdir().expect("tempdir"); let fastly_toml = dir.path().join("fastly.toml"); // The root now holds a v2 direct envelope from a newer writer. - let seed = concat!( - "name = \"demo\"\n\n", - "[local_server.config_stores.app_config]\n", - "format = \"inline-toml\"\n\n", - "[local_server.config_stores.app_config.contents]\n", - "app_config = \"{\\\"data\\\":{},\\\"sha256\\\":\\\"x\\\",\\\"generated_at\\\":\\\"t\\\",\\\"version\\\":2}\"\n", + let seed = format!( + concat!( + "name = \"demo\"\n\n", + "[local_server.config_stores.app_config]\n", + "format = \"inline-toml\"\n\n", + "[local_server.config_stores.app_config.contents]\n", + "app_config = {prior}\n", + ), + prior = toml_edit::value(serde_json::json!({"data": {}, "sha256": "x", "generated_at": "t", "version": 2_u32}).to_string()), ); - fs::write(&fastly_toml, seed).expect("seed"); + fs::write(&fastly_toml, &seed).expect("seed"); let direct = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); let err = FastlyCliAdapter @@ -14091,10 +14289,7 @@ echo 'unexpected' >&2; exit 1 ); // The v2 value must survive untouched. let after = fs::read_to_string(&fastly_toml).expect("read"); - assert!( - after.contains("\\\"version\\\":2"), - "the newer-format value must be left intact: {after}" - ); + assert_eq!(after, seed, "the newer-format file must be left intact"); } /// The locked downgrade guard must catch a future INNER envelope hidden behind @@ -14734,12 +14929,18 @@ echo 'unexpected' >&2; exit 1 let fastly_toml = dir.path().join("fastly.toml"); // A pointer-kind prior value missing its required fields — malformed, so // `prior_chunk_keys` returns Err (warn, delete nothing). - let seed = concat!( - "name = \"demo\"\n\n", - "[local_server.config_stores.app_config]\n", - "format = \"inline-toml\"\n\n", - "[local_server.config_stores.app_config.contents]\n", - "app_config = \"{\\\"edgezero_kind\\\":\\\"fastly_config_chunks\\\",\\\"version\\\":1}\"\n", + let seed = format!( + concat!( + "name = \"demo\"\n\n", + "[local_server.config_stores.app_config]\n", + "format = \"inline-toml\"\n\n", + "[local_server.config_stores.app_config.contents]\n", + "app_config = {prior}\n", + ), + prior = toml_edit::value( + serde_json::json!({"edgezero_kind": "fastly_config_chunks", "version": 1_u32}) + .to_string() + ), ); fs::write(&fastly_toml, seed).expect("seed"); @@ -14828,12 +15029,18 @@ echo 'unexpected' >&2; exit 1 use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; let dir = tempdir().expect("tempdir"); let fastly_toml = dir.path().join("fastly.toml"); - let seed = concat!( - "name = \"demo\"\n\n", - "[local_server.config_stores.app_config]\n", - "format = \"inline-toml\"\n\n", - "[local_server.config_stores.app_config.contents]\n", - "app_config = \"{\\\"edgezero_kind\\\":\\\"fastly_config_chunks\\\",\\\"version\\\":1}\"\n", + let seed = format!( + concat!( + "name = \"demo\"\n\n", + "[local_server.config_stores.app_config]\n", + "format = \"inline-toml\"\n\n", + "[local_server.config_stores.app_config.contents]\n", + "app_config = {prior}\n", + ), + prior = toml_edit::value( + serde_json::json!({"edgezero_kind": "fastly_config_chunks", "version": 1_u32}) + .to_string() + ), ); fs::write(&fastly_toml, seed).expect("seed"); diff --git a/crates/edgezero-adapter-fastly/src/config_store.rs b/crates/edgezero-adapter-fastly/src/config_store.rs index bad34170b..3b31c78f3 100644 --- a/crates/edgezero-adapter-fastly/src/config_store.rs +++ b/crates/edgezero-adapter-fastly/src/config_store.rs @@ -1,14 +1,23 @@ -//! Fastly adapter config store: wraps `fastly::ConfigStore`. +//! Fastly config reads use one caller-sized host buffer, without SDK retries. use std::cell::Cell; #[cfg(test)] use std::collections::HashMap; -use crate::chunked_config::resolve_fastly_config_value_typed; +use crate::chunked_config::{ + BoundedResolveFailure, ResolveFailure, SyncHostCallError, exact_fastly_read, + resolve_fastly_config_value_typed, resolve_fastly_config_value_typed_bounded, + run_sync_host_call, +}; use async_trait::async_trait; -use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; -use fastly::ConfigStore as FastlyConfigStoreInner; -use fastly::config_store::{LookupError, OpenError}; +use edgezero_core::config_store::{BoundedStoreRead, ConfigStore, ConfigStoreError}; +use edgezero_core::{Deadline, MonotonicClock}; +use fastly_shared::FastlyStatus; +use fastly_sys::fastly_config_store; + +// A physical entry is at most 8,000 Unicode scalars. This adapter also imposes +// a finite byte ceiling independently of changes to the provider's quota. +const MAX_ENTRY_BYTES: u64 = 32_000; /// Config store backed by a Fastly Config Store resource link. pub struct FastlyConfigStore { @@ -16,7 +25,7 @@ pub struct FastlyConfigStore { } enum FastlyConfigStoreBackend { - Fastly(FastlyConfigStoreInner), + Fastly(u32), #[cfg(test)] InMemory(HashMap), } @@ -29,16 +38,65 @@ impl FastlyConfigStore { } } + #[expect( + unsafe_code, + reason = "the pinned ABI receives a live initialized slice and cannot resize it" + )] + fn read_entry(&self, key: &str, limit: u64) -> Result, ConfigStoreError> { + let read_limit = limit.min(MAX_ENTRY_BYTES); + match &self.inner { + FastlyConfigStoreBackend::Fastly(handle) => { + read_host_buffer(read_limit, |buffer| { + let mut written = 0; + // SAFETY: host output is confined to this initialized slice; + // reported lengths are validated before indexing or truncation. + let status = unsafe { + fastly_config_store::get( + *handle, + key.as_ptr(), + key.len(), + buffer.as_mut_ptr(), + buffer.len(), + &raw mut written, + ) + }; + (status, written) + }) + } + #[cfg(test)] + FastlyConfigStoreBackend::InMemory(data) => match data.get(key) { + Some(value) + if u64::try_from(value.len()).map_or(true, |length| length > read_limit) => + { + Err(ConfigStoreError::ValueTooLarge) + } + value => Ok(value.cloned()), + }, + } + } + /// Open a Fastly Config Store by resource link name. /// - /// Returns an error if the configured store cannot be opened. - /// /// # Errors - /// Returns the underlying [`fastly::config_store::OpenError`] when the named store does not exist or cannot be opened. + /// Returns a redacted typed failure if the host cannot open the store. #[inline] - pub fn try_open(name: &str) -> Result { - FastlyConfigStoreInner::try_open(name).map(|inner| Self { - inner: FastlyConfigStoreBackend::Fastly(inner), + #[expect( + unsafe_code, + reason = "the pinned host ABI writes only the supplied handle out-parameter" + )] + pub fn try_open(name: &str) -> Result { + let mut handle = fastly_shared::INVALID_CONFIG_STORE_HANDLE; + // SAFETY: the UTF-8 name slice and initialized out-parameter remain live + // for this synchronous call. Config Store exposes no close operation. + let status = + unsafe { fastly_config_store::open(name.as_ptr(), name.len(), &raw mut handle) }; + if status != FastlyStatus::OK || handle == fastly_shared::INVALID_CONFIG_STORE_HANDLE { + return Err(ConfigStoreError::unavailable( + "config store could not be opened", + )); + } + Ok(Self { + inner: FastlyConfigStoreBackend::Fastly(handle), }) } } @@ -46,14 +104,8 @@ impl FastlyConfigStore { #[async_trait(?Send)] impl ConfigStore for FastlyConfigStore { #[inline] - async fn get(&self, key: &str) -> Result, ConfigStoreError> { - let root_value = match &self.inner { - FastlyConfigStoreBackend::Fastly(inner) => { - inner.try_get(key).map_err(|err| map_lookup_error(&err))? - } - #[cfg(test)] - FastlyConfigStoreBackend::InMemory(data) => data.get(key).cloned(), - }; + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + let root_value = self.read_entry(key, MAX_ENTRY_BYTES)?; let Some(value) = root_value else { return Ok(None); }; @@ -90,20 +142,14 @@ impl ConfigStore for FastlyConfigStore { // here, because those ARE our reserved namespace. let transient = Cell::new(false); let outcome = resolve_fastly_config_value_typed(key, value, |chunk_key| { - let got = match &self.inner { - FastlyConfigStoreBackend::Fastly(inner) => { - inner.try_get(chunk_key).map_err(|err| { - if is_transient_lookup(&err) { - transient.set(true); - } - // The pointer-controlled chunk key is not echoed (the resolver - // adds a safe position locator); the SDK `err` carries no value. - format!("config store lookup failed: {err}") - })? - } - #[cfg(test)] - FastlyConfigStoreBackend::InMemory(data) => data.get(chunk_key).cloned(), - }; + let got = self + .read_entry(chunk_key, MAX_ENTRY_BYTES) + .map_err(|error| { + if !matches!(error, ConfigStoreError::InvalidKey { .. }) { + transient.set(true); + } + "config store chunk read failed".to_owned() + })?; if got.is_none() { // Referenced chunk absent at this POP: treat as propagation lag // (transient) rather than corruption. @@ -111,84 +157,151 @@ impl ConfigStore for FastlyConfigStore { } Ok(got) }); + outcome + .map(edgezero_core::ConfigValue::from) + .map(Some) + .map_err(|error| map_resolve_failure(key, transient.get(), error)) + } + + #[inline] + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + let root_limit = max_backend_bytes.min(max_value_bytes.max(8_000)); + let materialized_root = + run_sync_host_call(clock, deadline, || self.read_entry(key, root_limit)) + .map_err(map_sync_config_error)?; + let root_read = exact_fastly_read(materialized_root, max_backend_bytes) + .map_err(|_size_error| ConfigStoreError::ValueTooLarge)?; + let Some(root_value) = root_read.value else { + return Ok(BoundedStoreRead { + backend_bytes: root_read.backend_bytes, + value: None, + }); + }; + + let transient = Cell::new(false); + let outcome = resolve_fastly_config_value_typed_bounded( + key, + root_value, + root_read.backend_bytes, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + |chunk_key, remaining_backend_bytes| { + let chunk_value = run_sync_host_call(clock, deadline, || { + self.read_entry(chunk_key, remaining_backend_bytes) + }) + .map_err(map_sync_config_error)?; + if chunk_value.is_none() { + transient.set(true); + } + exact_fastly_read(chunk_value, remaining_backend_bytes) + .map_err(|_size_error| ConfigStoreError::ValueTooLarge) + }, + ); + + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } match outcome { - Ok(resolved) => Ok(Some(resolved)), - Err(err) if err.is_future_format() => { - // A NEWER format in OUR reserved namespace (an unknown - // `edgezero_kind`, or a future pointer/inner-envelope version): - // re-pushing the same config will not help; the deployed build - // must be UPGRADED. - log::warn!( - "Fastly config-store value for `{key}` uses a NEWER format than this build \ - understands: {}. Re-pushing the same config will not help -- redeploy this \ - service with an updated EdgeZero build.", - err.into_message() - ); - Err(ConfigStoreError::internal(anyhow::anyhow!( - "config store value uses a newer format than this build understands; redeploy \ - this service with an updated build (re-pushing will not help)" - ))) - } - Err(err) => { - let message = err.into_message(); - if transient.get() { - log::warn!( - "Fastly config-store chunk lookup for `{key}` was transiently \ - unavailable: {message}" - ); - Err(ConfigStoreError::unavailable( - "config store temporarily unavailable", - )) - } else { - log::warn!( - "Fastly config-store chunk resolution failed for `{key}`: {message}. \ - Re-run ` config push` to repair the store." - ); - Err(ConfigStoreError::internal(anyhow::anyhow!( - "config store entry is corrupt or incomplete; re-run config push to \ - repair: {message}" - ))) + Ok(read) => { + let value = read.value.map(Into::into); + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); } + Ok(BoundedStoreRead { + backend_bytes: read.backend_bytes, + value, + }) + } + Err(BoundedResolveFailure::Backend(error)) => Err(error), + Err(BoundedResolveFailure::DeadlineExceeded) => Err(ConfigStoreError::DeadlineExceeded), + Err(BoundedResolveFailure::Resolve(error)) => { + Err(map_resolve_failure(key, transient.get(), error)) } + Err(BoundedResolveFailure::ValueTooLarge) => Err(ConfigStoreError::ValueTooLarge), } } } -/// Is a CHUNK lookup failure environmental (retry) rather than corrupt config -/// (`config push` to repair)? Only a bad KEY names corrupt state a re-push -/// rewrites; everything else — an invalid store handle, lookup exhaustion, an -/// unclassified/future failure, or a value that outgrew the read buffer — is -/// transient, because re-pushing cannot fix a request-scoped condition. -fn is_transient_lookup(err: &LookupError) -> bool { - // `ValueTooLong` is TRANSIENT, not corruption: the SDK already retried with - // the reported buffer size, so a `ValueTooLong` reaching us means the value - // GREW between host calls (a concurrent write) — a race a retry resolves, not - // a re-push-me corruption. - !matches!(err, LookupError::KeyInvalid | LookupError::KeyTooLong) -} - -fn map_lookup_error(err: &LookupError) -> ConfigStoreError { - // `LookupError` is from the `fastly` crate; using a wildcard arm guards - // against new variants being added in upstream point releases without - // forcing us into a breaking match every bump. - #[expect( - clippy::wildcard_enum_match_arm, - reason = "external enum; new variants must remain unavailable→unavailable" - )] - match err { - LookupError::KeyInvalid | LookupError::KeyTooLong => { - ConfigStoreError::invalid_key("invalid config key") +fn read_host_buffer( + limit: u64, + read: impl FnOnce(&mut [u8]) -> (FastlyStatus, usize), +) -> Result, ConfigStoreError> { + let length = usize::try_from(limit).map_err(|_error| ConfigStoreError::ValueTooLarge)?; + let mut buffer = Vec::new(); + buffer + .try_reserve_exact(length) + .map_err(|_error| ConfigStoreError::ValueTooLarge)?; + buffer.resize(length, 0); + let (status, written) = read(&mut buffer); + match status { + FastlyStatus::OK if written <= buffer.len() => { + buffer.truncate(written); + String::from_utf8(buffer) + .map(Some) + .map_err(|_error| ConfigStoreError::unavailable("config value is not UTF-8")) } - _ => { - log::warn!("Fastly config store lookup failed: {err}"); - ConfigStoreError::unavailable("config store temporarily unavailable") + FastlyStatus::NONE => Ok(None), + FastlyStatus::BUFLEN => Err(ConfigStoreError::ValueTooLarge), + FastlyStatus::INVAL | FastlyStatus::UNSUPPORTED => { + Err(ConfigStoreError::invalid_key("invalid config key")) } + _ => Err(ConfigStoreError::unavailable("config store read failed")), + } +} + +fn map_sync_config_error(call_error: SyncHostCallError) -> ConfigStoreError { + match call_error { + SyncHostCallError::Backend(backend_error) => backend_error, + SyncHostCallError::DeadlineExceeded => ConfigStoreError::DeadlineExceeded, + } +} + +fn map_resolve_failure(key: &str, transient: bool, error: ResolveFailure) -> ConfigStoreError { + if error.is_future_format() { + log::warn!( + "Fastly config-store value for `{key}` uses a NEWER format than this build \ + understands: {}. Re-pushing the same config will not help -- redeploy this \ + service with an updated EdgeZero build.", + error.into_message() + ); + return ConfigStoreError::internal(anyhow::anyhow!( + "config store value uses a newer format than this build understands; redeploy \ + this service with an updated build (re-pushing will not help)" + )); + } + + let message = error.into_message(); + if transient { + log::warn!( + "Fastly config-store chunk lookup for `{key}` was transiently unavailable: {message}" + ); + ConfigStoreError::unavailable("config store temporarily unavailable") + } else { + log::warn!( + "Fastly config-store chunk resolution failed for `{key}`: {message}. \ + Re-run ` config push` to repair the store." + ); + ConfigStoreError::internal(anyhow::anyhow!( + "config store entry is corrupt or incomplete; re-run config push to repair: {message}" + )) } } #[cfg(test)] mod tests { use super::*; + use edgezero_core::{Deadline, MonotonicClock, MonotonicInstant}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; edgezero_core::config_store_contract_tests!(fastly_config_store_contract, { FastlyConfigStore::from_entries([ @@ -198,31 +311,131 @@ mod tests { }); #[test] - fn key_invalid_maps_to_invalid_key_error() { - let err = map_lookup_error(&LookupError::KeyInvalid); - assert!(matches!(err, ConfigStoreError::InvalidKey { .. })); + fn bounded_chunked_read_counts_root_pointer_and_all_chunks() { + use crate::chunked_config::prepare_fastly_config_entries; + use edgezero_core::blob_envelope::BlobEnvelope; + use futures::executor::block_on; + use serde_json::json; + + let envelope = serde_json::to_string(&BlobEnvelope::new( + json!({ "pad": "x".repeat(9_000) }), + "2026-01-01T00:00:00Z".to_owned(), + )) + .expect("envelope"); + let entries = prepare_fastly_config_entries("app_config", &envelope).expect("entries"); + let maybe_backend_bytes = entries.iter().try_fold(0_u64, |total, (_, value)| { + total.checked_add(u64::try_from(value.len()).ok()?) + }); + let expected_backend_bytes = maybe_backend_bytes.expect("backend byte sum"); + let store = FastlyConfigStore::from_entries(entries); + let clock = MonotonicClock::default(); + + let read = block_on(store.get_bounded( + "app_config", + &clock, + Deadline::after(Duration::from_secs(1)), + expected_backend_bytes, + u64::try_from(envelope.len()).expect("envelope length"), + )) + .expect("exact aggregate cap must succeed"); + + assert_eq!(read.backend_bytes, expected_backend_bytes); + assert_eq!(read.value.as_deref(), Some(envelope.as_str())); } #[test] - fn key_too_long_maps_to_invalid_key_error() { - let err = map_lookup_error(&LookupError::KeyTooLong); - assert!(matches!(err, ConfigStoreError::InvalidKey { .. })); + fn bounded_config_read_uses_injected_clock() { + use futures::executor::block_on; + + let process_now = MonotonicInstant::now(); + let injected_now = process_now + .checked_sub(Duration::from_mins(1)) + .expect("injected instant"); + let clock = MonotonicClock::new(move || injected_now); + let deadline = Deadline::at_instant( + injected_now + .checked_add(Duration::from_secs(1)) + .expect("deadline instant"), + ); + let store = FastlyConfigStore::from_entries([("key".to_owned(), "value".to_owned())]); + let read = block_on(store.get_bounded("key", &clock, deadline, 5, 5)) + .expect("the application clock is still before its deadline"); + assert_eq!(read.value.as_deref(), Some("value")); + + let start = MonotonicInstant::now(); + let terminal = start + .checked_add(Duration::from_secs(1)) + .expect("terminal instant"); + let now = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&now); + let advancing_clock = MonotonicClock::new(move || *clock_now.lock().expect("clock lock")); + let error = run_sync_host_call(&advancing_clock, Deadline::at_instant(terminal), || { + *now.lock().expect("clock lock") = terminal; + Err::<(), _>(ConfigStoreError::unavailable("provider failed at expiry")) + }) + .expect_err("equality expiry must beat a ready provider error"); + assert!(matches!(error, SyncHostCallError::DeadlineExceeded)); } - /// A CHUNK lookup that fails transiently (lookup exhaustion, an invalid - /// store handle, an unclassified error, or a value that outgrew the read - /// buffer) must keep Unavailable semantics — re-pushing cannot repair a - /// request-scoped condition. Only a bad KEY is corrupt state a re-push fixes. #[test] - fn transient_chunk_lookups_are_not_treated_as_corruption() { - assert!(is_transient_lookup(&LookupError::TooManyLookups)); - assert!(is_transient_lookup(&LookupError::ConfigStoreInvalid)); - assert!(is_transient_lookup(&LookupError::Other)); - // ValueTooLong is TRANSIENT: the SDK already retried at the reported size, - // so it reaching us means the value grew between host calls (a race). - assert!(is_transient_lookup(&LookupError::ValueTooLong)); - assert!(!is_transient_lookup(&LookupError::KeyInvalid)); - assert!(!is_transient_lookup(&LookupError::KeyTooLong)); + fn fixture_matches_physical_entry_byte_ceiling() { + let store = FastlyConfigStore::from_entries([("key".to_owned(), "x".repeat(32_001))]); + let error = store.read_entry("key", u64::MAX).expect_err("physical cap"); + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + } + + #[test] + fn host_buffer_accepts_exact_cap_without_copying_the_string() { + let value = read_host_buffer(4, |buffer| { + assert_eq!(buffer.len(), 4); + buffer.copy_from_slice(b"test"); + (FastlyStatus::OK, 4) + }) + .expect("exact cap"); + assert_eq!(value.as_deref(), Some("test")); + } + + #[test] + fn host_buffer_never_retries_or_allocates_the_reported_oversize_length() { + for limit in [0, 1, 7_000] { + let calls = Cell::new(0_u32); + let error = read_host_buffer(limit, |buffer| { + calls.set(calls.get() + 1); + assert_eq!(u64::try_from(buffer.len()).expect("buffer length"), limit); + (FastlyStatus::BUFLEN, usize::MAX) + }) + .expect_err("oversize"); + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + assert_eq!(calls.get(), 1); + } + } + + #[test] + fn host_buffer_status_and_malformed_results_are_typed_and_redacted() { + for status in [FastlyStatus::INVAL, FastlyStatus::UNSUPPORTED] { + let error = read_host_buffer(1, |_buffer| (status, 0)).expect_err("key failure"); + assert!(matches!(error, ConfigStoreError::InvalidKey { .. })); + } + for status in [ + FastlyStatus::ERROR, + FastlyStatus::BADF, + FastlyStatus::LIMITEXCEEDED, + FastlyStatus::OK, + ] { + let error = read_host_buffer(1, |_buffer| (status, 2)).expect_err("bad result"); + assert!(matches!(error, ConfigStoreError::Unavailable { .. })); + } + assert!( + read_host_buffer(0, |_buffer| (FastlyStatus::NONE, 0)) + .expect("missing") + .is_none() + ); + let error = read_host_buffer(1, |buffer| { + buffer.fill(255); + (FastlyStatus::OK, 1) + }) + .expect_err("invalid UTF-8"); + assert!(matches!(error, ConfigStoreError::Unavailable { .. })); } /// A referenced chunk that is ABSENT maps to Unavailable (HTTP 503), not @@ -270,7 +483,7 @@ mod tests { // would not exercise the corruption path at all. let store = FastlyConfigStore::from_entries([( "app_config".to_owned(), - r#"{"edgezero_kind":"fastly_config_chunks"}"#.to_owned(), + serde_json::json!({"edgezero_kind": "fastly_config_chunks"}).to_string(), )]); let err = block_on(store.get("app_config")) .expect_err("corrupt root must map to a ConfigStoreError"); @@ -296,7 +509,7 @@ mod tests { use futures::executor::block_on; let store = FastlyConfigStore::from_entries([( "app_config".to_owned(), - r#"{"edgezero_kind":"fastly_config_chunks","version":2,"chunks":[]}"#.to_owned(), + serde_json::json!({"edgezero_kind": "fastly_config_chunks", "version": 2_u64, "chunks": []}).to_string(), )]); let err = block_on(store.get("app_config")).expect_err("a future format must error"); assert!( diff --git a/crates/edgezero-adapter-fastly/src/fastly_response_abi.rs b/crates/edgezero-adapter-fastly/src/fastly_response_abi.rs new file mode 100644 index 000000000..0090cd7f3 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/fastly_response_abi.rs @@ -0,0 +1,112 @@ +#![allow( + unsafe_code, + reason = "the status-returning Fastly ABI avoids panic-on-error SDK response wrappers" +)] + +use fastly_shared::{ + BodyWriteEnd, FastlyStatus, FramingHeadersMode, INVALID_BODY_HANDLE, INVALID_RESPONSE_HANDLE, +}; +use fastly_sys::{BodyHandle, ResponseHandle, fastly_http_body, fastly_http_resp}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct FastlyAbiError; + +pub(super) fn abandon_body(body: BodyHandle) -> Result<(), FastlyAbiError> { + // SAFETY: `body` is an owned, live body handle and this call consumes it. + result(unsafe { fastly_http_body::abandon(body) }) +} + +pub(super) fn append_header( + response: ResponseHandle, + name: &[u8], + value: &[u8], +) -> Result<(), FastlyAbiError> { + // SAFETY: both byte slices remain live for the duration of the synchronous hostcall. + result(unsafe { + fastly_http_resp::header_append( + response, + name.as_ptr(), + name.len(), + value.as_ptr(), + value.len(), + ) + }) +} + +pub(super) fn close_body(body: BodyHandle) -> Result<(), FastlyAbiError> { + // SAFETY: `body` is an owned, live body handle and this call consumes it. + result(unsafe { fastly_http_body::close(body) }) +} + +pub(super) fn close_response(response: ResponseHandle) -> Result<(), FastlyAbiError> { + // SAFETY: `response` is an owned, live response handle and this call consumes it. + result(unsafe { fastly_http_resp::close(response) }) +} + +pub(super) fn new_body() -> Result { + let mut handle = INVALID_BODY_HANDLE; + // SAFETY: the output pointer is valid and points to initialized `u32` storage. + result(unsafe { fastly_http_body::new(&raw mut handle) })?; + Ok(handle) +} + +pub(super) fn new_response() -> Result { + let mut handle = INVALID_RESPONSE_HANDLE; + // SAFETY: the output pointer is valid and points to initialized `u32` storage. + result(unsafe { fastly_http_resp::new(&raw mut handle) })?; + Ok(handle) +} + +fn result(status: FastlyStatus) -> Result<(), FastlyAbiError> { + status.result().map_err(|_status| FastlyAbiError) +} + +pub(super) fn send_streaming( + response: ResponseHandle, + body: BodyHandle, +) -> Result<(), FastlyAbiError> { + // SAFETY: both handles are exclusively owned and are consumed by the send attempt. + result(unsafe { fastly_http_resp::send_downstream(response, body, 1) }) +} + +pub(super) fn set_manual_framing(response: ResponseHandle) -> Result<(), FastlyAbiError> { + // SAFETY: `response` is a live, exclusively owned response handle. + result(unsafe { + fastly_http_resp::framing_headers_mode_set( + response, + FramingHeadersMode::ManuallyFromHeaders, + ) + }) +} + +pub(super) fn set_status(response: ResponseHandle, status: u16) -> Result<(), FastlyAbiError> { + // SAFETY: `response` is a live, exclusively owned response handle. + result(unsafe { fastly_http_resp::status_set(response, status) }) +} + +pub(super) fn write_body(body: BodyHandle, bytes: &[u8]) -> Result { + let mut written = 0; + // SAFETY: `body` is live, `bytes` remains live during the synchronous hostcall, and the + // output pointer refers to initialized `usize` storage. + result(unsafe { + fastly_http_body::write( + body, + bytes.as_ptr(), + bytes.len(), + BodyWriteEnd::Back, + &raw mut written, + ) + })?; + Ok(written) +} + +#[cfg(test)] +mod tests { + use super::{FastlyAbiError, FastlyStatus, result}; + + #[test] + fn status_result_preserves_success_and_collapses_host_failure() { + assert_eq!(result(FastlyStatus::OK), Ok(())); + assert_eq!(result(FastlyStatus::ERROR), Err(FastlyAbiError)); + } +} diff --git a/crates/edgezero-adapter-fastly/src/key_value_store.rs b/crates/edgezero-adapter-fastly/src/key_value_store.rs index 111d18fda..9ca5f7c39 100644 --- a/crates/edgezero-adapter-fastly/src/key_value_store.rs +++ b/crates/edgezero-adapter-fastly/src/key_value_store.rs @@ -6,26 +6,19 @@ //! //! This module is only compiled when the `fastly` feature is enabled. -#[cfg(feature = "fastly")] use async_trait::async_trait; -#[cfg(feature = "fastly")] use bytes::Bytes; -#[cfg(feature = "fastly")] use edgezero_core::key_value_store::{KvError, KvPage, KvStore}; -#[cfg(feature = "fastly")] use fastly::kv_store::{KVStore, KVStoreError}; -#[cfg(feature = "fastly")] use std::time::Duration; /// KV store backed by Fastly's KV Store API. /// /// Wraps a `fastly::kv_store::KVStore` handle obtained via `KVStore::open(name)`. -#[cfg(feature = "fastly")] pub struct FastlyKvStore { store: KVStore, } -#[cfg(feature = "fastly")] impl FastlyKvStore { /// Open a Fastly KV Store by name. /// @@ -42,7 +35,6 @@ impl FastlyKvStore { } } -#[cfg(feature = "fastly")] #[async_trait(?Send)] impl KvStore for FastlyKvStore { #[inline] diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 6ba195a63..000708c40 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -5,15 +5,21 @@ feature = "fastly", expect( clippy::pub_use, - reason = "re-export the SDK serving builder rather than duplicate its API" + reason = "reuse the platform serving limits and summary" ) )] -// Only compiled where it is actually used by the CLI push/GC path. Gating it -// keeps a `--no-default-features` build dead-code clean. -#[cfg(any(feature = "cli", feature = "fastly", test))] +#[cfg(feature = "fastly")] +use anyhow::Context as _; + +// Compile storage helpers only for their runtime, CLI or colocated tests. +#[cfg(any( + feature = "fastly", + test, + all(feature = "cli", not(target_arch = "wasm32")) +))] pub(crate) mod chunked_config; -#[cfg(feature = "cli")] +#[cfg(all(feature = "cli", not(target_arch = "wasm32")))] pub mod cli; #[cfg(feature = "fastly")] pub mod config_store; @@ -21,27 +27,54 @@ pub mod context; #[cfg(feature = "fastly")] pub mod key_value_store; pub mod lifecycle; -#[cfg(feature = "fastly")] +#[cfg(any(feature = "fastly", all(test, not(target_arch = "wasm32"))))] pub mod logger; -#[cfg(feature = "fastly")] -pub mod proxy; +#[cfg(any(test, feature = "test-utils", feature = "fastly"))] +pub mod outbound; #[cfg(feature = "fastly")] pub mod request; -#[cfg(feature = "fastly")] +#[cfg(any(test, feature = "fastly"))] pub mod response; #[cfg(feature = "fastly")] pub mod secret_store; #[cfg(any(feature = "fastly", test))] -use edgezero_core::app::App; -#[cfg(feature = "fastly")] -use edgezero_core::app::Hooks; +use edgezero_core::app::{App, Hooks}; +#[cfg(any(feature = "fastly", test))] +use edgezero_core::error::EdgeError; #[cfg(feature = "fastly")] -use edgezero_core::http::Extensions; +use edgezero_core::http::{Extensions, Response}; #[cfg(any(feature = "fastly", test))] use edgezero_core::manifest::ResolvedLoggingConfig; #[cfg(feature = "fastly")] pub use fastly::http::serve::{Serve, ServeSummary}; +use std::num::NonZeroU32; +#[cfg(feature = "fastly")] +use std::sync::Once; + +/// Fastly Compute's published per-execution heap and stack limits. +pub const FASTLY_PLATFORM: edgezero_core::PlatformMetadata = edgezero_core::PlatformMetadata::new( + edgezero_core::PlatformFact::known( + edgezero_core::MemoryCeiling::new( + 128_000_000, + edgezero_core::MemoryCeilingScope::PerExecution, + Some(1_000_000), + ), + edgezero_core::PlatformResourceSource::PlatformLimit { + provider: "Fastly Compute", + }, + ), + edgezero_core::PlatformFact::known( + edgezero_core::InboundRequestPopulationBound::new(NonZeroU32::MIN), + edgezero_core::PlatformResourceSource::PlatformLimit { + provider: "Fastly Compute", + }, + ), + edgezero_core::PlatformFact::unknown(edgezero_core::PlatformUnknownReason::ProviderUnpublished), +); + +#[cfg(feature = "fastly")] +static FASTLY_ABI_INIT: Once = Once::new(); #[cfg(any(feature = "fastly", test))] #[derive(Debug, Clone)] @@ -66,30 +99,27 @@ impl From for FastlyLogging { } } -#[cfg(any(feature = "fastly", test))] -#[derive(Default)] -struct RetainedApp { - app: Option, +#[cfg(feature = "fastly")] +fn build_app_for_dispatch() -> Result { + build_target_app::().context("application configuration failed") } #[cfg(any(feature = "fastly", test))] -impl RetainedApp { - fn get_or_init( - &mut self, - logging: &FastlyLogging, - owns_logging: impl FnOnce() -> bool, - install_logger: impl FnOnce(&str, log::LevelFilter, bool) -> Result<(), E>, - build: impl FnOnce() -> App, - ) -> Result<&App, E> { - if self.app.is_none() && logging.use_fastly_logger && !owns_logging() { - install_logger( - logging.endpoint.as_deref().unwrap_or("stdout"), - logging.level, - logging.echo_stdout, - )?; - } - Ok(self.app.get_or_insert_with(build)) - } +fn build_target_app() -> Result { + App::build::(FASTLY_PLATFORM) +} + +/// Test seam for the production application-assembly error mapping. +#[cfg(all(feature = "test-utils", feature = "fastly"))] +#[doc(hidden)] +#[inline] +pub fn build_app_for_test() -> Result { + build_app_for_dispatch::() +} + +#[cfg(feature = "fastly")] +fn init_fastly_abi() { + FASTLY_ABI_INIT.call_once(fastly::init); } /// # Errors @@ -129,42 +159,102 @@ pub fn init_logger( /// Logger setup failures and unavailable required stores return errors. #[cfg(feature = "fastly")] #[inline] -pub fn run_app(req: fastly::Request) -> Result { - run_app_with_request_extensions::(req, |_req, _extensions| {}) +pub fn run_app() -> Result<(), fastly::Error> { + run_app_with_hooks::(|_req, _extensions| {}, |_response| ()).map(|_state| ()) } -/// Like [`run_app`], but runs `extend` against a scratch -/// [`Extensions`] populated from the raw -/// `fastly::Request` (TLS JA4, H2 fingerprint, client IP, …) before the request -/// is converted; the scratch values are merged into the core request's -/// extensions and are visible to middleware and the `State`/extractor layer. +/// Runs the manifest-wired app through Fastly's closed request/response lifecycle. +/// +/// `prepare` borrows the raw request and extension bag before conversion. `finalize` borrows only +/// routed responses before response-egress policy and framing. `EdgeZero` retains transmission +/// ownership; routed hook state is returned only after terminal delivery, while detached ingress +/// responses skip `finalize` and return `None`. /// /// # Errors /// Logger setup failures and unavailable required stores return errors. #[cfg(feature = "fastly")] #[inline] -pub fn run_app_with_request_extensions( - req: fastly::Request, - extend: F, -) -> Result +pub fn run_app_with_hooks( + prepare: Prepare, + finalize: Finalize, +) -> Result, fastly::Error> where A: Hooks, - F: FnOnce(&fastly::Request, &mut Extensions), + Prepare: FnOnce(&mut fastly::Request, &mut Extensions), + Finalize: FnOnce(&mut Response) -> State, { + init_fastly_abi(); let stores = A::stores(); let logging = FastlyLogging::from(A::logging_for("fastly")); if logging.use_fastly_logger && !A::owns_logging() { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); init_logger(endpoint, logging.level, logging.echo_stdout)?; } - let app = A::build_app(); - request::dispatch_with_registries(&app, req, stores, extend) + let app = build_app_for_dispatch::()?; + request::send_with_registries_and_hooks(&app, stores, prepare, finalize) +} + +/// Retain a successfully built app while the SDK serves sequential requests. +/// +/// Each callback resolves fresh logical store registries. Logging uses baked +/// settings, and the adapter owns response delivery without SDK resends. +#[cfg(feature = "fastly")] +#[must_use = "inspect the summary or handle its terminal error with into_result()"] +#[inline] +pub fn serve_app(serve: Serve) -> ServeSummary { + serve_app_with_hooks::(serve, |_request, _extensions| {}, |_response| ()) +} + +/// Retained serving with request-local preparation and routed response finalization. +/// +/// The hooks follow [`run_app_with_hooks`]. Preparation runs before admission; +/// finalization runs only for routed responses, before adapter-owned delivery. +/// Captures persist across callbacks and must not accidentally retain request resources. +#[cfg(feature = "fastly")] +#[must_use = "inspect the summary or handle its terminal error with into_result()"] +#[inline] +pub fn serve_app_with_hooks( + serve: Serve, + mut prepare: Prepare, + mut finalize: Finalize, +) -> ServeSummary +where + A: Hooks, + Prepare: FnMut(&mut fastly::Request, &mut Extensions), + Finalize: FnMut(&mut Response) -> State, +{ + init_fastly_abi(); + let stores = A::stores(); + let logging = FastlyLogging::from(A::logging_for("fastly")); + lifecycle::serve_custom(serve, move |req, retained: &mut lifecycle::Sandbox| { + retained.setup_once(|| { + if logging.use_fastly_logger && !A::owns_logging() { + init_logger( + logging.endpoint.as_deref().unwrap_or("stdout"), + logging.level, + logging.echo_stdout, + )?; + } + Ok::<(), fastly::Error>(()) + })?; + retained.initialize(build_app_for_dispatch::)?; + let app = retained.state().ok_or_else(|| { + fastly::Error::msg("application initialization did not retain an app") + })?; + request::send_request_with_registries_and_hooks( + app, + stores, + req, + &mut prepare, + &mut finalize, + ) + .map(|_state| ()) + }) } -/// Dispatch with a config store wired explicitly. Its name and default key are -/// provided by the caller rather than derived from manifest store metadata. -/// KV is not auto-injected on this path; chain `.with_kv(name)` on a -/// [`request::FastlyService`] builder if you need KV alongside the config store. +/// Dispatch with an explicitly wired config store. Its name comes from the caller, +/// and its default key is `"default"`, not a manifest logical ID. KV is not +/// automatically injected; use [`request::FastlyService`] for explicit bindings. /// /// # Errors /// Returns an error if logger setup fails or the underlying handler returns an error. @@ -172,19 +262,19 @@ where #[inline] pub fn run_app_with_config( logging: &FastlyLogging, - req: fastly::Request, config_store_name: Option<&str>, -) -> Result { +) -> Result<(), fastly::Error> { + init_fastly_abi(); if logging.use_fastly_logger && !A::owns_logging() { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); init_logger(endpoint, logging.level, logging.echo_stdout)?; } - let app = A::build_app(); + let app = build_app_for_dispatch::()?; let mut service = request::FastlyService::new(&app); if let Some(name) = config_store_name { service = service.with_config(name); } - service.dispatch(req) + service.send() } #[cfg(test)] @@ -219,23 +309,24 @@ mod fastly_logging_tests { } #[cfg(test)] -mod retained_app_tests { - use super::*; +mod platform_tests { use edgezero_core::app::{App, Hooks}; + use edgezero_core::error::EdgeError; use edgezero_core::router::RouterService; - use std::cell::{Cell, RefCell}; - struct CountedApp; - thread_local! { static CONFIGURES: Cell = const { Cell::new(0) }; } + struct PlatformAwareConfiguration; #[expect( clippy::missing_trait_methods, - reason = "exercise default app construction" + reason = "test hook exercises only target metadata propagation" )] - impl Hooks for CountedApp { - fn configure(app: &mut App) { - CONFIGURES.with(|count| count.set(count.get().checked_add(1).unwrap())); - app.set_name("retained"); + impl Hooks for PlatformAwareConfiguration { + fn configure(app: &mut App) -> Result<(), EdgeError> { + if app.platform() == crate::FASTLY_PLATFORM { + Ok(()) + } else { + Err(EdgeError::service_unavailable("wrong application platform")) + } } fn routes() -> RouterService { @@ -243,141 +334,11 @@ mod retained_app_tests { } } - fn configured_logging() -> FastlyLogging { - FastlyLogging { - echo_stdout: true, - endpoint: Some("fixture-logs".to_owned()), - level: log::LevelFilter::Debug, - use_fastly_logger: true, - } - } - - #[test] - fn retained_app_initializes_logging_before_build_once() { - let mut retained = RetainedApp::default(); - let events = RefCell::new(Vec::new()); - CONFIGURES.with(|count| count.set(0)); - for _ in 0_usize..2 { - let app = retained - .get_or_init( - &configured_logging(), - || false, - |endpoint, level, echo| { - assert_eq!(endpoint, "fixture-logs"); - assert_eq!(level, log::LevelFilter::Debug); - assert!(echo); - events.borrow_mut().push("logger"); - Ok::<(), &'static str>(()) - }, - || { - events.borrow_mut().push("build"); - CountedApp::build_app() - }, - ) - .unwrap(); - assert_eq!(app.name(), "retained"); - } - assert_eq!(*events.borrow(), ["logger", "build"]); - CONFIGURES.with(|count| assert_eq!(count.get(), 1)); - } - - #[test] - fn retained_app_skips_disabled_logging_and_builds_once() { - let mut retained = RetainedApp::default(); - let builds = Cell::new(0_usize); - let logging = FastlyLogging::from(ResolvedLoggingConfig::default()); - for _ in 0_usize..2 { - retained - .get_or_init( - &logging, - || false, - |_, _, _| -> Result<(), &'static str> { panic!("logger is disabled") }, - || { - builds.set(builds.get().checked_add(1).unwrap()); - CountedApp::build_app() - }, - ) - .unwrap(); - } - assert_eq!(builds.get(), 1); - } - #[test] - fn retained_app_respects_owned_logging_and_fresh_owners() { - let builds = Cell::new(0_usize); - for _ in 0_usize..2 { - let mut retained = RetainedApp::default(); - retained - .get_or_init( - &configured_logging(), - || true, - |_, _, _| -> Result<(), &'static str> { panic!("caller owns logging") }, - || { - builds.set(builds.get().checked_add(1).unwrap()); - CountedApp::build_app() - }, - ) - .unwrap(); - } - assert_eq!(builds.get(), 2); - } + fn application_configuration_receives_fastly_platform_metadata() { + let app = super::build_target_app::() + .expect("configured application"); - #[test] - fn retained_app_logger_error_prevents_construction() { - let mut retained = RetainedApp::default(); - let result = retained.get_or_init( - &configured_logging(), - || false, - |_, _, _| Err("logger failed"), - || panic!("must not build"), - ); - assert_eq!(result.err(), Some("logger failed")); - assert!(retained.app.is_none()); + assert_eq!(app.platform(), crate::FASTLY_PLATFORM); } } - -/// Serve requests with an app initialized once on the first callback. -/// -/// Opt in using an ordinary `main` and an explicitly bounded [`Serve`]. The -/// runtime may exit before any configured limit; every request must tolerate -/// fresh initialization. The standard response conversion buffers core streams. -/// Inspect the returned summary (whose request count includes failed attempts) -/// or call its `into_result()` method to propagate terminal callback errors. -#[cfg(feature = "fastly")] -#[must_use = "inspect the serving summary or call into_result() to handle terminal errors"] -#[inline] -pub fn serve_app(serve: Serve) -> ServeSummary { - serve_app_with_request_extensions::(serve, |_request, _extensions| {}) -} - -/// Serve a retained app with fresh request extensions and store registries. -/// -/// Store aliases and logging settings are baked into `A`. The app and logging -/// initialization are retained for the serving loop, while request extensions -/// and store handles are recreated for every callback. Any returned error -/// terminates the SDK loop; handler `EdgeError`s that render as responses do not. -/// Construction panics remain sandbox failures. -/// -/// The callback runs per request, but the closure and its captured state live -/// for the entire serving loop. Create request-local mutable data inside the -/// callback. For mutable native requests, manual streaming, response -/// finalization, or custom initialization, use [`lifecycle::serve_custom`]. -#[cfg(feature = "fastly")] -#[must_use = "inspect the serving summary or call into_result() to handle terminal errors"] -#[inline] -pub fn serve_app_with_request_extensions( - serve: Serve, - mut extend: F, -) -> ServeSummary -where - A: Hooks, - F: FnMut(&fastly::Request, &mut Extensions), -{ - let stores = A::stores(); - let logging = FastlyLogging::from(A::logging_for("fastly")); - let mut retained = RetainedApp::default(); - serve.run(move |req| -> Result { - let app = retained.get_or_init(&logging, A::owns_logging, init_logger, A::build_app)?; - request::dispatch_with_registries(app, req, stores, &mut extend) - }) -} diff --git a/crates/edgezero-adapter-fastly/src/lifecycle.rs b/crates/edgezero-adapter-fastly/src/lifecycle.rs index 11400e696..64f75c566 100644 --- a/crates/edgezero-adapter-fastly/src/lifecycle.rs +++ b/crates/edgezero-adapter-fastly/src/lifecycle.rs @@ -17,6 +17,18 @@ pub struct Sandbox { state: Option, } +#[cfg(any(feature = "fastly", test))] +struct DeliveryResult(Result<(), E>); + +#[cfg(feature = "fastly")] +impl HandlerResult for DeliveryResult { + type Error = E; + + fn send(self) -> Result<(), E> { + self.0 + } +} + impl Default for Sandbox { #[inline] fn default() -> Self { @@ -105,54 +117,70 @@ impl Sandbox { /// Serve custom callbacks with lazy retained state and the supplied SDK limits. /// /// The callback controls initialization, dispatch, finalization and streaming. -/// Its result goes directly to the SDK's sending boundary. A callback that sends -/// its own response should return `()` or `Ok(())`; after commitment, handle -/// failures locally rather than return an error that would send another response. +/// The callback owns sending and returns only a terminal `Result<(), E>`. The +/// SDK records that result without sending a synthetic error response. Errors +/// terminate serving, including failures after commitment, without a second send. /// /// SDK limits are upper bounds, not a guarantee of reuse. This function does not /// read configuration or enable reuse in existing entry points. #[cfg(feature = "fastly")] #[must_use = "inspect the serving summary or call into_result() to handle terminal errors"] #[inline] -pub fn serve_custom(serve: Serve, mut handler: F) -> ServeSummary +pub fn serve_custom(serve: Serve, mut handler: F) -> ServeSummary where - F: FnMut(fastly::Request, &mut Sandbox) -> R, - R: HandlerResult, + F: FnMut(fastly::Request, &mut Sandbox) -> Result<(), E>, { + crate::init_fastly_abi(); let mut sandbox = Sandbox::default(); serve.run_with_context( - |request, context: &mut Sandbox| context.handle(request, &mut handler), + |request, context: &mut Sandbox| DeliveryResult(context.handle(request, &mut handler)), &mut sandbox, ) } /// Handle one request with fresh state, without entering the SDK serving loop. /// -/// Takes a request already received by the caller and completes the callback's -/// `HandlerResult` exactly once. Use from an ordinary `fn main`: propagating a -/// returned error through `#[fastly::main]` could attempt a second error response. -/// Explicitly sending callbacks follow the same rules as [`serve_custom`]. +/// Takes a request already received by the caller. Sending remains owned by the +/// callback; this function never asks the SDK to render an error. Use an ordinary `main`. /// /// # Errors -/// Returns the error from completing the callback's SDK result. +/// Returns the callback error unchanged without another response attempt. #[cfg(feature = "fastly")] #[inline] -pub fn run_custom(request: fastly::Request, handler: F) -> Result<(), R::Error> +pub fn run_custom(request: fastly::Request, handler: F) -> Result<(), E> where - R: HandlerResult, - F: FnOnce(fastly::Request, &mut Sandbox) -> R, + F: FnOnce(fastly::Request, &mut Sandbox) -> Result<(), E>, { - Sandbox::default().handle(request, handler).send() + crate::init_fastly_abi(); + Sandbox::default().handle(request, handler) } #[cfg(test)] mod tests { - use super::Sandbox; + use super::{DeliveryResult, Sandbox}; // Neither the retained value nor an error payload needs framework traits. struct App(u64); struct Fallback(&'static str); + #[test] + fn owned_delivery_retains_terminal_error_without_synthetic_send() { + let result = DeliveryResult(Err::<(), _>("post-commit error")); + assert_eq!(result.0, Err("post-commit error")); + } + + #[cfg(feature = "fastly")] + #[test] + fn sdk_delivery_result_returns_terminal_error_without_synthetic_send() { + use fastly::http::serve::HandlerResult as _; + + assert_eq!( + DeliveryResult(Err::<(), _>("post-commit error")).send(), + Err("post-commit error") + ); + assert_eq!(DeliveryResult(Ok::<(), &str>(())).send(), Ok(())); + } + #[test] fn early_return_counts_request_without_setup_or_build() { let mut sandbox = Sandbox::::default(); diff --git a/crates/edgezero-adapter-fastly/src/logger.rs b/crates/edgezero-adapter-fastly/src/logger.rs index 1b040eafd..86d1a7ef2 100644 --- a/crates/edgezero-adapter-fastly/src/logger.rs +++ b/crates/edgezero-adapter-fastly/src/logger.rs @@ -1,3 +1,4 @@ +use edgezero_core::logging::{BOOT_LOG_LEVEL, BOOT_LOG_TARGET}; use log::LevelFilter; /// Errors that can occur when initialising the Fastly logger. @@ -13,29 +14,23 @@ pub enum InitLoggerError { SetLogger(#[from] log::SetLoggerError), } -/// Initialize logging (opinionated): formatted timestamps using `fern`, -/// chained to the Fastly logger. -/// -/// # Errors -/// Returns [`InitLoggerError::Build`] if the underlying logger builder -/// rejects its inputs (e.g. an empty endpoint), or -/// [`InitLoggerError::SetLogger`] if a global logger is already installed. -#[inline] -pub fn init_logger( +fn build_backend( endpoint: &str, level: LevelFilter, echo_stdout: bool, -) -> Result<(), InitLoggerError> { - let logger = log_fastly::Logger::builder() +) -> Result { + log_fastly::Logger::builder() .default_endpoint(endpoint) .echo_stdout(echo_stdout) - .max_level(level) + .max_level(level.max(BOOT_LOG_LEVEL)) .build() - .map_err(|err| InitLoggerError::Build(err.to_string()))?; + .map_err(|err| InitLoggerError::Build(err.to_string())) +} - // Format timestamps in RFC3339 with milliseconds using UTC to avoid TZ issues in WASM. - let dispatch = fern::Dispatch::new() +fn build_dispatch(logger: Box, level: LevelFilter) -> fern::Dispatch { + fern::Dispatch::new() .level(level) + .level_for(BOOT_LOG_TARGET, BOOT_LOG_LEVEL) .format(|out, message, record| { out.finish(format_args!( "{} {} {}", @@ -44,12 +39,122 @@ pub fn init_logger( message )); }) - .chain({ - let boxed: Box = Box::new(logger); - boxed - }); + .chain(logger) +} - dispatch.apply()?; - log::set_max_level(level); +/// Initialize logging (opinionated): formatted timestamps using `fern`, +/// chained to the Fastly logger. Boot warnings are retained independently of +/// the ordinary runtime level, including `Off`. +/// +/// # Errors +/// Returns [`InitLoggerError::Build`] if the underlying logger builder +/// rejects its inputs (e.g. an empty endpoint), or +/// [`InitLoggerError::SetLogger`] if a global logger is already installed. +#[inline] +pub fn init_logger( + endpoint: &str, + level: LevelFilter, + echo_stdout: bool, +) -> Result<(), InitLoggerError> { + let logger = build_backend(endpoint, level, echo_stdout)?; + build_dispatch(Box::new(logger), level).apply()?; Ok(()) } + +#[cfg(test)] +#[cfg(not(target_arch = "wasm32"))] +mod tests { + use edgezero_core::logging::{BOOT_LOG_LEVEL, BOOT_LOG_TARGET}; + use log::{Level, LevelFilter, Log as _, Metadata, Record}; + + #[test] + fn backend_rejects_records_even_when_facade_allows_them() { + let logger = log_fastly::Logger::builder() + .default_endpoint("logging-test") + .max_level(LevelFilter::Error) + .build() + .expect("SDK logger"); + assert!( + !logger.enabled( + &Metadata::builder() + .target(BOOT_LOG_TARGET) + .level(Level::Warn) + .build() + ) + ); + } + + #[test] + fn production_filters_preserve_boot_warnings_and_runtime_level() { + let guard = log_fastly::reset_logger( + log_fastly::Logger::builder() + .max_level(LevelFilter::Off) + .build() + .expect("isolated SDK capture"), + ); + + for runtime_level in [ + LevelFilter::Off, + LevelFilter::Error, + LevelFilter::Warn, + LevelFilter::Info, + LevelFilter::Debug, + LevelFilter::Trace, + ] { + let backend = + super::build_backend("logging-test", runtime_level, false).expect("SDK backend"); + let (ceiling, logger) = + super::build_dispatch(Box::new(backend), runtime_level).into_log(); + assert_eq!(ceiling, runtime_level.max(BOOT_LOG_LEVEL)); + let preserved_warning = format!("preserved boot warning {runtime_level}"); + + for level in [ + Level::Error, + Level::Warn, + Level::Info, + Level::Debug, + Level::Trace, + ] { + let boot = Metadata::builder() + .target(BOOT_LOG_TARGET) + .level(level) + .build(); + let ordinary = Metadata::builder() + .target("application") + .level(level) + .build(); + assert_eq!(logger.enabled(&boot), level <= BOOT_LOG_LEVEL); + assert_eq!(logger.enabled(&ordinary), level <= runtime_level); + } + + logger.log( + &Record::builder() + .target(BOOT_LOG_TARGET) + .level(Level::Warn) + .args(format_args!("{preserved_warning}")) + .build(), + ); + guard.assert_contains("logging-test", &preserved_warning); + + logger.log( + &Record::builder() + .target(BOOT_LOG_TARGET) + .level(Level::Info) + .args(format_args!("suppressed boot info")) + .build(), + ); + guard.assert_absent("logging-test", "suppressed boot info"); + + if runtime_level <= LevelFilter::Error { + logger.log( + &Record::builder() + .target("application") + .level(Level::Warn) + .args(format_args!("suppressed runtime warning")) + .build(), + ); + guard.assert_absent("logging-test", "suppressed runtime warning"); + } + } + } +} diff --git a/crates/edgezero-adapter-fastly/src/outbound.rs b/crates/edgezero-adapter-fastly/src/outbound.rs new file mode 100644 index 000000000..5cdd66b51 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/outbound.rs @@ -0,0 +1,2845 @@ +#![cfg_attr( + feature = "fastly", + expect( + clippy::arbitrary_source_item_ordering, + reason = "the target-gated implementation module is kept before shared test seams" + ) +)] +#![cfg_attr( + feature = "fastly", + expect( + clippy::pub_use, + reason = "the target-gated implementation keeps Fastly imports out of native builds" + ) +)] + +#[cfg(any(feature = "fastly", test))] +use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(test)] +use std::time::Duration; + +use edgezero_core::error::EdgeError; +#[cfg(any(feature = "fastly", test))] +use edgezero_core::error::{BadGatewayReason, BudgetSource}; +#[cfg(any(feature = "fastly", test))] +use edgezero_core::outbound::{ + OutboundBatchDriverEvent, OutboundBatchItem, OutboundResponse, finish_batch_item, +}; +#[cfg(any(feature = "fastly", feature = "test-utils"))] +use edgezero_core::outbound::{OutboundRequest, validate_for_dispatch}; +#[cfg(any(feature = "fastly", test))] +use edgezero_core::time::Deadline; +#[cfg(any(feature = "fastly", test))] +use edgezero_core::time::{DispatchBudget, MonotonicInstant}; + +#[cfg(any(feature = "fastly", test))] +static NEXT_BACKEND_NAMESPACE: AtomicU64 = AtomicU64::new(0); + +#[cfg(any(feature = "fastly", test))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SendFailure { + BudgetedTimeout, + LocalInvariant, + PlatformInternal, + ProviderTimeout, + Transport, + Unknown, + Unreachable, + UpstreamProtocol, +} + +#[cfg(any(feature = "fastly", test))] +enum FastlyBatchObservation { + Continue(OutboundBatchItem), + Cutoff, +} + +#[cfg(any(feature = "fastly", test))] +fn next_backend_namespace() -> u64 { + NEXT_BACKEND_NAMESPACE.fetch_add(1, Ordering::Relaxed) +} + +#[cfg(any(feature = "fastly", test))] +fn namespaced_backend_name(identity_name: &str, namespace: u64) -> String { + format!("{identity_name}_{namespace}") +} + +#[cfg(any(feature = "fastly", test))] +fn classify_send_failure( + failure: SendFailure, + budget: DispatchBudget, + observed_at: MonotonicInstant, +) -> EdgeError { + if budget.deadline.is_expired_at(observed_at) { + return timeout_error(budget.cause); + } + match failure { + SendFailure::BudgetedTimeout => timeout_error(budget.cause), + SendFailure::ProviderTimeout => timeout_error(BudgetSource::Unspecified), + SendFailure::Unreachable => EdgeError::bad_gateway_with_reason( + "outbound destination could not be reached", + BadGatewayReason::Unreachable, + ), + SendFailure::Transport => EdgeError::bad_gateway_with_reason( + "outbound connection failed after dispatch", + BadGatewayReason::Transport, + ), + SendFailure::UpstreamProtocol => EdgeError::bad_gateway_with_reason( + "upstream response violated the HTTP protocol", + BadGatewayReason::Protocol, + ), + SendFailure::Unknown => EdgeError::bad_gateway_with_reason( + "Fastly outbound request failed", + BadGatewayReason::Unspecified, + ), + SendFailure::LocalInvariant | SendFailure::PlatformInternal => EdgeError::internal( + anyhow::anyhow!("Fastly rejected an adapter-owned outbound operation"), + ), + } +} + +#[cfg(test)] +fn test_budget(started_at: MonotonicInstant, cause: BudgetSource) -> DispatchBudget { + let duration = Duration::from_secs(1); + DispatchBudget { + cause, + deadline: Deadline::at_instant(started_at.checked_add(duration).expect("deadline instant")), + duration, + } +} + +#[cfg(test)] +fn assert_send_failure_error(failure: SendFailure, error: &EdgeError, selected: BudgetSource) { + match failure { + SendFailure::BudgetedTimeout => { + if let EdgeError::GatewayTimeout { cause, .. } = error { + assert_eq!(*cause, selected); + } else { + panic!("expected selected timeout"); + } + } + SendFailure::ProviderTimeout => assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::Unspecified, + .. + } + )), + SendFailure::LocalInvariant | SendFailure::PlatformInternal => { + assert!(matches!(error, EdgeError::Internal { .. })); + } + SendFailure::Transport => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Transport, + .. + } + )), + SendFailure::Unknown => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Unspecified, + .. + } + )), + SendFailure::Unreachable => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Unreachable, + .. + } + )), + SendFailure::UpstreamProtocol => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Protocol, + .. + } + )), + } +} + +#[cfg(any(feature = "fastly", test))] +fn finish_fastly_batch_observation( + index: usize, + started_at: MonotonicInstant, + completed_at: MonotonicInstant, + observation_cutoff: Deadline, + outcome: Result, +) -> FastlyBatchObservation { + match finish_batch_item(index, started_at, completed_at, observation_cutoff, outcome) { + Some(item) => FastlyBatchObservation::Continue(item), + None => FastlyBatchObservation::Cutoff, + } +} + +#[cfg(feature = "fastly")] +mod fastly_impl { + #[cfg(feature = "test-utils")] + use std::cell::Cell; + use std::collections::HashMap; + use std::io::{Result as IoResult, Write}; + use std::num::NonZeroU64; + use std::sync::Mutex; + use std::time::Duration; + + use async_stream::stream; + use async_trait::async_trait; + use bytes::Bytes; + use edgezero_core::body::{Body, BodyStream}; + use edgezero_core::error::{BadGatewayReason, EdgeError}; + use edgezero_core::http::header::ACCEPT_ENCODING; + use edgezero_core::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri}; + use edgezero_core::outbound::{ + OutboundBatch, OutboundBatchDriverEvent, OutboundCachePolicy, OutboundHttpClient, + OutboundRequest, OutboundRequestParts, OutboundResponse, ResponseBodyDisposition, + ResponseBodyPolicy, ResponseHeaderLimiter, ResponseMode, apply_response_body_policy, + collect_response_stream, insert_proxy_header, normalize_for_dispatch, + normalize_response_headers, validate_for_dispatch, + }; + use edgezero_core::time::{ + BATCH_DISPATCH_SLACK_MAX, Deadline, DispatchBudget, MonotonicClock, MonotonicInstant, + dispatch_budget, + }; + use fastly::backend::BackendCreationError; + use fastly::handle::{BodyHandle, PendingRequestHandle, ResponseHandle, select_handles}; + use fastly::http::body::StreamingBody; + use fastly::http::request::{PendingRequest, SendError, SendErrorCause}; + use fastly::{ + Backend, Body as FastlyBody, Request as FastlyRequest, Response as FastlyResponse, + }; + use futures_util::StreamExt as _; + use sha2::{Digest as _, Sha256}; + + use super::{ + FastlyBatchObservation, driver_selection_failure, finish_fastly_batch_observation, + namespaced_backend_name, next_backend_namespace, reassociate_selection, timeout_error, + validate_batch_request, + }; + + pub const DYNAMIC_BACKENDS_DISABLED_MESSAGE: &str = "Fastly dynamic backends are not enabled on this service; enable them in the service configuration"; + const RESPONSE_READ_BYTES: usize = 16 * 1024; + const DISPATCH_SLACK_MESSAGE: &str = "Fastly batch adapter overhead between batch_now and SDK arming (preflight + dynamic-backend lookup/creation + SDK setup) exceeded BATCH_DISPATCH_SLACK_MAX; refusing to arm SDK timers with stale duration"; + + #[cfg(feature = "test-utils")] + std::thread_local! { + static DISPATCH_SLACK_INJECTION: Cell> = const { Cell::new(None) }; + } + + #[cfg(feature = "test-utils")] + struct DispatchSlackInjection { + previous: Option, + } + + #[cfg(feature = "test-utils")] + impl Drop for DispatchSlackInjection { + fn drop(&mut self) { + DISPATCH_SLACK_INJECTION.set(self.previous); + } + } + + /// Overrides the dispatch-guard clock offset for one target-runtime contract test. + #[cfg(feature = "test-utils")] + #[inline] + #[must_use] + pub fn inject_dispatch_slack_for_test(delay: Duration) -> impl Drop { + let previous = DISPATCH_SLACK_INJECTION.replace(Some(delay)); + DispatchSlackInjection { previous } + } + + #[derive(Clone, Debug, Eq, Hash, PartialEq)] + struct BackendIdentity { + budget_ms: u64, + host: String, + port: u16, + scheme: String, + tls: bool, + wire_authority: String, + } + + struct PreparedRequest { + backend: Backend, + budget: DispatchBudget, + parts: OutboundRequestParts, + started_at: MonotonicInstant, + } + + struct PendingSlot { + metadata: PendingSlotMetadata, + pending: PendingRequestHandle, + } + + struct PendingSlotMetadata { + index: usize, + budget: DispatchBudget, + max_decoder_bytes: u64, + max_brotli_window_bits: u8, + max_chunk_bytes: Option, + max_decoded_response_bytes: Option, + max_encoded_response_bytes: Option, + max_response_header_bytes: Option, + max_response_header_count: Option, + request_method: Method, + response_mode: ResponseMode, + } + + /// Native outbound HTTP implementation for Fastly Compute. + pub struct FastlyOutboundClient { + backend_namespace: u64, + backends: Mutex>, + clock: MonotonicClock, + } + + impl FastlyOutboundClient { + #[must_use] + #[inline] + pub fn new() -> Self { + Self::with_clock(MonotonicClock::default()) + } + + /// Builds a client that evaluates every outbound lifetime against `clock`. + #[must_use] + #[inline] + pub fn with_clock(clock: MonotonicClock) -> Self { + Self { + backend_namespace: next_backend_namespace(), + backends: Mutex::new(HashMap::new()), + clock, + } + } + + fn backend_name(&self, identity: &BackendIdentity) -> String { + namespaced_backend_name(&backend_name(identity), self.backend_namespace) + } + + fn ensure_backend( + &self, + request: &OutboundRequest, + budget: DispatchBudget, + batch_started_at: Option, + ) -> Result { + let identity = backend_identity(request, budget)?; + let name = self.backend_name(&identity); + { + let cache = self.backends.lock().map_err(|_poisoned| { + EdgeError::internal(anyhow::anyhow!("Fastly backend cache was poisoned")) + })?; + if let Some((cached_identity, backend)) = cache.get(&name) { + if cached_identity != &identity { + return Err(EdgeError::internal(anyhow::anyhow!( + "dynamic backend name collision; refusing to reuse" + ))); + } + return Ok(backend.clone()); + } + } + + let target = request.backend_target(); + let host_override = backend_host_override(request); + let timers = backend_timers(identity.budget_ms); + let mut builder = Backend::builder(&name, &target) + .override_host(host_override) + .connect_timeout(timers.connect) + .first_byte_timeout(timers.first_byte) + .between_bytes_timeout(timers.between_bytes); + if identity.tls { + builder = builder.enable_ssl(); + if let Some(sni) = request.sni_hostname() { + builder = builder.sni_hostname(sni); + } + if let Some(cert_host) = request.cert_host() { + builder = builder.check_certificate(cert_host); + } + } + let creation = + register_backend_with_batch_guard(batch_started_at, budget, &self.clock, || { + builder.finish() + })?; + finish_backend_creation(creation, budget, &self.clock, |backend| { + let mut cache = self.backends.lock().map_err(|_poisoned| { + EdgeError::internal(anyhow::anyhow!("Fastly backend cache was poisoned")) + })?; + if let Some((cached_identity, cached_backend)) = cache.get(&name) { + if cached_identity != &identity { + return Err(EdgeError::internal(anyhow::anyhow!( + "dynamic backend name collision; refusing to reuse" + ))); + } + return Ok(cached_backend.clone()); + } + cache.insert(name, (identity, backend.clone())); + Ok(backend) + }) + } + + fn prepare( + &self, + request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + validate_for_dispatch(&request)?; + self.prepare_validated(request, started_at, None, false) + } + + fn prepare_batch( + &self, + request: OutboundRequest, + started_at: MonotonicInstant, + observation_cutoff: Deadline, + ) -> Result { + validate_batch_request(&request)?; + self.prepare_validated(request, started_at, Some(observation_cutoff), true) + } + + fn prepare_validated( + &self, + mut request: OutboundRequest, + started_at: MonotonicInstant, + observation_cutoff: Option, + enforce_batch_slack: bool, + ) -> Result { + let budget = dispatch_budget(&request, started_at, observation_cutoff)?; + normalize_fastly_request(&mut request)?; + budget_remaining(budget, &self.clock)?; + let backend = + self.ensure_backend(&request, budget, enforce_batch_slack.then_some(started_at))?; + budget_remaining(budget, &self.clock)?; + Ok(PreparedRequest { + backend, + budget, + parts: request.into_parts(), + started_at, + }) + } + + async fn execute(&self, prepared: PreparedRequest) -> Result { + let PreparedRequest { + backend, + budget, + parts, + started_at, + } = prepared; + let OutboundRequestParts { + body, + cache_policy, + headers, + max_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_request_body_bytes, + max_response_header_bytes, + max_response_header_count, + method, + response_mode, + uri, + .. + } = parts; + let fastly_request = build_fastly_request(&method, &uri, &headers, cache_policy); + let response = match body { + Body::Once(bytes) => { + validate_request_body_length(&bytes, max_request_body_bytes)?; + let mut buffered_request = fastly_request; + buffered_request.set_body(bytes.to_vec()); + dispatch_guard(started_at, budget, &self.clock)?; + let pending = buffered_request + .send_async(backend) + .map_err(|error| map_send_error(&error, budget, &self.clock))?; + wait_pending(pending, budget, &self.clock)? + } + Body::Stream(source) => { + send_streamed( + fastly_request, + backend, + source, + max_request_body_bytes, + budget, + started_at, + self.clock.clone(), + ) + .await? + } + }; + process_response( + response, + method, + response_mode, + budget, + max_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + self.clock.clone(), + ) + .await + } + + fn dispatch_batch_slot( + &self, + index: usize, + prepared: PreparedRequest, + ) -> Result { + let PreparedRequest { + backend, + budget, + parts, + started_at, + } = prepared; + let OutboundRequestParts { + body, + cache_policy, + headers, + max_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_request_body_bytes, + max_response_header_bytes, + max_response_header_count, + method, + response_mode, + uri, + .. + } = parts; + let Body::Once(bytes) = body else { + return Err(EdgeError::internal(anyhow::anyhow!( + "Fastly batch preflight admitted a streamed upload" + ))); + }; + validate_request_body_length(&bytes, max_request_body_bytes)?; + let mut request = build_fastly_request(&method, &uri, &headers, cache_policy); + request.set_body(bytes.to_vec()); + dispatch_guard(started_at, budget, &self.clock)?; + let (request_handle, optional_body_handle) = request.into_handles(); + let body_handle = optional_body_handle.ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "Fastly buffered batch request did not produce a body handle" + )) + })?; + let pending = request_handle + .send_async_to_backend(body_handle, &backend) + .map_err(|cause| map_send_cause(&cause, budget, &self.clock))?; + Ok(PendingSlot { + metadata: PendingSlotMetadata { + index, + budget, + max_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + request_method: method, + response_mode, + }, + pending, + }) + } + } + + impl Default for FastlyOutboundClient { + #[inline] + fn default() -> Self { + Self::new() + } + } + + fn backend_host_override(request: &OutboundRequest) -> String { + request.host_authority().to_owned() + } + + #[async_trait(?Send)] + impl OutboundHttpClient for FastlyOutboundClient { + #[inline] + async fn send(&self, request: OutboundRequest) -> Result { + let started_at = self.clock.now(); + let prepared = self.prepare(request, started_at)?; + self.execute(prepared).await + } + + #[inline] + fn start_batch_until( + &self, + requests: Vec, + observation_cutoff: Deadline, + ) -> OutboundBatch { + let batch_started_at = self.clock.now(); + let slot_count = requests.len(); + if observation_cutoff.is_expired_at(batch_started_at) { + return OutboundBatch::cutoff(slot_count); + } + + let mut completed = Vec::new(); + let mut pending = Vec::new(); + let mut observation_cutoff_reached = false; + for (index, request) in requests.into_iter().enumerate() { + let prepared = self + .prepare_batch(request, batch_started_at, observation_cutoff) + .and_then(|prepared| self.dispatch_batch_slot(index, prepared)); + match prepared { + Ok(slot) => pending.push(slot), + Err(error) => { + let completed_at = self.clock.now(); + match finish_fastly_batch_observation( + index, + batch_started_at, + completed_at, + observation_cutoff, + Err(error), + ) { + FastlyBatchObservation::Continue(item) => completed.push(item), + FastlyBatchObservation::Cutoff => { + observation_cutoff_reached = true; + break; + } + } + } + } + } + + let clock = self.clock.clone(); + let completions = stream! { + for item in completed { + yield OutboundBatchDriverEvent::Item(item); + } + + if observation_cutoff_reached { + yield OutboundBatchDriverEvent::Cutoff; + return; + } + + while !pending.is_empty() && !observation_cutoff.is_expired_at(clock.now()) { + let (selection, metadata) = match select_pending_slot(&mut pending) { + Ok(selected) => selected, + Err(error) => { + yield driver_selection_failure(error); + return; + } + }; + let index = metadata.index; + let outcome = finish_selected(selection, metadata, &clock).await; + let completed_at = clock.now(); + match finish_fastly_batch_observation( + index, + batch_started_at, + completed_at, + observation_cutoff, + outcome, + ) { + FastlyBatchObservation::Continue(item) => { + yield OutboundBatchDriverEvent::Item(item); + } + FastlyBatchObservation::Cutoff => { + yield OutboundBatchDriverEvent::Cutoff; + return; + } + } + } + + if !pending.is_empty() { + yield OutboundBatchDriverEvent::Cutoff; + } + }; + OutboundBatch::from_driver(slot_count, completions) + } + } + + #[derive(Clone, Copy)] + struct BackendTimers { + between_bytes: Duration, + connect: Duration, + first_byte: Duration, + } + + fn backend_identity( + request: &OutboundRequest, + budget: DispatchBudget, + ) -> Result { + let budget_ms = ceil_millis(budget.duration); + let scheme = request + .uri() + .scheme_str() + .ok_or_else(|| EdgeError::bad_request("outbound request URI has no scheme"))?; + let port = request + .uri() + .port_u16() + .unwrap_or(if scheme == "https" { 443 } else { 80 }); + Ok(BackendIdentity { + budget_ms, + host: request.host_name().to_owned(), + port, + scheme: scheme.to_owned(), + tls: scheme == "https", + wire_authority: request.host_authority().to_owned(), + }) + } + + fn backend_name(identity: &BackendIdentity) -> String { + let tls_mode = if identity.tls { "tls" } else { "plain" }; + let canonical = format!( + "{}:{}:{}:{}:{}:{}", + identity.scheme, + identity.host, + identity.port, + tls_mode, + identity.budget_ms, + identity.wire_authority + ); + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + let digest = hasher.finalize(); + let mut name = String::from("ez_"); + let Some(prefix) = digest.get(..16) else { + return name; + }; + for byte in prefix { + let high = u32::from(*byte >> 4_u8); + let low = u32::from(*byte & 0x0f); + if let Some(character) = char::from_digit(high, 16) { + name.push(character); + } + if let Some(character) = char::from_digit(low, 16) { + name.push(character); + } + } + name + } + + fn backend_timers(total_ms: u64) -> BackendTimers { + let total = Duration::from_millis(total_ms.max(1)); + if total_ms < 4 { + return BackendTimers { + between_bytes: total, + connect: total, + first_byte: total, + }; + } + let connect = total.checked_div(4).unwrap_or(total); + BackendTimers { + between_bytes: total, + connect, + first_byte: total.saturating_sub(connect), + } + } + + fn build_fastly_request( + method: &Method, + uri: &Uri, + headers: &HeaderMap, + cache_policy: OutboundCachePolicy, + ) -> FastlyRequest { + let mut request = FastlyRequest::new(method.clone(), uri.to_string()); + request.set_method(method.clone()); + for (name, value) in headers { + request.append_header(name.as_str(), value.as_bytes()); + } + if cache_policy == OutboundCachePolicy::Bypass { + request.set_pass(true); + } + request + } + + fn normalize_fastly_request(request: &mut OutboundRequest) -> Result<(), EdgeError> { + normalize_for_dispatch(request)?; + if !request.headers().contains_key(ACCEPT_ENCODING) { + request + .headers_mut() + .insert(ACCEPT_ENCODING, HeaderValue::from_static("identity")); + } + Ok(()) + } + + fn ceil_millis(duration: Duration) -> u64 { + let rounded = duration.as_nanos().div_ceil(1_000_000); + u64::try_from(rounded).unwrap_or(u64::MAX).max(1) + } + + fn register_backend_with_batch_guard( + batch_started_at: Option, + budget: DispatchBudget, + clock: &MonotonicClock, + register: Register, + ) -> Result, EdgeError> + where + Register: FnOnce() -> Result, + { + if let Some(started_at) = batch_started_at { + dispatch_guard(started_at, budget, clock)?; + } + Ok(register()) + } + + type SelectedResponse = Result<(ResponseHandle, BodyHandle), SendErrorCause>; + + fn select_pending_slot( + slots: &mut Vec, + ) -> Result<(SelectedResponse, PendingSlotMetadata), EdgeError> { + let maximum = usize::try_from(fastly_shared::MAX_PENDING_REQS).unwrap_or(usize::MAX); + let selection_count = slots.len().min(maximum); + if selection_count == 0 { + return Err(EdgeError::internal(anyhow::anyhow!( + "Fastly batch selection requires a pending slot" + ))); + } + + let selected_group = slots.drain(..selection_count).collect::>(); + let mut handles = Vec::with_capacity(selection_count); + let mut records = Vec::with_capacity(selection_count); + for PendingSlot { + metadata, + pending: handle, + } in selected_group + { + records.push((pending_handle_id(&handle), Some(metadata))); + handles.push(handle); + } + + let (selection, selected_position, remaining_handles) = select_handles(handles); + let remaining_ids = remaining_handles + .iter() + .map(pending_handle_id) + .collect::>(); + let (selected_metadata, remaining_metadata) = + reassociate_selection(records, selected_position, &remaining_ids)?; + let mut restored = remaining_handles + .into_iter() + .zip(remaining_metadata) + .map(|(handle, metadata)| PendingSlot { + metadata, + pending: handle, + }) + .collect::>(); + restored.append(slots); + *slots = restored; + Ok((selection, selected_metadata)) + } + + #[cfg_attr( + not(target_env = "p1"), + expect( + deprecated, + reason = "Fastly exposes only the raw handle identity needed to restore select order" + ) + )] + fn pending_handle_id(handle: &PendingRequestHandle) -> u32 { + handle.as_u32() + } + + async fn finish_selected( + selection: SelectedResponse, + metadata: PendingSlotMetadata, + clock: &MonotonicClock, + ) -> Result { + let PendingSlotMetadata { + budget, + max_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + request_method, + response_mode, + .. + } = metadata; + let response = match selection { + Ok((response, body)) => FastlyResponse::from_handles(response, body), + Err(cause) => return Err(map_send_cause(&cause, budget, clock)), + }; + process_response( + response, + request_method, + response_mode, + budget, + max_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + clock.clone(), + ) + .await + } + + fn map_backend_creation_error(error: &BackendCreationError) -> EdgeError { + match error { + BackendCreationError::Disallowed => EdgeError::bad_gateway_with_reason( + DYNAMIC_BACKENDS_DISABLED_MESSAGE, + BadGatewayReason::Unspecified, + ), + BackendCreationError::NameInUse => EdgeError::internal(anyhow::anyhow!( + "dynamic backend name collision; refusing to reuse" + )), + BackendCreationError::BetweenBytesTimeoutTooLarge(_) + | BackendCreationError::ConnectTimeoutTooLarge(_) + | BackendCreationError::EncodingError(_) + | BackendCreationError::FirstByteTimeoutTooLarge(_) + | BackendCreationError::InvalidHealthcheckValue(_) + | BackendCreationError::NameTooLong(_) => EdgeError::internal(anyhow::anyhow!( + "Fastly rejected deterministic dynamic backend configuration" + )), + BackendCreationError::HostError(_) => EdgeError::bad_gateway_with_reason( + "Fastly failed to register a dynamic backend", + BadGatewayReason::Unspecified, + ), + } + } + + fn finish_backend_creation( + result: Result, + budget: DispatchBudget, + clock: &MonotonicClock, + retain_success: RetainSuccess, + ) -> Result + where + RetainSuccess: FnOnce(BackendValue) -> Result, + { + match result { + Ok(backend) => { + let retained = retain_success(backend); + if budget.deadline.is_expired_at(clock.now()) { + Err(timeout_error(budget.cause)) + } else { + retained + } + } + Err(error) => { + if budget.deadline.is_expired_at(clock.now()) { + Err(timeout_error(budget.cause)) + } else { + Err(map_backend_creation_error(&error)) + } + } + } + } + + fn cause_to_failure(cause: &SendErrorCause) -> super::SendFailure { + match cause { + SendErrorCause::ConnectionTimeout | SendErrorCause::HttpResponseTimeout => { + super::SendFailure::BudgetedTimeout + } + SendErrorCause::DnsTimeout => super::SendFailure::ProviderTimeout, + SendErrorCause::ConnectionLimitReached + | SendErrorCause::ConnectionRefused + | SendErrorCause::DestinationIpUnroutable + | SendErrorCause::DestinationNotFound + | SendErrorCause::DestinationUnavailable + | SendErrorCause::DnsError { .. } + | SendErrorCause::TlsAlertReceived { .. } + | SendErrorCause::TlsCertificateError + | SendErrorCause::TlsConfigurationError + | SendErrorCause::TlsProtocolError => super::SendFailure::Unreachable, + SendErrorCause::ConnectionTerminated | SendErrorCause::IoError(_) => { + super::SendFailure::Transport + } + SendErrorCause::Http2StreamError { .. } + | SendErrorCause::HttpIncompleteResponse + | SendErrorCause::HttpProtocolError + | SendErrorCause::HttpResponseBodyTooLarge + | SendErrorCause::HttpResponseHeaderSectionTooLarge + | SendErrorCause::HttpResponseStatusInvalid + | SendErrorCause::HttpUpgradeFailed => super::SendFailure::UpstreamProtocol, + SendErrorCause::HttpCacheApiUnsupported + | SendErrorCause::HttpCacheLimitExceeded + | SendErrorCause::HttpRequestCacheKeyInvalid + | SendErrorCause::HttpRequestUriInvalid => super::SendFailure::LocalInvariant, + SendErrorCause::InternalError(_) => super::SendFailure::PlatformInternal, + SendErrorCause::FanoutNotEnabled + | SendErrorCause::ImageOptimizerUnsupported + | SendErrorCause::RequestCollapse + | SendErrorCause::Custom(_) + | _ => super::SendFailure::Unknown, + } + } + + fn map_send_error( + error: &SendError, + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> EdgeError { + map_send_cause(error.root_cause(), budget, clock) + } + + fn map_send_cause( + cause: &SendErrorCause, + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> EdgeError { + super::classify_send_failure(cause_to_failure(cause), budget, clock.now()) + } + + #[expect( + clippy::too_many_arguments, + reason = "the adapter consumes independent response-policy fields" + )] + async fn process_response( + mut response: FastlyResponse, + request_method: Method, + response_mode: ResponseMode, + budget: DispatchBudget, + max_decoder_bytes: u64, + max_brotli_window_bits: u8, + max_chunk_bytes: Option, + max_decoded_response_bytes: Option, + max_encoded_response_bytes: Option, + max_response_header_bytes: Option, + max_response_header_count: Option, + clock: MonotonicClock, + ) -> Result { + budget_remaining(budget, &clock)?; + let response_clock = clock.clone(); + let status = StatusCode::from_u16(response.get_status().as_u16()).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "Fastly returned an invalid upstream status code", + BadGatewayReason::Protocol, + ) + })?; + let mut headers = response_headers(&response); + let mut header_limiter = + ResponseHeaderLimiter::new(max_response_header_bytes, max_response_header_count); + header_limiter.observe(&headers)?; + let normalization = normalize_response_headers(&request_method, status, &mut headers)?; + let disposition = normalization.body_disposition; + insert_proxy_header( + &mut headers, + &mut header_limiter, + HeaderValue::from_static("fastly"), + )?; + if disposition == ResponseBodyDisposition::FramingBodyless { + return Ok(OutboundResponse::new( + request_method, + status, + headers, + Body::empty(), + response_clock, + normalization.content_type_provenance, + )); + } + + let declared_reset_body = matches!( + disposition, + ResponseBodyDisposition::ResetContent { + declared_body: true + } + ); + let native = fastly_body_stream(response.take_body(), budget, clock.clone()); + if matches!(disposition, ResponseBodyDisposition::ResetContent { .. }) { + if !declared_reset_body { + let mut reset_stream = native; + if let Some(item) = reset_stream.next().await { + item?; + } + } + return Ok(OutboundResponse::new( + request_method, + status, + headers, + Body::empty(), + response_clock, + normalization.content_type_provenance, + )); + } + + let shaped = apply_response_body_policy( + native, + &mut headers, + ResponseBodyPolicy { + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_decoder_bytes, + max_encoded_response_bytes, + response_mode, + }, + )?; + let deadline_bound = deadline_stream(shaped, budget, clock); + let body = match response_mode { + ResponseMode::Buffered { max_bytes } => { + Body::from(collect_response_stream(deadline_bound, max_bytes).await?) + } + ResponseMode::Streamed => Body::from_stream(deadline_bound), + }; + Ok(OutboundResponse::new( + request_method, + status, + headers, + body, + response_clock, + normalization.content_type_provenance, + )) + } + + fn response_headers(response: &FastlyResponse) -> HeaderMap { + let mut headers = HeaderMap::new(); + for name in response.get_header_names() { + for value in response.get_header_all(name) { + headers.append(name.clone(), value.clone()); + } + } + headers + } + + trait StreamedUploadWriter { + fn finish(self) -> IoResult<()>; + fn flush(&mut self) -> IoResult<()>; + fn write_all(&mut self, bytes: &[u8]) -> IoResult<()>; + } + + impl StreamedUploadWriter for StreamingBody { + fn finish(self) -> IoResult<()> { + StreamingBody::finish(self) + } + + fn flush(&mut self) -> IoResult<()> { + Write::flush(self) + } + + fn write_all(&mut self, bytes: &[u8]) -> IoResult<()> { + Write::write_all(self, bytes) + } + } + + async fn complete_streamed_exchange( + mut writer: Writer, + mut source: BodyStream, + maximum: u64, + budget: DispatchBudget, + clock: &MonotonicClock, + wait: Wait, + ) -> Result + where + Writer: StreamedUploadWriter, + Wait: FnOnce() -> Result, + { + let mut total = 0_u64; + loop { + budget_remaining(budget, clock)?; + let next_item = source.next().await; + budget_remaining(budget, clock)?; + let Some(item) = next_item else { + break; + }; + let bytes = item?; + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + let Some(next_total) = total.checked_add(length) else { + return Err(EdgeError::bad_request( + "outbound request body size accounting overflow", + )); + }; + if next_total > maximum { + return Err(EdgeError::bad_request( + "outbound request body exceeded configured limit", + )); + } + resolve_stream_io_result( + writer.write_all(&bytes), + budget, + clock.now(), + "Fastly outbound request body write failed", + )?; + resolve_stream_io_result( + writer.flush(), + budget, + clock.now(), + "Fastly outbound request body flush failed", + )?; + total = next_total; + } + resolve_stream_io_result( + writer.finish(), + budget, + clock.now(), + "Fastly outbound request completion failed", + )?; + wait() + } + + async fn send_streamed( + request: FastlyRequest, + backend: Backend, + source: BodyStream, + maximum: u64, + budget: DispatchBudget, + started_at: MonotonicInstant, + clock: MonotonicClock, + ) -> Result { + dispatch_guard(started_at, budget, &clock)?; + let (writer, pending) = request + .send_async_streaming(backend) + .map_err(|error| map_send_error(&error, budget, &clock))?; + complete_streamed_exchange(writer, source, maximum, budget, &clock, || { + wait_pending(pending, budget, &clock) + }) + .await + } + + fn resolve_stream_io_result( + result: IoResult<()>, + budget: DispatchBudget, + observed_at: MonotonicInstant, + failure_message: &'static str, + ) -> Result<(), EdgeError> { + if budget.deadline.is_expired_at(observed_at) { + return Err(timeout_error(budget.cause)); + } + result.map_err(|_error| { + EdgeError::bad_gateway_with_reason(failure_message, BadGatewayReason::Transport) + }) + } + + fn fastly_body_stream( + mut body: FastlyBody, + budget: DispatchBudget, + clock: MonotonicClock, + ) -> BodyStream { + stream! { + loop { + budget_remaining(budget, &clock)?; + let mut chunks = body.read_chunks(RESPONSE_READ_BYTES); + let item = chunks.next(); + drop(chunks); + match item { + Some(Ok(chunk)) => { + budget_remaining(budget, &clock)?; + yield Ok(Bytes::from(chunk)); + } + Some(Err(_error)) => { + if budget.deadline.is_expired_at(clock.now()) { + yield Err(timeout_error(budget.cause)); + } else { + yield Err(EdgeError::bad_gateway_with_reason( + "Fastly upstream response body failed", + BadGatewayReason::Transport, + )); + } + return; + } + None => { + budget_remaining(budget, &clock)?; + return; + } + } + } + } + .boxed_local() + } + + fn deadline_stream( + mut source: BodyStream, + budget: DispatchBudget, + clock: MonotonicClock, + ) -> BodyStream { + stream! { + loop { + if let Err(error) = budget_remaining(budget, &clock) { + yield Err(error); + return; + } + let next_item = source.next().await; + if let Err(error) = budget_remaining(budget, &clock) { + yield Err(error); + return; + } + match next_item { + Some(Ok(bytes)) => yield Ok(bytes), + Some(Err(error)) => { + yield Err(error); + return; + } + None => return, + } + } + } + .boxed_local() + } + + fn validate_request_body_length(bytes: &Bytes, maximum: u64) -> Result<(), EdgeError> { + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + if length > maximum { + return Err(EdgeError::bad_request( + "outbound request body exceeded configured limit", + )); + } + Ok(()) + } + + fn wait_pending( + pending: PendingRequest, + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result { + budget_remaining(budget, clock)?; + let outcome = pending + .wait() + .map_err(|error| map_send_error(&error, budget, clock)); + budget_remaining(budget, clock)?; + outcome + } + + fn budget_remaining( + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result { + budget + .deadline + .remaining_at(clock.now()) + .map(|remaining| remaining.min(budget.duration)) + .ok_or_else(|| timeout_error(budget.cause)) + } + + fn dispatch_guard( + started_at: MonotonicInstant, + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result<(), EdgeError> { + dispatch_guard_at(started_at, budget, dispatch_observed_at(started_at, clock)?) + } + + #[cfg(feature = "test-utils")] + fn dispatch_observed_at( + started_at: MonotonicInstant, + clock: &MonotonicClock, + ) -> Result { + if let Some(delay) = DISPATCH_SLACK_INJECTION.get() { + return started_at.checked_add(delay).ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "Fastly dispatch test clock exceeded the monotonic instant range" + )) + }); + } + Ok(clock.now()) + } + + #[cfg(not(feature = "test-utils"))] + #[expect( + clippy::unnecessary_wraps, + reason = "the test-utils build can reject an injected monotonic overflow" + )] + fn dispatch_observed_at( + _started_at: MonotonicInstant, + clock: &MonotonicClock, + ) -> Result { + Ok(clock.now()) + } + + fn dispatch_guard_at( + started_at: MonotonicInstant, + budget: DispatchBudget, + observed_at: MonotonicInstant, + ) -> Result<(), EdgeError> { + if budget.deadline.is_expired_at(observed_at) { + return Err(timeout_error(budget.cause)); + } + let elapsed = observed_at + .checked_duration_since(started_at) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "monotonic clock moved backwards before Fastly SDK dispatch" + )) + })?; + if elapsed > BATCH_DISPATCH_SLACK_MAX { + return Err(EdgeError::internal(anyhow::anyhow!(DISPATCH_SLACK_MESSAGE))); + } + Ok(()) + } + + #[cfg(test)] + mod tests { + use std::cell::Cell; + use std::collections::VecDeque; + use std::io::{Error, Write as _}; + use std::mem::discriminant; + use std::num::NonZeroU64; + use std::sync::{Arc, Mutex}; + use std::thread; + + use edgezero_core::error::{BudgetSource, ResponseLimitReason}; + use edgezero_core::http::header::{ + CONNECTION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, + }; + use edgezero_core::outbound::{ContentTypeIneligibility, ContentTypeProvenance}; + use edgezero_core::time::Deadline; + use flate2::{Compression, write::GzEncoder}; + use futures::executor::block_on; + use futures_util::stream; + + use super::*; + + fn scripted_clock(script: Vec) -> MonotonicClock { + let observations = Arc::new(Mutex::new(VecDeque::from(script))); + MonotonicClock::new(move || { + observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + }) + } + + fn constant_clock(now: MonotonicInstant) -> MonotonicClock { + MonotonicClock::new(move || now) + } + + fn clock_budget(start: MonotonicInstant, duration: Duration) -> DispatchBudget { + DispatchBudget { + cause: BudgetSource::PerCallTimeout, + deadline: Deadline::at_instant(start.checked_add(duration).expect("deadline")), + duration, + } + } + + #[test] + fn method_entry_and_preflight_elapsed_use_the_injected_clock() { + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(9)) + .expect("completed instant"); + let client = FastlyOutboundClient::with_clock(scripted_clock(vec![start, completed])); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = block_on( + client + .start_batch_until( + vec![request], + Deadline::at_instant( + start + .checked_add(Duration::from_secs(1)) + .expect("batch cutoff"), + ), + ) + .collect(), + ) + .expect("valid batch driver"); + let slot = results.slots[0].as_ref().expect("resolved slot"); + + assert_eq!(slot.elapsed, Duration::from_millis(9)); + assert!(matches!(slot.outcome, Err(EdgeError::BadRequest { .. }))); + } + + #[test] + fn batch_reports_per_slot_elapsed() { + let start = MonotonicInstant::now(); + let first = start + .checked_add(Duration::from_millis(3)) + .expect("first completion"); + let second = start + .checked_add(Duration::from_millis(8)) + .expect("second completion"); + let client = + FastlyOutboundClient::with_clock(scripted_clock(vec![start, first, second])); + let requests = vec![ + OutboundRequest::get("https://example.com/") + .expect("request") + .body("invalid GET body"), + OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(), + ]; + + let results = block_on( + client + .start_batch_until( + requests, + Deadline::at_instant( + start + .checked_add(Duration::from_secs(1)) + .expect("batch cutoff"), + ), + ) + .collect(), + ) + .expect("valid batch driver"); + let first_slot = results.slots[0].as_ref().expect("first resolved slot"); + let second_slot = results.slots[1].as_ref().expect("second resolved slot"); + + assert_eq!(first_slot.elapsed, Duration::from_millis(3)); + assert_eq!(second_slot.elapsed, Duration::from_millis(8)); + assert!( + results + .slots + .iter() + .flatten() + .all(|slot| matches!(slot.outcome, Err(EdgeError::BadRequest { .. }))) + ); + } + + #[test] + fn one_slot_batch_matches_send() { + let now = MonotonicInstant::now(); + let invalid = || { + OutboundRequest::get("https://example.com/") + .expect("request") + .body("invalid GET body") + }; + let single_outcome = + block_on(FastlyOutboundClient::with_clock(constant_clock(now)).send(invalid())); + let batch = block_on( + FastlyOutboundClient::with_clock(constant_clock(now)) + .start_batch_until( + vec![invalid()], + Deadline::at_instant( + now.checked_add(Duration::from_secs(1)) + .expect("batch cutoff"), + ), + ) + .collect(), + ) + .expect("valid batch driver"); + let batched_outcome = batch + .slots + .into_iter() + .next() + .flatten() + .expect("slot") + .outcome; + + let (Err(single_error), Err(batched_error)) = (single_outcome, batched_outcome) else { + panic!("single and batch paths must return errors"); + }; + assert_eq!(discriminant(&single_error), discriminant(&batched_error)); + assert_eq!(single_error.status(), batched_error.status()); + assert_eq!(single_error.message(), batched_error.message()); + } + + #[test] + fn backwards_clock_fails_slot_without_invalid_elapsed() { + let start = MonotonicInstant::now(); + let earlier = start + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let client = FastlyOutboundClient::with_clock(scripted_clock(vec![start, earlier])); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = block_on( + client + .start_batch_until( + vec![request], + Deadline::at_instant( + start + .checked_add(Duration::from_secs(1)) + .expect("batch cutoff"), + ), + ) + .collect(), + ) + .expect("valid batch driver"); + let slot = results.slots[0].as_ref().expect("resolved slot"); + + assert_eq!(slot.elapsed, Duration::ZERO); + assert!(matches!(slot.outcome, Err(EdgeError::Internal { .. }))); + } + + #[test] + fn backwards_clock_cannot_expand_the_selected_budget() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_millis(10)); + let earlier = start + .checked_sub(Duration::from_millis(5)) + .expect("earlier instant"); + let clock = scripted_clock(vec![earlier]); + + assert_eq!( + budget_remaining(budget, &clock).expect("remaining budget"), + budget.duration + ); + } + + #[test] + fn backend_identity_uses_the_method_entry_budget_snapshot() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_millis(10)); + let request = OutboundRequest::get("https://example.com/").expect("request"); + + let identity = backend_identity(&request, budget).expect("backend identity"); + + assert_eq!(identity.budget_ms, 10); + } + + #[test] + fn adapter_final_dispatch_reapplies_request_normalization() { + let mut request = OutboundRequest::get("https://example.com/").expect("request"); + request + .headers_mut() + .insert(CONNECTION, HeaderValue::from_static("accept-encoding")); + request + .headers_mut() + .insert(ACCEPT_ENCODING, HeaderValue::from_static("gzip")); + + normalize_fastly_request(&mut request).expect("normalized request"); + + assert!(!request.headers().contains_key(CONNECTION)); + assert_eq!( + request.headers().get(ACCEPT_ENCODING), + Some(&HeaderValue::from_static("identity")) + ); + } + + #[test] + fn canonical_uri_wire_serialization_table() { + for raw in [ + "https://example.com", + "https://example.com/a/../b?x=%2F", + "http://127.0.0.1:8080/path?empty=", + "https://[::1]:8443/", + "https://xn--bcher-kva.example/catalog", + ] { + let request = OutboundRequest::get(raw).expect("canonical request"); + let native = build_fastly_request( + request.method(), + request.uri(), + request.headers(), + OutboundCachePolicy::PlatformDefault, + ); + + assert_eq!(native.get_url_str(), request.uri().to_string(), "{raw}"); + } + } + + #[test] + fn backend_builder_keeps_pooling_for_identical_identity_and_settings() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_millis(100)); + let first = OutboundRequest::get("https://example.com/path").expect("first request"); + let second = OutboundRequest::get("https://example.com/other").expect("second request"); + let first_identity = backend_identity(&first, budget).expect("first identity"); + let second_identity = backend_identity(&second, budget).expect("second identity"); + + assert_eq!(first_identity, second_identity); + assert_eq!( + backend_name(&first_identity), + backend_name(&second_identity) + ); + let first_timers = backend_timers(first_identity.budget_ms); + let second_timers = backend_timers(second_identity.budget_ms); + assert_eq!(first_timers.connect, second_timers.connect); + assert_eq!(first_timers.first_byte, second_timers.first_byte); + assert_eq!(first_timers.between_bytes, second_timers.between_bytes); + } + + #[test] + fn authority_override_has_a_distinct_backend_identity() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_millis(100)); + let original = OutboundRequest::get("https://origin.example/path").expect("request"); + let overridden = OutboundRequest::get("https://origin.example/path") + .expect("request") + .host_authority_override("tenant.example:8443") + .expect("authority override"); + + let original_identity = backend_identity(&original, budget).expect("identity"); + let overridden_identity = backend_identity(&overridden, budget).expect("identity"); + + assert_eq!(overridden_identity.host, "origin.example"); + assert_eq!(overridden_identity.port, 443); + assert_ne!(original_identity, overridden_identity); + assert_ne!( + backend_name(&original_identity), + backend_name(&overridden_identity) + ); + } + + #[test] + fn ceil_millis_rounds_up_and_saturates_without_wrapping() { + assert_eq!(ceil_millis(Duration::ZERO), 1); + assert_eq!(ceil_millis(Duration::from_nanos(1)), 1); + assert_eq!(ceil_millis(Duration::from_micros(999)), 1); + assert_eq!(ceil_millis(Duration::from_millis(1)), 1); + assert_eq!(ceil_millis(Duration::from_nanos(1_000_001)), 2); + assert_eq!(ceil_millis(Duration::from_micros(1_001)), 2); + assert_eq!(ceil_millis(Duration::MAX), u64::MAX); + } + + #[test] + fn heterogeneous_hosts_and_exact_budgets_have_distinct_backend_identities() { + let started_at = MonotonicInstant::now(); + let first_budget = clock_budget(started_at, Duration::from_millis(1)); + let second_budget = clock_budget(started_at, Duration::from_nanos(1_000_001)); + let first_request = + OutboundRequest::get("https://first.example/path").expect("first request"); + let second_request = + OutboundRequest::get("https://second.example/path").expect("second request"); + + let first = backend_identity(&first_request, first_budget).expect("first identity"); + let second = backend_identity(&second_request, second_budget).expect("second identity"); + + assert_eq!(first.budget_ms, 1); + assert_eq!(second.budget_ms, 2); + assert_ne!(first, second); + assert_ne!(backend_name(&first), backend_name(&second)); + } + + #[test] + fn constant_clock_can_be_sampled_without_exhaustion() { + let now = MonotonicInstant::now(); + let clock = constant_clock(now); + + assert_eq!(clock.now(), now); + assert_eq!(clock.now(), now); + assert_eq!(clock.now(), now); + } + + #[test] + fn response_stream_retains_clock_for_post_ready_expiry() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, budget.deadline.instant()]); + let mut body = fastly_body_stream(FastlyBody::from("body"), budget, clock); + + let error = block_on(body.next()) + .expect("terminal item") + .expect_err("post-ready expiry"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[test] + fn converted_response_retains_injected_clock() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_secs(1)); + let observed = Arc::new(Mutex::new(start)); + let clock_observed = Arc::clone(&observed); + let clock = + MonotonicClock::new(move || *clock_observed.lock().expect("clock observation")); + let native = FastlyResponse::from_status(StatusCode::NO_CONTENT.as_u16()); + let response = block_on(process_response( + native, + Method::GET, + ResponseMode::Streamed, + budget, + 32 * 1024 * 1024, + 24, + None, + None, + None, + None, + None, + clock, + )) + .expect("converted response"); + *observed.lock().expect("clock observation") = budget.deadline.instant(); + + let error = block_on(response.into_bytes_bounded_until(1, budget.deadline)) + .expect_err("retained clock reached deadline"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + type ContentTypeCase = (&'static [&'static [u8]], bool, ContentTypeProvenance, usize); + + fn content_type_cases() -> &'static [ContentTypeCase] { + use ContentTypeIneligibility::{Missing, MultipleValues, NonText, Removed}; + use ContentTypeProvenance::{Ineligible, SingleTextUnchanged}; + + &[ + (&[], false, Ineligible(Missing), 0), + (&[b"text/plain"], false, SingleTextUnchanged, 1), + ( + &[b"text/plain; label=caf\xc3\xa9"], + false, + SingleTextUnchanged, + 1, + ), + ( + &[b"text/plain", b"text/plain"], + false, + Ineligible(MultipleValues), + 2, + ), + ( + &[b"text/plain", b"application/json"], + false, + Ineligible(MultipleValues), + 2, + ), + ( + &[b"text/plain", b"\xff"], + false, + Ineligible(MultipleValues), + 1, + ), + (&[b"\xff"], false, Ineligible(NonText), 0), + ( + &[b"text/plain, application/json"], + false, + SingleTextUnchanged, + 1, + ), + (&[b"not a MIME type"], false, SingleTextUnchanged, 1), + (&[b"text/plain"], true, Ineligible(Removed), 0), + ] + } + + #[test] + fn converted_response_preserves_original_content_type_provenance() { + use ContentTypeIneligibility::Modified; + use ContentTypeProvenance::{Ineligible, SingleTextUnchanged}; + + for &(values, nominated, expected, retained_count) in content_type_cases() { + for status in [ + StatusCode::OK, + StatusCode::NO_CONTENT, + StatusCode::RESET_CONTENT, + ] { + for mode in [ + ResponseMode::Streamed, + ResponseMode::Buffered { max_bytes: 64 }, + ] { + let start = MonotonicInstant::now(); + let mut native = FastlyResponse::from_status(status.as_u16()); + for value in values { + native.append_header(CONTENT_TYPE, *value); + } + if nominated { + native.set_header(CONNECTION, "content-type"); + } + let mut response = block_on(process_response( + native, + Method::GET, + mode, + clock_budget(start, Duration::from_secs(1)), + 1024, + 24, + None, + None, + None, + None, + None, + constant_clock(start), + )) + .expect("converted response"); + + assert_eq!(response.content_type_provenance(), expected); + assert_eq!( + response.headers().get_all(CONTENT_TYPE).iter().count(), + retained_count + ); + if expected == SingleTextUnchanged { + assert_eq!( + response + .headers() + .get(CONTENT_TYPE) + .map(HeaderValue::as_bytes), + values.first().copied() + ); + } + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + let after_mutation = if expected == SingleTextUnchanged { + Ineligible(Modified) + } else { + expected + }; + assert_eq!(response.content_type_provenance(), after_mutation); + } + } + } + } + + #[test] + fn response_read_checks_deadline_after_eof() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, budget.deadline.instant()]); + let mut body = fastly_body_stream(FastlyBody::new(), budget, clock); + + assert!(matches!( + block_on(body.next()), + Some(Err(EdgeError::GatewayTimeout { + cause: BudgetSource::PerCallTimeout, + .. + })) + )); + } + + #[test] + fn decoder_stalls_timeout_at_all_completion_boundaries() { + let started_at = MonotonicInstant::now(); + let budget = clock_budget(started_at, Duration::from_millis(100)); + let mut gzip_encoder = GzEncoder::new(Vec::new(), Compression::default()); + gzip_encoder.write_all(b"ab").expect("gzip input"); + let gzip_bytes = gzip_encoder.finish().expect("gzip output"); + let mut native = FastlyResponse::from_status(StatusCode::OK.as_u16()); + native.set_header(CONTENT_ENCODING, "gzip"); + native.set_body(gzip_bytes); + + let response = block_on(process_response( + native, + Method::GET, + ResponseMode::Streamed, + budget, + 32 * 1024 * 1024, + 24, + NonZeroU64::new(1), + None, + None, + None, + None, + MonotonicClock::default(), + )) + .expect("response head"); + let mut body = response.into_body().into_stream().expect("streamed body"); + + assert_eq!( + block_on(body.next()) + .expect("first item") + .expect("first byte"), + Bytes::from_static(b"a") + ); + thread::sleep(Duration::from_millis(125)); + assert!(matches!( + block_on(body.next()), + Some(Err(EdgeError::GatewayTimeout { + cause: BudgetSource::PerCallTimeout, + .. + })) + )); + } + + #[test] + fn response_content_length_rejects_before_body_poll() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_secs(1)); + let mut native = FastlyResponse::from_status(StatusCode::OK.as_u16()); + native.set_header(CONTENT_LENGTH, "2"); + native.set_body("xx"); + + let error = block_on(process_response( + native, + Method::GET, + ResponseMode::Buffered { max_bytes: 1 }, + budget, + 32 * 1024 * 1024, + 24, + None, + None, + None, + None, + None, + constant_clock(start), + )) + .expect_err("content length exceeds final buffer cap"); + + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::BufferedBody, + .. + } + )); + } + + #[test] + fn response_content_length_explicit_identity_rejects_before_body_poll() { + let start = MonotonicInstant::now(); + let budget = clock_budget(start, Duration::from_secs(1)); + let mut native = FastlyResponse::from_status(StatusCode::OK.as_u16()); + native.set_header(CONTENT_ENCODING, "identity"); + native.set_header(CONTENT_LENGTH, "2"); + native.set_body("xx"); + + let error = block_on(process_response( + native, + Method::GET, + ResponseMode::Buffered { max_bytes: 16 }, + budget, + 32 * 1024 * 1024, + 24, + None, + Some(1), + None, + None, + None, + constant_clock(start), + )) + .expect_err("content length exceeds decoded cap"); + + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::DecodedBody, + .. + } + )); + } + + #[derive(Default)] + struct UploadEvents { + finishes: usize, + flushes: usize, + waits: usize, + writes: Vec, + } + + struct TestUploadWriter { + events: Arc>, + } + + impl StreamedUploadWriter for TestUploadWriter { + fn finish(self) -> IoResult<()> { + let mut events = self + .events + .lock() + .map_err(|_poisoned| Error::other("upload events lock poisoned"))?; + events.finishes = events.finishes.saturating_add(1); + Ok(()) + } + + fn flush(&mut self) -> IoResult<()> { + let mut events = self + .events + .lock() + .map_err(|_poisoned| Error::other("upload events lock poisoned"))?; + events.flushes = events.flushes.saturating_add(1); + Ok(()) + } + + fn write_all(&mut self, bytes: &[u8]) -> IoResult<()> { + self.events + .lock() + .map_err(|_poisoned| Error::other("upload events lock poisoned"))? + .writes + .push(Bytes::copy_from_slice(bytes)); + Ok(()) + } + } + + #[test] + fn streamed_upload_finishes_exactly_once() { + let started_at = MonotonicInstant::now(); + let budget = clock_budget(started_at, Duration::from_secs(1)); + let clock = constant_clock(started_at); + let events = Arc::new(Mutex::new(UploadEvents::default())); + let writer = TestUploadWriter { + events: Arc::clone(&events), + }; + let source = stream::iter([ + Ok(Bytes::from_static(b"first")), + Ok(Bytes::from_static(b"second")), + ]) + .boxed_local(); + let wait_events = Arc::clone(&events); + + let outcome = block_on(complete_streamed_exchange( + writer, + source, + 32, + budget, + &clock, + move || { + wait_events.lock().expect("upload events").waits += 1; + Ok(7_u8) + }, + )); + let observed = events.lock().expect("upload events"); + + assert_eq!(outcome.expect("exchange"), 7); + assert_eq!( + observed.writes, + [Bytes::from_static(b"first"), Bytes::from_static(b"second")] + ); + assert_eq!(observed.flushes, 2); + assert_eq!(observed.finishes, 1); + assert_eq!(observed.waits, 1); + } + + #[test] + fn streamed_upload_failure_never_waits() { + let started_at = MonotonicInstant::now(); + let budget = clock_budget(started_at, Duration::from_secs(1)); + let clock = constant_clock(started_at); + let events = Arc::new(Mutex::new(UploadEvents::default())); + let writer = TestUploadWriter { + events: Arc::clone(&events), + }; + let source = stream::iter([Err(EdgeError::bad_gateway_with_reason( + "source failed", + BadGatewayReason::Transport, + ))]) + .boxed_local(); + let wait_events = Arc::clone(&events); + + let outcome = block_on(complete_streamed_exchange( + writer, + source, + 32, + budget, + &clock, + move || { + wait_events.lock().expect("upload events").waits += 1; + Ok(7_u8) + }, + )); + let observed = events.lock().expect("upload events"); + + assert!(matches!( + outcome, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Transport, + .. + }) + )); + assert!(observed.writes.is_empty()); + assert_eq!(observed.finishes, 0); + assert_eq!(observed.waits, 0); + } + + #[test] + fn backend_creation_error_table_is_exhaustive() { + use fastly_shared::FastlyStatus; + + let mapped = map_backend_creation_error(&BackendCreationError::Disallowed); + assert_eq!(mapped.status(), StatusCode::BAD_GATEWAY); + assert!(matches!( + mapped, + EdgeError::BadGateway { + message, + reason: BadGatewayReason::Unspecified, + } if message == DYNAMIC_BACKENDS_DISABLED_MESSAGE + )); + + for local_invariant in [ + BackendCreationError::BetweenBytesTimeoutTooLarge(Duration::from_secs(1)), + BackendCreationError::ConnectTimeoutTooLarge(Duration::from_secs(1)), + BackendCreationError::EncodingError( + String::from_utf8(vec![0xff]).expect_err("invalid UTF-8"), + ), + BackendCreationError::FirstByteTimeoutTooLarge(Duration::from_secs(1)), + BackendCreationError::InvalidHealthcheckValue("invalid".to_owned()), + BackendCreationError::NameTooLong("x".to_owned()), + BackendCreationError::NameInUse, + ] { + assert!(matches!( + map_backend_creation_error(&local_invariant), + EdgeError::Internal { .. } + )); + } + + assert!(matches!( + map_backend_creation_error(&BackendCreationError::HostError(FastlyStatus::ERROR)), + EdgeError::BadGateway { + reason: BadGatewayReason::Unspecified, + .. + } + )); + } + + #[test] + fn backend_creation_failure_observed_at_deadline_is_a_timeout() { + let started_at = MonotonicInstant::now(); + let budget = super::super::test_budget(started_at, BudgetSource::BatchCutoff); + let retained = Cell::new(false); + let clock = scripted_clock(vec![budget.deadline.instant()]); + + let error = finish_backend_creation::<(), _>( + Err(BackendCreationError::Disallowed), + budget, + &clock, + |_backend| { + retained.set(true); + Ok(()) + }, + ) + .expect_err("deadline must outrank backend failure"); + + assert!(!retained.get()); + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::BatchCutoff, + .. + } + )); + } + + #[test] + fn successful_backend_creation_is_retained_before_late_timeout() { + let started_at = MonotonicInstant::now(); + let budget = super::super::test_budget(started_at, BudgetSource::BatchCutoff); + let retained = Cell::new(false); + let observed_at = Arc::new(Mutex::new(started_at)); + let clock_observation = Arc::clone(&observed_at); + let clock = + MonotonicClock::new(move || *clock_observation.lock().expect("clock observation")); + + let error = finish_backend_creation(Ok(7_u8), budget, &clock, |backend| { + retained.set(true); + *observed_at.lock().expect("clock observation") = budget.deadline.instant(); + Ok(backend) + }) + .expect_err("late success must still return the attributed timeout"); + + assert!(retained.get()); + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::BatchCutoff, + .. + } + )); + } + + #[test] + fn late_timeout_outranks_retention_error_after_success_is_retained() { + let started_at = MonotonicInstant::now(); + let budget = super::super::test_budget(started_at, BudgetSource::BatchCutoff); + let retained = Cell::new(false); + let observed_at = Arc::new(Mutex::new(started_at)); + let clock_observation = Arc::clone(&observed_at); + let clock = + MonotonicClock::new(move || *clock_observation.lock().expect("clock observation")); + + let error = finish_backend_creation(Ok(7_u8), budget, &clock, |_backend| { + retained.set(true); + *observed_at.lock().expect("clock observation") = budget.deadline.instant(); + Err(EdgeError::internal(anyhow::anyhow!("retention failed"))) + }) + .expect_err("late timeout must outrank retention failure"); + + assert!(retained.get()); + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::BatchCutoff, + .. + } + )); + } + + #[test] + fn streamed_write_failure_observed_at_expiry_is_a_timeout() { + let started_at = MonotonicInstant::now(); + let budget = super::super::test_budget(started_at, BudgetSource::PerCallTimeout); + + let error = resolve_stream_io_result( + Err(Error::other("write failed")), + budget, + budget.deadline.instant(), + "Fastly outbound request body write failed", + ) + .expect_err("deadline must outrank transport failure"); + + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::PerCallTimeout, + .. + } + )); + } + + #[test] + fn backend_identity_uses_every_canonical_property() { + let first = BackendIdentity { + budget_ms: 100, + host: "example.com".to_owned(), + port: 443, + scheme: "https".to_owned(), + tls: true, + wire_authority: "example.com".to_owned(), + }; + assert_eq!(backend_name(&first), "ez_1e145c6e3d9e9dd28342de004f8b6954"); + let mut second = first.clone(); + second.budget_ms = 101; + assert_ne!(backend_name(&first), backend_name(&second)); + second = first.clone(); + second.port = 8443; + assert_ne!(backend_name(&first), backend_name(&second)); + second = first.clone(); + second.host = "other.example".to_owned(); + assert_ne!(backend_name(&first), backend_name(&second)); + } + + #[test] + fn backend_host_override_preserves_explicit_port() { + let mut request = + OutboundRequest::get("https://api.example.com:8443/resource").expect("request"); + normalize_for_dispatch(&mut request).expect("normalize"); + + assert_eq!(backend_host_override(&request), "api.example.com:8443"); + } + + #[test] + fn backend_host_override_preserves_bracketed_ipv6() { + let mut request = OutboundRequest::get("http://[::1]:8443/resource").expect("request"); + normalize_for_dispatch(&mut request).expect("normalize"); + + assert_eq!(backend_host_override(&request), "[::1]:8443"); + } + + #[test] + fn backend_timer_partition_is_bounded() { + let timers = backend_timers(100); + assert_eq!(timers.connect, Duration::from_millis(25)); + assert_eq!(timers.first_byte, Duration::from_millis(75)); + assert_eq!(timers.between_bytes, Duration::from_millis(100)); + let short = backend_timers(1); + assert_eq!(short.connect, Duration::from_millis(1)); + assert_eq!(short.first_byte, Duration::from_millis(1)); + assert_eq!(short.between_bytes, Duration::from_millis(1)); + } + + #[test] + fn dispatch_guard_checks_expiry_before_slack() { + use edgezero_core::time::{BATCH_DISPATCH_SLACK_MAX, Deadline}; + + let started_at = MonotonicInstant::now(); + let duration = Duration::from_secs(1); + let budget = DispatchBudget { + cause: BudgetSource::PerCallTimeout, + deadline: Deadline::at_instant( + started_at.checked_add(duration).expect("deadline instant"), + ), + duration, + }; + let at_limit = started_at + .checked_add(BATCH_DISPATCH_SLACK_MAX) + .expect("slack boundary"); + dispatch_guard_at(started_at, budget, at_limit).expect("dispatch at slack boundary"); + + let over_limit = at_limit + .checked_add(Duration::from_nanos(1)) + .expect("past slack boundary"); + assert!(matches!( + dispatch_guard_at(started_at, budget, over_limit), + Err(EdgeError::Internal { source }) + if source.to_string() == DISPATCH_SLACK_MESSAGE + )); + + let expired_budget = DispatchBudget { + deadline: Deadline::at_instant(over_limit), + ..budget + }; + assert!(matches!( + dispatch_guard_at(started_at, expired_budget, over_limit), + Err(EdgeError::GatewayTimeout { + cause: BudgetSource::PerCallTimeout, + .. + }) + )); + } + + #[test] + fn cold_backend_registration_is_refused_before_more_synchronous_work() { + use edgezero_core::time::{BATCH_DISPATCH_SLACK_MAX, Deadline}; + + let started_at = MonotonicInstant::now(); + let over_limit = started_at + .checked_add(BATCH_DISPATCH_SLACK_MAX + Duration::from_nanos(1)) + .expect("past slack boundary"); + let budget = DispatchBudget { + cause: BudgetSource::PerCallTimeout, + deadline: Deadline::at_instant( + started_at + .checked_add(Duration::from_secs(1)) + .expect("deadline instant"), + ), + duration: Duration::from_secs(1), + }; + let clock = scripted_clock(vec![started_at, over_limit]); + let registrations = Cell::new(0_u8); + let register = || { + registrations.set(registrations.get().saturating_add(1)); + Ok::<_, ()>(()) + }; + + let first = + register_backend_with_batch_guard(Some(started_at), budget, &clock, register); + assert!(matches!(first, Ok(Ok(())))); + let second = + register_backend_with_batch_guard(Some(started_at), budget, &clock, register); + assert!(matches!( + second, + Err(EdgeError::Internal { source }) + if source.to_string() == DISPATCH_SLACK_MESSAGE + )); + assert_eq!(registrations.get(), 1); + } + + #[test] + #[expect( + clippy::too_many_lines, + reason = "the pinned SDK cause table intentionally constructs every known variant" + )] + fn send_error_cause_table_preserves_timeout_source() { + let cases = [ + ( + SendErrorCause::DnsTimeout, + super::super::SendFailure::ProviderTimeout, + ), + ( + SendErrorCause::DnsError { + rcode: None, + info_code: None, + }, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::DestinationNotFound, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::DestinationUnavailable, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::DestinationIpUnroutable, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::ConnectionRefused, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::ConnectionTerminated, + super::super::SendFailure::Transport, + ), + ( + SendErrorCause::ConnectionTimeout, + super::super::SendFailure::BudgetedTimeout, + ), + ( + SendErrorCause::ConnectionLimitReached, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::TlsProtocolError, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::TlsCertificateError, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::TlsAlertReceived { alert_id: None }, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::TlsConfigurationError, + super::super::SendFailure::Unreachable, + ), + ( + SendErrorCause::HttpIncompleteResponse, + super::super::SendFailure::UpstreamProtocol, + ), + ( + SendErrorCause::HttpResponseHeaderSectionTooLarge, + super::super::SendFailure::UpstreamProtocol, + ), + ( + SendErrorCause::HttpResponseBodyTooLarge, + super::super::SendFailure::UpstreamProtocol, + ), + ( + SendErrorCause::HttpResponseTimeout, + super::super::SendFailure::BudgetedTimeout, + ), + ( + SendErrorCause::HttpResponseStatusInvalid, + super::super::SendFailure::UpstreamProtocol, + ), + ( + SendErrorCause::HttpUpgradeFailed, + super::super::SendFailure::UpstreamProtocol, + ), + ( + SendErrorCause::Http2StreamError { + frame_type: 0, + error_code: 0, + }, + super::super::SendFailure::UpstreamProtocol, + ), + ( + SendErrorCause::HttpProtocolError, + super::super::SendFailure::UpstreamProtocol, + ), + ( + SendErrorCause::HttpRequestCacheKeyInvalid, + super::super::SendFailure::LocalInvariant, + ), + ( + SendErrorCause::HttpRequestUriInvalid, + super::super::SendFailure::LocalInvariant, + ), + ( + SendErrorCause::HttpCacheLimitExceeded, + super::super::SendFailure::LocalInvariant, + ), + ( + SendErrorCause::HttpCacheApiUnsupported, + super::super::SendFailure::LocalInvariant, + ), + ( + SendErrorCause::IoError(Error::other("test")), + super::super::SendFailure::Transport, + ), + ( + SendErrorCause::FanoutNotEnabled, + super::super::SendFailure::Unknown, + ), + ( + SendErrorCause::ImageOptimizerUnsupported, + super::super::SendFailure::Unknown, + ), + ( + SendErrorCause::InternalError(None), + super::super::SendFailure::PlatformInternal, + ), + ( + SendErrorCause::RequestCollapse, + super::super::SendFailure::Unknown, + ), + ( + SendErrorCause::Custom(anyhow::anyhow!("test")), + super::super::SendFailure::Unknown, + ), + ]; + + let observed_at = MonotonicInstant::now(); + let selected = BudgetSource::PerCallTimeout; + let budget = super::super::test_budget(observed_at, selected); + for (cause, expected) in cases { + let failure = cause_to_failure(&cause); + assert_eq!(failure, expected, "{cause:?}"); + super::super::assert_send_failure_error( + failure, + &super::super::classify_send_failure(failure, budget, observed_at), + selected, + ); + } + } + } +} + +#[cfg(all(feature = "fastly", feature = "test-utils"))] +pub use fastly_impl::inject_dispatch_slack_for_test; +#[cfg(feature = "fastly")] +pub use fastly_impl::{DYNAMIC_BACKENDS_DISABLED_MESSAGE, FastlyOutboundClient}; + +#[cfg(any(feature = "fastly", test))] +fn reassociate_selection( + mut records: Vec<(u32, Option)>, + selected_position: usize, + remaining_ids: &[u32], +) -> Result<(Metadata, Vec), EdgeError> { + let selected = records + .get_mut(selected_position) + .and_then(|(_id, metadata)| metadata.take()) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "Fastly returned an invalid selected handle index" + )) + })?; + let mut remaining = Vec::with_capacity(remaining_ids.len()); + for id in remaining_ids { + let metadata = records + .iter_mut() + .find_map(|(candidate, metadata)| { + (*candidate == *id).then(|| metadata.take()).flatten() + }) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "Fastly returned an unknown or duplicate pending handle" + )) + })?; + remaining.push(metadata); + } + if records.iter().any(|(_id, metadata)| metadata.is_some()) { + return Err(EdgeError::internal(anyhow::anyhow!( + "Fastly omitted a pending handle from selection" + ))); + } + Ok((selected, remaining)) +} + +#[cfg(any(feature = "fastly", feature = "test-utils"))] +fn validate_batch_request(request: &OutboundRequest) -> Result<(), EdgeError> { + validate_for_dispatch(request)?; + if request.is_stream_body() { + return Err(EdgeError::bad_request( + "outbound batches require buffered request bodies; use send for a streamed upload", + )); + } + if request.is_stream_response() { + return Err(EdgeError::bad_request( + "outbound batches require buffered responses; use send for a streamed response", + )); + } + Ok(()) +} + +#[cfg(any(feature = "fastly", test))] +fn driver_selection_failure(error: EdgeError) -> OutboundBatchDriverEvent { + OutboundBatchDriverEvent::Failed(error) +} + +/// Runs the target-neutral Fastly batch preflight contract in native tests. +/// +/// # Errors +/// Returns the same portable validation or batch-shape error as production dispatch. +#[cfg(feature = "test-utils")] +#[inline] +pub fn validate_batch_request_for_test(request: &OutboundRequest) -> Result<(), EdgeError> { + validate_batch_request(request) +} + +#[cfg(any(feature = "fastly", test))] +fn timeout_error(cause: BudgetSource) -> EdgeError { + EdgeError::gateway_timeout_caused("outbound request deadline expired", cause) +} + +#[cfg(test)] +mod send_failure_policy_tests { + use edgezero_core::outbound::{ + OutboundBatch, OutboundBatchDriverEvent, OutboundBatchItem, OutboundBatchTermination, + OutboundSlotResult, + }; + use futures::executor::block_on; + use futures_util::stream; + + use super::*; + + #[test] + fn independent_clients_use_distinct_backend_names() { + let first = next_backend_namespace(); + let second = next_backend_namespace(); + let identity_name = "ez_1e145c6e3d9e9dd28342de004f8b6954"; + + assert_eq!( + namespaced_backend_name(identity_name, first), + namespaced_backend_name(identity_name, first) + ); + assert_ne!( + namespaced_backend_name(identity_name, first), + namespaced_backend_name(identity_name, second) + ); + } + + #[test] + fn selection_metadata_follows_host_returned_handle_order() { + let (selected, remaining) = reassociate_selection( + vec![ + (11_u32, Some("first")), + (22, Some("second")), + (33, Some("third")), + ], + 1, + &[33, 11], + ) + .expect("valid selection"); + + assert_eq!(selected, "second"); + assert_eq!(remaining, ["third", "first"]); + } + + #[test] + fn selection_rejects_an_invalid_selected_handle_index() { + let error = reassociate_selection(vec![(11_u32, Some("first"))], 1, &[]) + .expect_err("selected index must identify an input handle"); + + assert_eq!( + error.to_string(), + "internal error: Fastly returned an invalid selected handle index" + ); + } + + #[test] + fn selection_rejects_an_unknown_or_duplicate_pending_handle() { + let unknown = reassociate_selection( + vec![(11_u32, Some("first")), (22, Some("second"))], + 0, + &[99], + ) + .expect_err("remaining handle must come from the input group"); + let duplicate = reassociate_selection( + vec![(11_u32, Some("first")), (22, Some("second"))], + 0, + &[22, 22], + ) + .expect_err("remaining handle must not be duplicated"); + + for error in [unknown, duplicate] { + assert_eq!( + error.to_string(), + "internal error: Fastly returned an unknown or duplicate pending handle" + ); + } + } + + #[test] + fn selection_rejects_an_omitted_pending_handle() { + let error = reassociate_selection( + vec![ + (11_u32, Some("first")), + (22, Some("second")), + (33, Some("third")), + ], + 0, + &[22], + ) + .expect_err("every unselected handle must be returned"); + + assert_eq!( + error.to_string(), + "internal error: Fastly omitted a pending handle from selection" + ); + } + + #[test] + fn selection_failure_preserves_its_diagnostic_and_previously_collected_slots() { + let event = driver_selection_failure(EdgeError::internal(anyhow::anyhow!( + "Fastly omitted a pending handle from selection" + ))); + let OutboundBatchDriverEvent::Failed(_) = &event else { + panic!("selection failure must become a driver failure event"); + }; + let completed = OutboundBatchItem::new( + 0, + OutboundSlotResult::new( + Duration::from_millis(1), + Err(EdgeError::bad_gateway("completed slot failure")), + ), + ); + let batch = OutboundBatch::from_driver( + 2, + stream::iter([OutboundBatchDriverEvent::Item(completed), event]), + ); + let failure = + block_on(batch.collect()).expect_err("selection failure must fail collection"); + + assert_eq!( + failure.error.to_string(), + "internal error: Fastly omitted a pending handle from selection" + ); + assert!(matches!(failure.slots.first(), Some(Some(_)))); + assert!(matches!(failure.slots.get(1), Some(None))); + } + + #[test] + fn early_observation_cutoff_attributed_timeout_preserves_a_later_sibling() { + let started_at = MonotonicInstant::now(); + let observation_cutoff = Deadline::at_instant( + started_at + .checked_add(Duration::from_millis(10)) + .expect("cutoff instant"), + ); + let early_timeout = finish_fastly_batch_observation( + 0, + started_at, + started_at + .checked_add(Duration::from_millis(1)) + .expect("early completion"), + observation_cutoff, + Err(timeout_error(BudgetSource::BatchCutoff)), + ); + let sibling = finish_fastly_batch_observation( + 1, + started_at, + started_at + .checked_add(Duration::from_millis(2)) + .expect("sibling completion"), + observation_cutoff, + Err(EdgeError::bad_gateway("sibling completed")), + ); + let events = [early_timeout, sibling].map(|observation| match observation { + FastlyBatchObservation::Continue(item) => OutboundBatchDriverEvent::Item(item), + FastlyBatchObservation::Cutoff => OutboundBatchDriverEvent::Cutoff, + }); + let batch = OutboundBatch::from_driver(2, stream::iter(events)); + + let results = block_on(batch.collect()).expect("valid Fastly batch driver"); + + assert_eq!(results.termination, OutboundBatchTermination::Completed); + assert!(matches!( + results.slots.first(), + Some(Some(OutboundSlotResult { + outcome: Err(EdgeError::GatewayTimeout { + cause: BudgetSource::BatchCutoff, + .. + }), + .. + })) + )); + assert!(matches!( + results.slots.get(1), + Some(Some(OutboundSlotResult { + outcome: Err(EdgeError::BadGateway { .. }), + .. + })) + )); + } + + fn failures() -> [SendFailure; 8] { + [ + SendFailure::BudgetedTimeout, + SendFailure::LocalInvariant, + SendFailure::PlatformInternal, + SendFailure::ProviderTimeout, + SendFailure::Transport, + SendFailure::Unknown, + SendFailure::Unreachable, + SendFailure::UpstreamProtocol, + ] + } + + #[test] + fn send_failure_policy_maps_every_known_category() { + let observed_at = MonotonicInstant::now(); + for selected in [ + BudgetSource::PerCallTimeout, + BudgetSource::BatchCutoff, + BudgetSource::RequestDeadline, + BudgetSource::Default, + ] { + let budget = test_budget(observed_at, selected); + for failure in failures() { + assert_send_failure_error( + failure, + &classify_send_failure(failure, budget, observed_at), + selected, + ); + } + } + } + + #[test] + fn absolute_deadline_wins_every_send_failure() { + let started_at = MonotonicInstant::now(); + for selected in [ + BudgetSource::PerCallTimeout, + BudgetSource::BatchCutoff, + BudgetSource::RequestDeadline, + BudgetSource::Default, + ] { + let budget = test_budget(started_at, selected); + let after_deadline = budget + .deadline + .instant() + .checked_add(Duration::from_nanos(1)) + .expect("after deadline"); + for observed_at in [budget.deadline.instant(), after_deadline] { + for failure in failures() { + let error = classify_send_failure(failure, budget, observed_at); + if let EdgeError::GatewayTimeout { cause, .. } = error { + assert_eq!(cause, selected); + } else { + panic!("deadline did not win {failure:?} for {selected:?}"); + } + } + } + } + } +} diff --git a/crates/edgezero-adapter-fastly/src/proxy.rs b/crates/edgezero-adapter-fastly/src/proxy.rs deleted file mode 100644 index eb7efc99f..000000000 --- a/crates/edgezero-adapter-fastly/src/proxy.rs +++ /dev/null @@ -1,276 +0,0 @@ -use async_stream::try_stream; -use async_trait::async_trait; -use bytes::Bytes; -use edgezero_core::body::Body; -use edgezero_core::compression::{decode_brotli_stream, decode_gzip_stream}; -use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderMap, HeaderValue, Method, Uri, header}; -use edgezero_core::proxy::{PROXY_HEADER, ProxyClient, ProxyRequest, ProxyResponse}; -use fastly::{ - Backend, Request as FastlyRequest, Response as FastlyResponse, error::anyhow, - http::body::StreamingBody, -}; -use futures_util::stream::{BoxStream, StreamExt as _}; -use std::io::{self, Write as _}; -use std::time::Duration; - -const BACKEND_PREFIX: &str = "edgezero-dynamic-"; - -type ChunkStream = BoxStream<'static, Result, io::Error>>; - -pub struct FastlyProxyClient; - -#[async_trait(?Send)] -impl ProxyClient for FastlyProxyClient { - #[inline] - async fn send(&self, request: ProxyRequest) -> Result { - let (method, uri, headers, body, _ext) = request.into_parts(); - let backend_name = ensure_backend(&uri)?; - let fastly_request = build_fastly_request(method, &uri, &headers); - let (mut streaming_body, pending_request) = fastly_request - .send_async_streaming(&backend_name) - .map_err(EdgeError::internal)?; - forward_request_body(body, &mut streaming_body).await?; - streaming_body.finish().map_err(EdgeError::internal)?; - let mut fastly_response = pending_request.wait().map_err(EdgeError::internal)?; - - let mut proxy_response = convert_response(&mut fastly_response); - proxy_response - .headers_mut() - .insert(PROXY_HEADER, HeaderValue::from_static("fastly")); - Ok(proxy_response) - } -} - -fn build_fastly_request(method: Method, uri: &Uri, headers: &HeaderMap) -> FastlyRequest { - let mut fastly_request = FastlyRequest::new(method.clone(), uri.to_string()); - fastly_request.set_method(method); - - // Append (not set) so a multi-value client header survives; `Host` below is - // set explicitly as a single value. - for (name, value) in headers { - if name.as_str().eq_ignore_ascii_case("host") { - continue; - } - fastly_request.append_header(name.as_str(), value.clone()); - } - - // Build `Host` from host + explicit port so a non-default target port is - // preserved (origin-form fidelity) WITHOUT leaking any `user:pass@` userinfo - // that `uri.authority()` would include. (Backend connection + TLS SNI still - // key off `uri.host()` in `ensure_backend`.) - if let Some(host) = uri.host() { - let value = match uri.port() { - Some(port) => format!("{host}:{}", port.as_str()), - None => host.to_owned(), - }; - fastly_request.set_header("Host", value.as_str()); - } - - fastly_request -} - -fn convert_response(fastly_response: &mut FastlyResponse) -> ProxyResponse { - let status = fastly_response.get_status(); - let mut proxy_response = ProxyResponse::new(status, Body::empty()); - - // Preserve multi-value ORIGIN response headers (e.g. Set-Cookie): read ALL - // values per name and append, instead of first-value + insert (which - // replaced). `get_header_names()` yields `&HeaderName`, usable for both - // `get_header_all` and `append`. - for name in fastly_response.get_header_names() { - for value in fastly_response.get_header_all(name) { - proxy_response.headers_mut().append(name, value.clone()); - } - } - - let encoding = proxy_response - .headers() - .get(header::CONTENT_ENCODING) - .and_then(|value| value.to_str().ok()) - .map(str::to_ascii_lowercase); - - let body = fastly_response.take_body(); - - let chunk_stream = fastly_body_stream(body); - let body_stream = transform_stream(chunk_stream, encoding.as_deref()); - *proxy_response.body_mut() = Body::from_stream(body_stream); - if encoding.as_deref() == Some("gzip") || encoding.as_deref() == Some("br") { - proxy_response - .headers_mut() - .remove(header::CONTENT_ENCODING); - proxy_response.headers_mut().remove(header::CONTENT_LENGTH); - } - - proxy_response -} - -fn ensure_backend(uri: &Uri) -> Result { - let host = uri - .host() - .ok_or_else(|| EdgeError::bad_request("proxy target must include host"))?; - - let scheme = uri.scheme_str().unwrap_or("https"); - let is_https = scheme.eq_ignore_ascii_case("https"); - - let target_port = match (uri.port_u16(), is_https) { - (Some(port), _) => port, - (None, true) => 443, - (None, false) => 80, - }; - - let host_with_port = format!("{host}:{target_port}"); - - // Human-readable name: backend_{scheme}_{host}_{port} with dots/colons sanitised - let name_base = format!("{scheme}_{host}_{target_port}"); - let backend_name = format!("{}{}", BACKEND_PREFIX, name_base.replace(['.', ':'], "_")); - - let mut builder = Backend::builder(&backend_name, &host_with_port) - .override_host(host) - .connect_timeout(Duration::from_secs(1)) - .first_byte_timeout(Duration::from_secs(15)) - .between_bytes_timeout(Duration::from_secs(10)); - - if is_https { - builder = builder - .enable_ssl() - .sni_hostname(host) - .check_certificate(host); - log::debug!("enable ssl for backend: {backend_name}"); - } - - match builder.finish() { - Ok(_) => { - log::debug!("created dynamic backend: {backend_name} -> {host_with_port}"); - Ok(backend_name) - } - Err(err) => { - let msg = err.to_string(); - if msg.contains("NameInUse") || msg.contains("already in use") { - log::debug!("reusing existing dynamic backend: {backend_name}"); - Ok(backend_name) - } else { - Err(EdgeError::internal(anyhow!( - "dynamic backend creation failed ({backend_name} -> {host_with_port}): {msg}" - ))) - } - } - } -} - -fn fastly_body_stream(mut body: fastly::Body) -> ChunkStream { - try_stream! { - for result in body.read_chunks(8 * 1024) { - let chunk = result?; - yield chunk; - } - } - .boxed() -} - -async fn forward_request_body( - body: Body, - streaming_body: &mut StreamingBody, -) -> Result<(), EdgeError> { - match body { - Body::Once(bytes) => { - if !bytes.is_empty() { - streaming_body - .write_all(bytes.as_ref()) - .map_err(EdgeError::internal)?; - } - } - Body::Stream(mut stream) => { - while let Some(result) = stream.next().await { - let chunk = result.map_err(EdgeError::internal)?; - streaming_body - .write_all(&chunk) - .map_err(EdgeError::internal)?; - } - } - } - - streaming_body.flush().map_err(EdgeError::internal)?; - - Ok(()) -} - -fn transform_stream( - stream: ChunkStream, - encoding: Option<&str>, -) -> BoxStream<'static, Result> { - match encoding { - Some("gzip") => decode_gzip_stream(stream).boxed(), - Some("br") => decode_brotli_stream(stream).boxed(), - _ => stream.map(|res| res.map(Bytes::from)).boxed(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use brotli::CompressorWriter; - use flate2::{Compression, write::GzEncoder}; - use futures::executor::block_on; - - fn collect_body(body: Body) -> Vec { - match body { - Body::Once(bytes) => bytes.to_vec(), - Body::Stream(mut stream) => block_on(async { - let mut out = Vec::new(); - while let Some(chunk) = stream.next().await { - out.extend_from_slice(&chunk.expect("chunk")); - } - out - }), - } - } - - #[test] - fn convert_response_preserves_multi_value_set_cookie() { - let mut fastly_response = FastlyResponse::from_status(200); - fastly_response.append_header("set-cookie", "a=1"); - fastly_response.append_header("set-cookie", "b=2"); - - let proxy_response = convert_response(&mut fastly_response); - - let cookies: Vec = proxy_response - .headers() - .get_all("set-cookie") - .into_iter() - .map(|value| value.to_str().expect("utf8").to_owned()) - .collect(); - assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]); - } - - #[test] - fn stream_handles_brotli() { - let mut compressed = Vec::new(); - let mut compressor = CompressorWriter::new(&mut compressed, 4096, 5, 21); - compressor.write_all(b"hello brotli").unwrap(); - drop(compressor); - - let mut br_body = fastly::Body::new(); - br_body.write_all(&compressed).unwrap(); - let body = Body::from_stream(transform_stream(fastly_body_stream(br_body), Some("br"))); - let collected = collect_body(body); - assert_eq!(collected, b"hello brotli"); - } - - #[test] - fn stream_handles_identity_and_gzip() { - let mut plain = fastly::Body::new(); - plain.write_all(b"plain").unwrap(); - let plain_body = Body::from_stream(transform_stream(fastly_body_stream(plain), None)); - assert_eq!(collect_body(plain_body), b"plain"); - - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(b"hello gzip").unwrap(); - let compressed = encoder.finish().unwrap(); - let mut gz_body = fastly::Body::new(); - gz_body.write_all(&compressed).unwrap(); - let gzip_body = - Body::from_stream(transform_stream(fastly_body_stream(gz_body), Some("gzip"))); - assert_eq!(collect_body(gzip_body), b"hello gzip"); - } -} diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index a0998f936..a3c58dfe6 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -1,28 +1,42 @@ use std::collections::{HashSet, VecDeque}; use std::fmt::Display; -use std::io::Read as _; +#[cfg(all(feature = "test-utils", target_arch = "wasm32"))] +use std::io::Cursor; +use std::io::Read; use std::sync::{Arc, Mutex, OnceLock, PoisonError}; +use std::task::Poll; +use bytes::Bytes; use edgezero_core::app::{App, StoreMetadata, StoresMetadata}; use edgezero_core::body::Body; -use edgezero_core::config_store::ConfigStoreHandle; +use edgezero_core::config_store::{ConfigStoreHandle, ConfigStoreOpenFailure}; use edgezero_core::error::EdgeError; -use edgezero_core::http::{Extensions, Request, request_builder}; +#[cfg(all(feature = "test-utils", target_arch = "wasm32"))] +use edgezero_core::http::Uri; +use edgezero_core::http::{Extensions, Method, Request, Response, request_builder}; +use edgezero_core::ingress::{ + IngressBeginOutcome, IngressDispatchOutcome, IngressFraming, IngressHeadAccounting, + IngressHeadParts, PreparedIngress, +}; use edgezero_core::key_value_store::KvHandle; -use edgezero_core::proxy::ProxyHandle; +use edgezero_core::outbound::HttpClient; +use edgezero_core::response_egress::ResponseEgressEnvelope; use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, }; -use fastly::{Error as FastlyError, Request as FastlyRequest, Response as FastlyResponse}; +use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; +use fastly::{Error as FastlyError, Request as FastlyRequest}; use futures::executor; +use futures_util::stream; use std::collections::BTreeMap; use crate::config_store::FastlyConfigStore; use crate::context::FastlyRequestContext; +use crate::init_fastly_abi; use crate::key_value_store::FastlyKvStore; -use crate::proxy::FastlyProxyClient; -use crate::response::{from_core_response, parse_uri}; +use crate::outbound::FastlyOutboundClient; +use crate::response::{parse_uri, send_egress_response}; use crate::secret_store::FastlySecretStore; const WARNED_STORE_CACHE_LIMIT: usize = 64; @@ -93,7 +107,7 @@ enum ConfigSource { /// .with_kv("sessions").require_kv() /// .with_config("app_config") /// .with_secrets() -/// .dispatch(req) +/// .send() /// ``` pub struct FastlyService<'app> { app: &'app App, @@ -112,25 +126,47 @@ enum SecretSource { On { required: bool }, } +enum DispatchOutcome { + Aborted, + Detached(ResponseEgressEnvelope), + Routed(ResponseEgressEnvelope), +} + +impl DispatchOutcome { + #[cfg(any(test, feature = "test-utils"))] + fn into_envelope(self) -> Result { + match self { + Self::Detached(envelope) | Self::Routed(envelope) => Ok(envelope), + Self::Aborted => Err(FastlyError::msg("ingress admission aborted")), + } + } +} + impl<'app> FastlyService<'app> { - /// Resolve every wired store at request time and dispatch - /// against the wrapped `App`. Consumes the service so a builder - /// can't be reused with stale wiring. - /// - /// # Errors - /// Returns an error if a required store cannot be opened or - /// the underlying handler returns an error. + /// Test-only request-in/egress-envelope dispatch seam. + #[cfg(feature = "test-utils")] + #[doc(hidden)] #[inline] - pub fn dispatch(self, req: FastlyRequest) -> Result { + pub fn capture_egress_for_test( + self, + req: FastlyRequest, + ) -> Result { + self.dispatch_with(req, |_req, _extensions| {}) + .and_then(DispatchOutcome::into_envelope) + } + + fn dispatch_with( + self, + req: FastlyRequest, + prepare: Prepare, + ) -> Result + where + Prepare: FnOnce(&mut FastlyRequest, &mut Extensions), + { + let request_start = self.app.monotonic_now(); let config_store = match self.config { ConfigSource::Handle(handle) => Some(handle), - ConfigSource::Name(name) => match FastlyConfigStore::try_open(&name) { - Ok(store) => Some(ConfigStoreHandle::new(Arc::new(store))), - Err(err) => { - warn_missing_store_once(&name, &err.to_string()); - None - } - }, + ConfigSource::Name(name) => Some(open_config_handle(&name)), ConfigSource::None => None, }; let kv = match self.kv { @@ -150,7 +186,8 @@ impl<'app> FastlyService<'app> { secrets, ..Default::default() }, - |_req, _extensions| {}, + request_start, + prepare, ) } @@ -191,6 +228,65 @@ impl<'app> FastlyService<'app> { self } + /// Exercises the closed request/response lifecycle with an injected delivery boundary. + #[cfg(feature = "test-utils")] + #[doc(hidden)] + #[inline] + pub fn run_request_with_hooks_for_test( + self, + req: FastlyRequest, + prepare: Prepare, + finalize: Finalize, + deliver: Deliver, + ) -> Result, FastlyError> + where + Prepare: FnOnce(&mut FastlyRequest, &mut Extensions), + Finalize: FnOnce(&mut Response) -> State, + Deliver: FnOnce(ResponseEgressEnvelope) -> Result<(), FastlyError>, + { + let outcome = self.dispatch_with(req, prepare)?; + deliver_with_hooks(outcome, finalize, deliver) + } + + /// Resolve every wired store, receive the client request, and transmit its response. + /// + /// # Errors + /// Returns an error if a required store cannot be opened or + /// the underlying handler returns an error. + #[inline] + pub fn send(self) -> Result<(), FastlyError> { + init_fastly_abi(); + let req = FastlyRequest::from_client(); + self.send_request_with_hooks(req, |_req, _extensions| {}, |_response| ()) + .map(|_state| ()) + } + + /// Runs an already-received request through the closed `EdgeZero` lifecycle. + /// + /// `prepare` may mutate the borrowed raw request and add portable extensions before + /// conversion. `finalize` may mutate only a routed response before egress policy/framing. The + /// adapter retains response ownership, sends exactly once, and returns routed hook state only + /// after delivery reaches its terminal boundary. Detached ingress responses return `None` and + /// skip `finalize`. + /// + /// # Errors + /// Returns an error when required stores, request conversion, or response delivery fails. + #[inline] + pub fn send_request_with_hooks( + self, + req: FastlyRequest, + prepare: Prepare, + finalize: Finalize, + ) -> Result, FastlyError> + where + Prepare: FnOnce(&mut FastlyRequest, &mut Extensions), + Finalize: FnOnce(&mut Response) -> State, + { + init_fastly_abi(); + let outcome = self.dispatch_with(req, prepare)?; + deliver_with_hooks(outcome, finalize, send_egress_response) + } + /// Open the Fastly Config Store named `name` and inject its /// handle into request extensions. If the store is unavailable /// at request time, the dispatcher logs the warning once and @@ -256,18 +352,47 @@ impl<'app> FastlyService<'app> { } } +fn detached_error_outcome( + app: &App, + error: EdgeError, + request_method: Method, + request_start: MonotonicInstant, +) -> DispatchOutcome { + match app.detached_ingress_error_egress(error, request_method, request_start) { + IngressDispatchOutcome::Response(envelope) => DispatchOutcome::Detached(*envelope), + IngressDispatchOutcome::Aborted | _ => DispatchOutcome::Aborted, + } +} + +fn deliver_with_hooks( + outcome: DispatchOutcome, + finalize: Finalize, + deliver: Deliver, +) -> Result, FastlyError> +where + Finalize: FnOnce(&mut Response) -> State, + Deliver: FnOnce(ResponseEgressEnvelope) -> Result<(), FastlyError>, +{ + match outcome { + DispatchOutcome::Aborted => Err(FastlyError::msg("ingress admission aborted")), + DispatchOutcome::Detached(envelope) => { + deliver(envelope)?; + Ok(None) + } + DispatchOutcome::Routed(mut envelope) => { + let state = finalize(envelope.response_mut()); + deliver(envelope)?; + Ok(Some(state)) + } + } +} + fn dispatch_core_request( app: &App, mut core_request: Request, stores: Stores, -) -> Result { - insert_store_registries(&mut core_request, stores); - let response = executor::block_on(app.router().oneshot(core_request)) - .map_err(|err| map_edge_error(&err))?; - from_core_response(response).map_err(|err| map_edge_error(&err)) -} - -fn insert_store_registries(core_request: &mut Request, stores: Stores) { + prepared: PreparedIngress, +) -> DispatchOutcome { // Hard-cutoff: legacy bare handles are no longer // inserted into request extensions. `with_config_handle` // still accepts a `ConfigStoreHandle`, but the dispatcher @@ -286,38 +411,98 @@ fn insert_store_registries(core_request: &mut Request, stores: Stores) { if let Some(registry) = secret_registry { core_request.extensions_mut().insert(registry); } + let response = executor::block_on(app.dispatch_admitted(prepared, core_request)); + DispatchOutcome::Routed(response) } -/// Run an app-provided closure against a scratch `Extensions` populated from the -/// RAW `fastly::Request` (JA4 / H2 / etc.), BEFORE `into_core_request` consumes -/// the request. Returns the scratch bag to be `extend`ed into the core request. -fn apply_request_extend(req: &FastlyRequest, extend: F) -> Extensions +/// Run an app-provided closure against the borrowed raw request and a scratch extension bag before +/// conversion consumes the request. +fn apply_request_prepare(req: &mut FastlyRequest, prepare: Prepare) -> Extensions where - F: FnOnce(&FastlyRequest, &mut Extensions), + Prepare: FnOnce(&mut FastlyRequest, &mut Extensions), { let mut scratch = Extensions::default(); - extend(req, &mut scratch); + prepare(req, &mut scratch); scratch } -fn dispatch_with_handles( +fn dispatch_with_handles( app: &App, - req: FastlyRequest, + mut req: FastlyRequest, stores: Stores, - extend: F, -) -> Result + request_start: MonotonicInstant, + prepare: Prepare, +) -> Result where - F: FnOnce(&FastlyRequest, &mut Extensions), + Prepare: FnOnce(&mut FastlyRequest, &mut Extensions), { - // Read raw-request signals into a scratch bag BEFORE conversion consumes `req`. - let scratch = apply_request_extend(&req, extend); - let mut core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?; - core_request.extensions_mut().extend(scratch); - dispatch_core_request(app, core_request, stores) + let scratch = apply_request_prepare(&mut req, prepare); + let mut head_request = into_core_request_head(&req, app.monotonic_clock()) + .map_err(|error| map_edge_error(&error))?; + head_request.extensions_mut().extend(scratch); + Ok(dispatch_ingress_reader( + app, + head_request, + stores, + request_start, + move || Ok(req.take_body()), + )) } -/// Dispatch with per-id store registries built from baked metadata — the same -/// store wiring [`run_app`](crate::run_app) uses. +fn dispatch_ingress_reader( + app: &App, + mut head_request: Request, + stores: Stores, + request_start: MonotonicInstant, + make_source: MakeSource, +) -> DispatchOutcome +where + Source: Read + 'static, + MakeSource: FnOnce() -> Result, +{ + let request_method = head_request.method().clone(); + let head_parts = IngressHeadParts::from_request( + &head_request, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + if let Err(error) = head_parts.validate_normalized(app.ingress_head_limits()) { + return detached_error_outcome(app, error, request_method, request_start); + } + let prepared = match app.begin_ingress(head_parts, request_start) { + Ok(outcome) => match outcome { + IngressBeginOutcome::Admitted(prepared) => prepared, + IngressBeginOutcome::Refused(response) => { + return DispatchOutcome::Detached(response); + } + IngressBeginOutcome::Aborted => return DispatchOutcome::Aborted, + _ => { + return detached_error_outcome( + app, + EdgeError::internal(anyhow::anyhow!("unsupported ingress admission outcome")), + request_method, + request_start, + ); + } + }, + Err(error) => { + return detached_error_outcome(app, error, request_method, request_start); + } + }; + let source = match make_source() { + Ok(source) => source, + Err(error) => { + return DispatchOutcome::Detached(app.admitted_error_egress(prepared, error)); + } + }; + *head_request.body_mut() = fastly_deadline_body( + source, + Some((prepared.read_deadline(), prepared.monotonic_clock())), + ); + dispatch_core_request(app, head_request, stores, prepared) +} + +/// Dispatch with per-id store registries built from baked metadata and closed lifecycle hooks. /// /// Fastly is `Multi` for all three kinds. Each declared ID is the stable /// resource-link alias opened by the runtime. Config always uses the logical ID @@ -326,71 +511,72 @@ where /// /// ```rust,ignore /// let stores = MyHooks::stores(); -/// dispatch_with_registries(&app, req, stores, |_req, _extensions| {}) +/// send_request_with_registries_and_hooks(&app, stores, req, |_req, _extensions| {}, |_response| ()) /// ``` /// /// [`FastlyService`]'s bare-handle path binds `default_key: "default"` instead /// of the logical ID. /// /// KV failures escalate via `resolve_kv_handle`'s `kv_required=true` path; -/// missing config / secret stores degrade silently with a one-time warning. +/// Failed declared config bindings remain typed failing handles, and secret +/// bindings open their logical alias when extraction requests the value. /// /// # Errors /// Returns an error if a declared KV store cannot be opened, or if the /// underlying handler returns an error. #[inline] -pub fn dispatch_with_registries( +pub fn send_with_registries_and_hooks( app: &App, - req: FastlyRequest, stores: StoresMetadata, - extend: F, -) -> Result + prepare: Prepare, + finalize: Finalize, +) -> Result, FastlyError> where - F: FnOnce(&FastlyRequest, &mut Extensions), + Prepare: FnOnce(&mut FastlyRequest, &mut Extensions), + Finalize: FnOnce(&mut Response) -> State, { - let core_request = into_core_request_with_registries(req, stores, extend)?; - let response = executor::block_on(app.router().oneshot(core_request)) - .map_err(|err| map_edge_error(&err))?; - from_core_response(response).map_err(|err| map_edge_error(&err)) + init_fastly_abi(); + let req = FastlyRequest::from_client(); + send_request_with_registries_and_hooks(app, stores, req, prepare, finalize) } -/// Convert a Fastly request and insert the registries derived from baked store -/// metadata, without dispatching or converting a response. +/// Deliver an already-received request through canonical admission and owned egress. /// -/// The extension callback reads the native request before conversion. The -/// registries are then built from the logical aliases linked to the deployed -/// service version. Drive `app.router().oneshot(request)` with -/// `futures::executor::block_on` when the caller needs response extensions or -/// progressive streaming. +/// Use this inside a retained serving loop; it never receives another client request. +/// Request registries, preparation, finalization, and body handles remain request-local. /// /// # Errors -/// Returns an error if a declared KV store cannot be opened or the request -/// cannot be converted, including an inbound body read failure. +/// Returns store setup, admission-abort, or terminal delivery errors without sending a second response. #[inline] -pub fn into_core_request_with_registries( - req: FastlyRequest, +pub fn send_request_with_registries_and_hooks( + app: &App, stores: StoresMetadata, - extend: F, -) -> Result + req: FastlyRequest, + prepare: Prepare, + finalize: Finalize, +) -> Result, FastlyError> where - F: FnOnce(&FastlyRequest, &mut Extensions), + Prepare: FnOnce(&mut FastlyRequest, &mut Extensions), + Finalize: FnOnce(&mut Response) -> State, { + init_fastly_abi(); + let request_start = app.monotonic_now(); let kv_registry = build_kv_registry(stores.kv)?; let config_registry = build_config_registry(stores.config); let secret_registry = build_secret_registry(stores.secrets); - let scratch = apply_request_extend(&req, extend); - let mut core_request = into_core_request(req).map_err(|err| map_edge_error(&err))?; - core_request.extensions_mut().extend(scratch); - insert_store_registries( - &mut core_request, + let outcome = dispatch_with_handles( + app, + req, Stores { config_registry, kv_registry, secret_registry, ..Default::default() }, - ); - Ok(core_request) + request_start, + prepare, + )?; + deliver_with_hooks(outcome, finalize, send_egress_response) } /// Pure synthesis: collapse a `Stores` (which may carry both a @@ -463,26 +649,25 @@ fn build_config_registry(config_meta: Option) -> Option = BTreeMap::new(); for id in meta.ids { - match FastlyConfigStore::try_open(id) { - Ok(store) => { - by_id.insert( - (*id).to_owned(), - ConfigStoreBinding { - handle: ConfigStoreHandle::new(Arc::new(store)), - default_key: (*id).to_owned(), - }, - ); - } - Err(err) => warn_missing_store_once(id, &err.to_string()), - } - } - let default_id = meta.default.to_owned(); - if !by_id.contains_key(&default_id) { - log::warn!( - "config registry default id `{default_id}` could not be opened; dropping the config registry" + by_id.insert( + (*id).to_owned(), + ConfigStoreBinding { + handle: open_config_handle(id), + default_key: (*id).to_owned(), + }, ); } - StoreRegistry::from_parts(by_id, default_id) + StoreRegistry::from_parts(by_id, meta.default.to_owned()) +} + +fn open_config_handle(store_name: &str) -> ConfigStoreHandle { + match FastlyConfigStore::try_open(store_name) { + Ok(store) => ConfigStoreHandle::new(Arc::new(store)), + Err(err) => { + warn_missing_store_once(store_name, &err.to_string()); + ConfigStoreHandle::failed_open(ConfigStoreOpenFailure::InvalidConfiguration) + } + } } fn build_secret_registry(secret_meta: Option) -> Option { @@ -509,6 +694,14 @@ fn build_secret_registry(secret_meta: Option) -> Option Result { + let request = into_core_request_head(&req, MonotonicClock::default())?; + Ok(attach_core_body(&mut req, request, None)) +} + +fn into_core_request_head( + req: &FastlyRequest, + outbound_clock: MonotonicClock, +) -> Result { let method = req.get_method().clone(); let uri = parse_uri(req.get_url_str())?; @@ -517,13 +710,7 @@ pub fn into_core_request(mut req: FastlyRequest) -> Result { builder = builder.header(name.as_str(), value.as_bytes()); } - let mut body = req.take_body(); - let mut bytes = Vec::new(); - body.read_to_end(&mut bytes).map_err(EdgeError::internal)?; - - let mut request = builder - .body(Body::from(bytes)) - .map_err(EdgeError::internal)?; + let mut request = builder.body(Body::empty()).map_err(EdgeError::internal)?; let context = FastlyRequestContext { client_ip: req.get_client_ip_addr(), @@ -531,11 +718,183 @@ pub fn into_core_request(mut req: FastlyRequest) -> Result { FastlyRequestContext::insert(&mut request, context); request .extensions_mut() - .insert(ProxyHandle::with_client(FastlyProxyClient)); + .insert(outbound_client(outbound_clock)); Ok(request) } +fn outbound_client(clock: MonotonicClock) -> HttpClient { + HttpClient::with_client(FastlyOutboundClient::with_clock(clock)) +} + +fn attach_core_body( + req: &mut FastlyRequest, + mut request: Request, + read_lifetime: Option<(Deadline, MonotonicClock)>, +) -> Request { + *request.body_mut() = fastly_deadline_body(req.take_body(), read_lifetime); + request +} + +fn fastly_deadline_body( + source: Source, + read_lifetime: Option<(Deadline, MonotonicClock)>, +) -> Body +where + Source: Read + 'static, +{ + let mut native_body = Some(source); + let mut terminal = false; + let stream = stream::poll_fn(move |_cx| { + if terminal { + return Poll::Ready(None); + } + if read_lifetime + .as_ref() + .is_some_and(|(deadline, clock)| deadline.is_expired_at(clock.now())) + { + terminal = true; + native_body.take(); + return Poll::Ready(Some(Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )))); + } + + let Some(body) = native_body.as_mut() else { + terminal = true; + return Poll::Ready(None); + }; + let mut chunk = vec![0_u8; 16 * 1024]; + let result = body.read(&mut chunk); + if read_lifetime + .as_ref() + .is_some_and(|(deadline, clock)| deadline.is_expired_at(clock.now())) + { + terminal = true; + native_body.take(); + return Poll::Ready(Some(Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )))); + } + match result { + Ok(0) => { + terminal = true; + native_body.take(); + Poll::Ready(None) + } + Ok(read) => { + chunk.truncate(read); + Poll::Ready(Some(Ok(Bytes::from(chunk)))) + } + Err(error) => { + terminal = true; + native_body.take(); + Poll::Ready(Some(Err(EdgeError::internal(error)))) + } + } + }); + Body::from_stream(stream) +} + +/// Dispatches an observable reader through the production ingress body wrapper. +#[cfg(all(feature = "test-utils", target_arch = "wasm32"))] +#[doc(hidden)] +#[inline] +pub fn dispatch_ingress_reader_for_test( + app: &App, + method: Method, + uri: Uri, + source: Source, +) -> Result +where + Source: Read + 'static, +{ + let request_start = app.monotonic_now(); + let mut core_request = request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .map_err(|error| map_edge_error(&EdgeError::internal(error)))?; + core_request + .extensions_mut() + .insert(outbound_client(app.monotonic_clock())); + dispatch_ingress_reader( + app, + core_request, + Stores::default(), + request_start, + move || Ok(source), + ) + .into_envelope() +} + +/// Exercises admission ordering with observable reader construction and delivery closures. +#[cfg(all(feature = "test-utils", target_arch = "wasm32"))] +#[doc(hidden)] +#[inline] +pub fn dispatch_ingress_with_for_test( + app: &App, + method: Method, + uri: Uri, + make_source: MakeSource, + deliver: Deliver, +) -> Result<(), FastlyError> +where + Source: Read + 'static, + MakeSource: FnOnce() -> Result, + Deliver: FnOnce(ResponseEgressEnvelope) -> Result<(), FastlyError>, +{ + let request_start = app.monotonic_now(); + let mut core_request = request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .map_err(|error| map_edge_error(&EdgeError::internal(error)))?; + core_request + .extensions_mut() + .insert(outbound_client(app.monotonic_clock())); + let outcome = dispatch_ingress_reader( + app, + core_request, + Stores::default(), + request_start, + make_source, + ); + deliver_with_hooks(outcome, |_response| (), deliver).map(|_state| ()) +} + +/// Exercises a reader-construction failure after admission through the production seam. +#[cfg(all(feature = "test-utils", target_arch = "wasm32"))] +#[doc(hidden)] +#[inline] +pub fn dispatch_ingress_source_error_for_test( + app: &App, + method: Method, + uri: Uri, +) -> Result { + let request_start = app.monotonic_now(); + let mut core_request = request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .map_err(|error| map_edge_error(&EdgeError::internal(error)))?; + core_request + .extensions_mut() + .insert(outbound_client(app.monotonic_clock())); + dispatch_ingress_reader( + app, + core_request, + Stores::default(), + request_start, + || -> Result>, EdgeError> { + Err(EdgeError::internal(anyhow::anyhow!( + "fastly source construction failed" + ))) + }, + ) + .into_envelope() +} + fn map_edge_error(err: &EdgeError) -> FastlyError { FastlyError::msg(err.to_string()) } @@ -611,18 +970,48 @@ fn warn_missing_store_once(store_name: &str, detail: &str) { #[cfg(test)] mod synthesis_tests { use super::*; - use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::config_store::{ + BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigStoreHandle, + finish_bounded_config_read, + }; + use edgezero_core::context::RequestContext; use edgezero_core::key_value_store::{KvStore, NoopKvStore}; + use edgezero_core::router::RouterService; use edgezero_core::secret_store::{NoopSecretStore, SecretHandle}; use std::collections::BTreeMap; use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; struct StubConfig; #[async_trait::async_trait(?Send)] impl ConfigStore for StubConfig { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + finish_bounded_config_read( + self.get(key).await, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + } } fn kv_handle() -> KvHandle { @@ -639,14 +1028,14 @@ mod synthesis_tests { } #[test] - fn apply_request_extend_populates_scratch_from_raw_request() { + fn apply_request_prepare_populates_scratch_from_raw_request() { use edgezero_core::http::Method; #[derive(Clone, Debug, PartialEq)] struct Ja4(String); - let raw = FastlyRequest::new(Method::GET, "http://example.test/"); - let scratch = apply_request_extend(&raw, |req, extensions| { + let mut raw = FastlyRequest::new(Method::GET, "http://example.test/"); + let scratch = apply_request_prepare(&mut raw, |req, extensions| { // A real closure would call req.get_tls_ja4(); deriving from the URL // keeps the assertion deterministic under Viceroy. let marker = req.get_url_str().to_owned(); @@ -659,6 +1048,61 @@ mod synthesis_tests { ); } + #[test] + fn standard_service_installs_the_exact_application_outbound_clock() { + use edgezero_core::http::Method; + + async fn elapsed(ctx: RequestContext) -> Result { + let client = ctx + .http_client() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing HTTP client")))?; + let request = + edgezero_core::OutboundRequest::get("https://example.com/")?.stream_response(); + let results = client + .send_all_until(vec![request], Deadline::after(Duration::from_secs(1))) + .await?; + let elapsed = results + .slots + .into_iter() + .next() + .flatten() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing batch slot")))? + .elapsed; + Ok(elapsed.as_millis().to_string()) + } + + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(7)) + .expect("completed instant"); + let observations = Arc::new(AtomicUsize::new(0)); + let clock_observations = Arc::clone(&observations); + let mut app = App::new(RouterService::builder().get("/clock", elapsed).build()); + app.set_monotonic_clock(MonotonicClock::new(move || { + if clock_observations.fetch_add(1, Ordering::SeqCst) < 2 { + start + } else { + completed + } + })); + let request = FastlyRequest::new(Method::GET, "http://example.test/clock"); + + let envelope = FastlyService::new(&app) + .dispatch_with(request, |_request, _extensions| {}) + .expect("dispatch") + .into_envelope() + .expect("response envelope"); + let Ok((prepared, _, mut attempt, egress_clock)) = envelope.begin() else { + panic!("begin response egress"); + }; + assert!(attempt.begin_writing()); + assert!(attempt.complete(egress_clock.now())); + let response = prepared.into_response(); + + assert_eq!(response.body().as_bytes(), Some(b"7".as_slice())); + assert!(observations.load(Ordering::SeqCst) >= 3); + } + #[test] fn extended_request_extensions_are_visible_to_handler() { use edgezero_core::body::Body; @@ -672,7 +1116,6 @@ mod synthesis_tests { async fn handler(ctx: RequestContext) -> Result { let ja4 = ctx - .request() .extensions() .get::() .map_or_else(|| "missing".to_owned(), |value| value.0.clone()); @@ -780,7 +1223,7 @@ mod synthesis_tests { // Regression for the `.with_secrets()` bug — the pre-fix // `resolve_secret_handle(false)` short-circuited to `None`, so the - // documented `.with_secrets().dispatch(...)` path silently ran + // documented `.with_secrets().send()` path silently ran // handlers without a `SecretRegistry`. After the fix, the handle // is always built when `SecretSource::On` is selected; `_required` // is reserved for whichever future per-secret-lookup availability diff --git a/crates/edgezero-adapter-fastly/src/response.rs b/crates/edgezero-adapter-fastly/src/response.rs index f9eb4d020..0960ac2d3 100644 --- a/crates/edgezero-adapter-fastly/src/response.rs +++ b/crates/edgezero-adapter-fastly/src/response.rs @@ -1,38 +1,606 @@ +#![cfg_attr( + feature = "fastly", + expect( + clippy::arbitrary_source_item_ordering, + reason = "portable protocol traits precede the gated Fastly ABI lifecycle implementation" + ) +)] + +#[cfg(feature = "fastly")] +#[path = "fastly_response_abi.rs"] +mod fastly_abi; + use edgezero_core::body::Body; use edgezero_core::error::EdgeError; -use edgezero_core::http::{Response, Uri}; -use fastly::Response as FastlyResponse; +use edgezero_core::http::{HeaderMap, Method, Response, StatusCode, Uri}; +use edgezero_core::response_egress::{ + RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET, ResponseEgressAttempt, ResponseEgressEnvelope, + ResponseEgressFallbackDisposition, ResponseEgressOutcome, ResponseEgressPolicy, +}; +use edgezero_core::response_egress_framing::{ + PreparedResponseEgress, prepare_response_egress, response_egress_source_error_category, +}; +use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; use futures::executor; use futures_util::StreamExt as _; -use std::io::Write as _; -/// # Errors -/// Returns [`EdgeError::Internal`] if the response body cannot be streamed to the Fastly send-channel. -#[inline] -pub fn from_core_response(response: Response) -> Result { - let (parts, body) = response.into_parts(); - let mut fastly_response = FastlyResponse::from_status(parts.status.as_u16()); +const INTERNAL_FALLBACK_BODY: &[u8] = b"internal server error"; +const TIMEOUT_FALLBACK_BODY: &[u8] = b"response write deadline exceeded"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DeliveryError { + Conversion, + Deadline, + Source, + Transport, +} + +impl DeliveryError { + const fn from_outcome(outcome: ResponseEgressOutcome) -> Self { + match outcome { + ResponseEgressOutcome::ConversionError => Self::Conversion, + ResponseEgressOutcome::DeadlineExceeded => Self::Deadline, + ResponseEgressOutcome::SourceError => Self::Source, + ResponseEgressOutcome::ClientDisconnected + | ResponseEgressOutcome::Completed + | ResponseEgressOutcome::HostHandoff + | ResponseEgressOutcome::RequestCancelled + | ResponseEgressOutcome::TransportError + | ResponseEgressOutcome::Unspecified + | _ => Self::Transport, + } + } + + const fn outcome(self) -> ResponseEgressOutcome { + match self { + Self::Conversion => ResponseEgressOutcome::ConversionError, + Self::Deadline => ResponseEgressOutcome::DeadlineExceeded, + Self::Source => ResponseEgressOutcome::SourceError, + Self::Transport => ResponseEgressOutcome::TransportError, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TransmitFailure { + Postcommit(DeliveryError), + Precommit { + cause: ResponseEgressOutcome, + error: DeliveryError, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TransmitKind { + Application, + Fallback, +} + +trait EgressSink { + type Error; + + fn abandon(&mut self) -> Result<(), Self::Error>; + fn finish(&mut self) -> Result<(), Self::Error>; + fn write(&mut self, bytes: &[u8]) -> Result; +} + +trait EgressCommitter { + type Error; + type Prepared; + type Sink: EgressSink; + + fn commit(&mut self, prepared: Self::Prepared) -> Result; + fn prepare( + &mut self, + status: StatusCode, + headers: &HeaderMap, + transmits_body: bool, + ) -> Result; +} + +#[cfg(feature = "fastly")] +mod platform { + use super::{ + DeliveryError, EgressCommitter, EgressSink, HeaderMap, ResponseEgressEnvelope, StatusCode, + send_egress_with, + }; + use std::mem; + + use edgezero_core::http::header::CONTENT_LENGTH; + use fastly_shared::{INVALID_BODY_HANDLE, INVALID_RESPONSE_HANDLE}; + use fastly_sys::{BodyHandle as FastlyBodyHandle, ResponseHandle as FastlyResponseHandle}; + + use super::fastly_abi::{self, FastlyAbiError}; + + const fn public_message(error: DeliveryError) -> &'static str { + match error { + DeliveryError::Conversion => "response conversion failed", + DeliveryError::Deadline => "response write deadline exceeded", + DeliveryError::Source => "response body source failed", + DeliveryError::Transport => "response transport failed", + } + } + + pub(super) struct FastlyPrepared { + body: FastlyBodyHandle, + response: FastlyResponseHandle, + } + + impl FastlyPrepared { + fn commit(mut self) -> Result { + let response = mem::replace(&mut self.response, INVALID_RESPONSE_HANDLE); + let body = mem::replace(&mut self.body, INVALID_BODY_HANDLE); + if fastly_abi::send_streaming(response, body).is_err() { + let _result = fastly_abi::abandon_body(body); + return Err(FastlyAbiError); + } + Ok(FastlyBodySink(body)) + } + + fn new( + status: StatusCode, + headers: &HeaderMap, + transmits_body: bool, + ) -> Result { + let response = fastly_abi::new_response()?; + let mut prepared = Self { + body: INVALID_BODY_HANDLE, + response, + }; + prepared.body = fastly_abi::new_body()?; + fastly_abi::set_status(prepared.response, status.as_u16())?; + for (name, value) in headers { + fastly_abi::append_header( + prepared.response, + name.as_str().as_bytes(), + value.as_bytes(), + )?; + } + if transmits_body && headers.contains_key(CONTENT_LENGTH) { + fastly_abi::set_manual_framing(prepared.response)?; + } + Ok(prepared) + } + } + + impl Drop for FastlyPrepared { + fn drop(&mut self) { + if self.body != INVALID_BODY_HANDLE { + let _result = fastly_abi::close_body(self.body); + self.body = INVALID_BODY_HANDLE; + } + if self.response != INVALID_RESPONSE_HANDLE { + let _result = fastly_abi::close_response(self.response); + self.response = INVALID_RESPONSE_HANDLE; + } + } + } + pub(super) struct FastlyBodySink(FastlyBodyHandle); + + impl Drop for FastlyBodySink { + fn drop(&mut self) { + if self.0 != INVALID_BODY_HANDLE { + let _result = fastly_abi::abandon_body(self.0); + self.0 = INVALID_BODY_HANDLE; + } + } + } + + impl EgressSink for FastlyBodySink { + type Error = FastlyAbiError; + + fn abandon(&mut self) -> Result<(), Self::Error> { + if self.0 == INVALID_BODY_HANDLE { + return Ok(()); + } + let handle = mem::replace(&mut self.0, INVALID_BODY_HANDLE); + fastly_abi::abandon_body(handle) + } + + fn finish(&mut self) -> Result<(), Self::Error> { + if self.0 == INVALID_BODY_HANDLE { + return Err(FastlyAbiError); + } + let handle = mem::replace(&mut self.0, INVALID_BODY_HANDLE); + fastly_abi::close_body(handle) + } + + fn write(&mut self, bytes: &[u8]) -> Result { + if self.0 == INVALID_BODY_HANDLE { + return Err(FastlyAbiError); + } + fastly_abi::write_body(self.0, bytes) + } + } + + pub(super) struct FastlyCommitter; + + impl EgressCommitter for FastlyCommitter { + type Error = FastlyAbiError; + type Prepared = FastlyPrepared; + type Sink = FastlyBodySink; + + fn commit(&mut self, prepared: Self::Prepared) -> Result { + prepared.commit() + } + + fn prepare( + &mut self, + status: StatusCode, + headers: &HeaderMap, + transmits_body: bool, + ) -> Result { + FastlyPrepared::new(status, headers, transmits_body) + } + } + + /// Sends one core response through Fastly's unbuffered downstream body handle. + /// + /// # Errors + /// Returns a category-only Fastly error when the committed response cannot reach its terminal + /// body-handle boundary. Precommit conversion and deadline failures first attempt the bounded + /// adapter fallback on the same response-egress attempt. + pub(crate) fn send_egress_response( + egress: ResponseEgressEnvelope, + ) -> Result<(), fastly::Error> { + send_egress_with(egress, &mut FastlyCommitter) + .map_err(|error| fastly::Error::msg(public_message(error))) + } +} + +#[cfg(feature = "fastly")] +pub(crate) use platform::send_egress_response; + +fn send_egress_with( + egress: ResponseEgressEnvelope, + committer: &mut Committer, +) -> Result<(), DeliveryError> +where + Committer: EgressCommitter, +{ + let request_method = egress.request_method().clone(); + match egress.begin() { + Ok((prepared, policy, mut attempt, clock)) => { + match transmit_prepared( + prepared, + policy, + &mut attempt, + &clock, + TransmitKind::Application, + committer, + ) { + Ok(()) => Ok(()), + Err(TransmitFailure::Precommit { cause, .. }) => { + send_fallback(&request_method, cause, attempt, &clock, committer) + } + Err(TransmitFailure::Postcommit(error)) => Err(error), + } + } + Err(failure) => { + let (attempt, clock, cause) = failure.into_parts(); + send_fallback(&request_method, cause, attempt, &clock, committer) + } + } +} + +fn send_fallback( + request_method: &Method, + original_cause: ResponseEgressOutcome, + mut attempt: ResponseEgressAttempt, + clock: &MonotonicClock, + committer: &mut Committer, +) -> Result<(), DeliveryError> +where + Committer: EgressCommitter, +{ + if !attempt.begin_fallback(original_cause) { + log::error!("response-egress fallback state transition failed"); + attempt.terminate(ResponseEgressOutcome::ConversionError, clock.now()); + return Err(DeliveryError::Conversion); + } + + let fallback_started_at = clock.now(); + let fallback_deadline = fallback_started_at + .checked_add(RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET) + .unwrap_or(fallback_started_at); + let fallback_response = fallback_response(original_cause); + let prepared = match prepare_response_egress(request_method, fallback_response) { + Ok(prepared) => prepared, + Err(_error) => { + log::error!("response-egress fallback framing failed"); + attempt.finish_fallback(ResponseEgressFallbackDisposition::Aborted, clock.now()); + return Err(DeliveryError::Conversion); + } + }; + let policy = ResponseEgressPolicy { + write_deadline: Deadline::at_instant(fallback_deadline), + }; + match transmit_prepared( + prepared, + policy, + &mut attempt, + clock, + TransmitKind::Fallback, + committer, + ) { + Ok(()) => Ok(()), + Err(TransmitFailure::Postcommit(error) | TransmitFailure::Precommit { error, .. }) => { + Err(error) + } + } +} + +fn fallback_response(cause: ResponseEgressOutcome) -> Response { + let (status, body) = if cause == ResponseEgressOutcome::DeadlineExceeded { + (StatusCode::GATEWAY_TIMEOUT, TIMEOUT_FALLBACK_BODY) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, INTERNAL_FALLBACK_BODY) + }; + let mut response = Response::new(Body::from(body)); + *response.status_mut() = status; + response +} + +fn transmit_prepared( + prepared: PreparedResponseEgress, + policy: ResponseEgressPolicy, + attempt: &mut ResponseEgressAttempt, + clock: &MonotonicClock, + kind: TransmitKind, + committer: &mut Committer, +) -> Result<(), TransmitFailure> +where + Committer: EgressCommitter, +{ + if policy.write_deadline.is_expired_at(clock.now()) { + return precommit_failure( + kind, + attempt, + clock, + ResponseEgressOutcome::DeadlineExceeded, + ); + } + + let transmits_body = prepared.transmits_body(); + let (parts, body) = prepared.into_response().into_parts(); + let prepared_head = match committer.prepare(parts.status, &parts.headers, transmits_body) { + Ok(prepared_head) => prepared_head, + Err(_error) => { + log::error!("response-egress Fastly head preparation failed"); + let cause = if policy.write_deadline.is_expired_at(clock.now()) { + ResponseEgressOutcome::DeadlineExceeded + } else { + ResponseEgressOutcome::ConversionError + }; + return precommit_failure(kind, attempt, clock, cause); + } + }; + + let commit_started_at = clock.now(); + if policy.write_deadline.is_expired_at(commit_started_at) { + return precommit_failure( + kind, + attempt, + clock, + ResponseEgressOutcome::DeadlineExceeded, + ); + } + if !attempt.begin_writing() { + log::error!("response-egress writing state transition failed"); + settle_failure( + kind, + attempt, + ResponseEgressOutcome::TransportError, + clock.now(), + ); + return Err(TransmitFailure::Postcommit(DeliveryError::Transport)); + } + + let mut sink = match committer.commit(prepared_head) { + Ok(sink) => sink, + Err(_error) => { + log::error!("response-egress Fastly downstream commit failed"); + settle_failure( + kind, + attempt, + ResponseEgressOutcome::TransportError, + clock.now(), + ); + return Err(TransmitFailure::Postcommit(DeliveryError::Transport)); + } + }; + + let committed_at = clock.now(); + if policy.write_deadline.is_expired_at(committed_at) { + abandon(&mut sink); + settle_failure( + kind, + attempt, + ResponseEgressOutcome::DeadlineExceeded, + committed_at, + ); + return Err(TransmitFailure::Postcommit(DeliveryError::Deadline)); + } + + let pump_result = pump_body(body, &mut sink, policy.write_deadline, attempt, clock); + if let Err(error) = pump_result { + abandon(&mut sink); + settle_failure(kind, attempt, error.outcome(), clock.now()); + return Err(TransmitFailure::Postcommit(error)); + } + + if policy.write_deadline.is_expired_at(clock.now()) { + abandon(&mut sink); + settle_failure( + kind, + attempt, + ResponseEgressOutcome::DeadlineExceeded, + clock.now(), + ); + return Err(TransmitFailure::Postcommit(DeliveryError::Deadline)); + } + // Fastly consumes the native handle before reporting a close failure, so no + // second abandon operation is available after this boundary. + let finish_result = sink.finish(); + let observed_at = clock.now(); + let finish_failed = finish_result.is_err(); + let finish_error = if policy.write_deadline.is_expired_at(observed_at) { + Some(DeliveryError::Deadline) + } else if finish_failed { + Some(DeliveryError::Transport) + } else { + None + }; + if let Some(error) = finish_error { + if finish_failed { + log::error!("response-egress Fastly body finish failed"); + } + settle_failure(kind, attempt, error.outcome(), observed_at); + return Err(TransmitFailure::Postcommit(error)); + } + + settle_success(kind, attempt, observed_at); + Ok(()) +} + +fn pump_body( + body: Body, + sink: &mut Sink, + deadline: Deadline, + attempt: &mut ResponseEgressAttempt, + clock: &MonotonicClock, +) -> Result<(), DeliveryError> +where + Sink: EgressSink, +{ match body { - Body::Once(bytes) => fastly_response.set_body(bytes.to_vec()), - Body::Stream(mut stream) => { - let mut fastly_body = fastly::Body::new(); - while let Some(result) = executor::block_on(stream.next()) { - let chunk = result.map_err(EdgeError::internal)?; - fastly_body.write_all(&chunk).map_err(EdgeError::internal)?; + Body::Once(bytes) => write_chunk(bytes.as_ref(), sink, deadline, attempt, clock), + Body::Stream(mut source) => loop { + ensure_deadline(deadline, clock)?; + let next = executor::block_on(source.next()); + ensure_deadline(deadline, clock)?; + match next { + Some(Ok(bytes)) => { + write_chunk(bytes.as_ref(), sink, deadline, attempt, clock)?; + } + Some(Err(error)) => { + log::warn!( + "response-egress Fastly body source failed after commit: {}", + response_egress_source_error_category(&error) + ); + return Err(DeliveryError::Source); + } + None => return Ok(()), + } + }, + } +} + +fn write_chunk( + bytes: &[u8], + sink: &mut Sink, + deadline: Deadline, + attempt: &mut ResponseEgressAttempt, + clock: &MonotonicClock, +) -> Result<(), DeliveryError> +where + Sink: EgressSink, +{ + let mut offset = 0; + while offset < bytes.len() { + ensure_deadline(deadline, clock)?; + let remaining = bytes.get(offset..).ok_or(DeliveryError::Transport)?; + let write_result = sink.write(remaining); + let observed_at = clock.now(); + if deadline.is_expired_at(observed_at) { + if let Ok(accepted_size) = write_result + && accepted_size <= remaining.len() + { + let accepted_u64 = + u64::try_from(accepted_size).map_err(|_error| DeliveryError::Transport)?; + if !attempt.account_bytes(accepted_u64, observed_at) { + return Err(DeliveryError::Transport); + } } - fastly_response.set_body(fastly_body); + return Err(DeliveryError::Deadline); } + let accepted_size = match write_result { + Ok(0) => return Err(DeliveryError::Transport), + Ok(accepted) if accepted <= remaining.len() => accepted, + Ok(_) | Err(_) => return Err(DeliveryError::Transport), + }; + let accepted_u64 = + u64::try_from(accepted_size).map_err(|_error| DeliveryError::Transport)?; + if !attempt.account_bytes(accepted_u64, observed_at) { + return Err(DeliveryError::Transport); + } + offset = offset + .checked_add(accepted_size) + .ok_or(DeliveryError::Transport)?; + } + Ok(()) +} + +fn ensure_deadline(deadline: Deadline, clock: &MonotonicClock) -> Result<(), DeliveryError> { + if deadline.is_expired_at(clock.now()) { + Err(DeliveryError::Deadline) + } else { + Ok(()) + } +} + +fn abandon(sink: &mut Sink) +where + Sink: EgressSink, +{ + if sink.abandon().is_err() { + log::error!("response-egress Fastly body abandon failed"); + } +} + +fn precommit_failure( + kind: TransmitKind, + attempt: &mut ResponseEgressAttempt, + clock: &MonotonicClock, + cause: ResponseEgressOutcome, +) -> Result<(), TransmitFailure> { + let error = DeliveryError::from_outcome(cause); + if kind == TransmitKind::Fallback { + attempt.finish_fallback(ResponseEgressFallbackDisposition::Aborted, clock.now()); } + Err(TransmitFailure::Precommit { cause, error }) +} - // `append_header` preserves multi-value headers (e.g. N `Set-Cookie`). The - // response starts empty (`from_status`) and `http::HeaderMap` iteration - // yields one entry per value, so appending is unconditionally correct. - for (name, value) in &parts.headers { - fastly_response.append_header(name.as_str(), value.as_bytes()); +fn settle_failure( + kind: TransmitKind, + attempt: &mut ResponseEgressAttempt, + outcome: ResponseEgressOutcome, + observed_at: MonotonicInstant, +) { + match kind { + TransmitKind::Application => { + attempt.terminate(outcome, observed_at); + } + TransmitKind::Fallback => { + attempt.finish_fallback(ResponseEgressFallbackDisposition::Aborted, observed_at); + } } +} - Ok(fastly_response) +fn settle_success( + kind: TransmitKind, + attempt: &mut ResponseEgressAttempt, + observed_at: MonotonicInstant, +) { + match kind { + TransmitKind::Application => { + attempt.complete(observed_at); + } + TransmitKind::Fallback => { + attempt.finish_fallback(ResponseEgressFallbackDisposition::Completed, observed_at); + } + } } pub(crate) fn parse_uri(uri: &str) -> Result { @@ -42,11 +610,220 @@ pub(crate) fn parse_uri(uri: &str) -> Result { #[cfg(test)] mod tests { - use super::*; + use std::cell::{Cell, RefCell}; + use std::collections::VecDeque; + use std::rc::Rc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::task::Poll; + use std::time::Duration; + use bytes::Bytes; - use edgezero_core::body::Body; - use edgezero_core::http::response_builder; - use futures_util::stream; + use edgezero_core::app::App; + use edgezero_core::context::RequestContext; + use edgezero_core::http::{Version, request_builder, response_builder}; + use edgezero_core::ingress::{IngressDispatchOutcome, IngressFraming, IngressHeadAccounting}; + use edgezero_core::response_egress::{ + ResponseEgressBodyKind, ResponseEgressCompletion, ResponseEgressHead, + ResponseEgressObserver, ResponseEgressObserverHandle, ResponseEgressReport, + }; + use edgezero_core::router::RouterService; + use edgezero_core::time::MonotonicInstant; + use futures_util::stream::{self, poll_fn}; + + use super::*; + + #[derive(Clone, Default)] + struct RecordingObserver(Arc>>); + + impl RecordingObserver { + fn reports(&self) -> Vec { + self.0.lock().expect("reports lock").clone() + } + } + + impl ResponseEgressObserver for RecordingObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.0.lock().expect("reports lock").push(report.clone()); + } + } + + #[derive(Default)] + struct SinkState { + abandon: MockTerminal, + accepted: Vec, + commits: Vec<(StatusCode, HeaderMap, bool)>, + events: Vec<&'static str>, + finish: MockTerminal, + write_plan: VecDeque>, + } + + struct MockSink(Rc>); + + #[derive(Clone, Copy, Default)] + enum MockTerminal { + Error, + #[default] + Success, + } + + impl EgressSink for MockSink { + type Error = (); + + fn abandon(&mut self) -> Result<(), Self::Error> { + let mut state = self.0.borrow_mut(); + state.events.push("abandon"); + match state.abandon { + MockTerminal::Error => Err(()), + MockTerminal::Success => Ok(()), + } + } + + fn finish(&mut self) -> Result<(), Self::Error> { + let mut state = self.0.borrow_mut(); + state.events.push("finish"); + match state.finish { + MockTerminal::Error => Err(()), + MockTerminal::Success => Ok(()), + } + } + + fn write(&mut self, bytes: &[u8]) -> Result { + let mut state = self.0.borrow_mut(); + state.events.push("write"); + let result = state.write_plan.pop_front().unwrap_or(Ok(bytes.len())); + if let Ok(accepted) = result + && accepted <= bytes.len() + { + state.accepted.extend_from_slice(&bytes[..accepted]); + } + result + } + } + + struct MockCommitter { + commit_plan: VecDeque, + prepare_plan: VecDeque>, + state: Rc>, + } + + enum MockCommit { + Accept, + Error, + } + + impl MockCommitter { + fn new(state: Rc>) -> Self { + Self { + commit_plan: VecDeque::new(), + prepare_plan: VecDeque::new(), + state, + } + } + } + + impl EgressCommitter for MockCommitter { + type Error = (); + type Prepared = (StatusCode, HeaderMap, bool); + type Sink = MockSink; + + fn commit(&mut self, prepared: Self::Prepared) -> Result { + self.state.borrow_mut().commits.push(prepared); + match self.commit_plan.pop_front().unwrap_or(MockCommit::Accept) { + MockCommit::Accept => Ok(MockSink(Rc::clone(&self.state))), + MockCommit::Error => Err(()), + } + } + + fn prepare( + &mut self, + status: StatusCode, + headers: &HeaderMap, + transmits_body: bool, + ) -> Result { + self.prepare_plan.pop_front().unwrap_or(Ok(()))?; + Ok((status, headers.clone(), transmits_body)) + } + } + + fn response_egress(outcome: IngressDispatchOutcome) -> ResponseEgressEnvelope { + let IngressDispatchOutcome::Response(envelope) = outcome else { + panic!("expected response egress"); + }; + *envelope + } + + fn stable_clock(now: MonotonicInstant) -> MonotonicClock { + MonotonicClock::new(move || now) + } + + fn attempt(observer: &RecordingObserver, now: MonotonicInstant) -> ResponseEgressAttempt { + let headers = HeaderMap::new(); + let head = + ResponseEgressHead::new(StatusCode::OK, Version::HTTP_11, &headers, None, now, None); + ResponseEgressAttempt::new( + &head, + now, + ResponseEgressCompletion::empty(), + ResponseEgressObserverHandle::new(observer.clone()), + MonotonicClock::default(), + ) + } + + fn future_policy(now: MonotonicInstant) -> ResponseEgressPolicy { + ResponseEgressPolicy { + write_deadline: Deadline::at_instant( + now.checked_add(Duration::from_secs(10)).expect("deadline"), + ), + } + } + + fn prepared(body: Body) -> PreparedResponseEgress { + prepare_response_egress( + &Method::GET, + response_builder() + .status(StatusCode::OK) + .body(body) + .expect("response"), + ) + .expect("prepared response") + } + + fn transmit( + body: Body, + state: Rc>, + ) -> (Result<(), TransmitFailure>, Vec) { + let now = MonotonicInstant::now(); + let clock = stable_clock(now); + let observer = RecordingObserver::default(); + let mut attempt = attempt(&observer, now); + let mut committer = MockCommitter::new(state); + let result = transmit_prepared( + prepared(body), + future_policy(now), + &mut attempt, + &clock, + TransmitKind::Application, + &mut committer, + ); + drop(attempt); + (result, observer.reports()) + } + + async fn invalid_length_response(_context: RequestContext) -> Result { + response_builder() + .status(StatusCode::OK) + .header("content-length", "2") + .body(Body::from("x")) + .map_err(EdgeError::internal) + } + + async fn ordinary_response(_context: RequestContext) -> Result { + response_builder() + .status(StatusCode::OK) + .body(Body::from("application")) + .map_err(EdgeError::internal) + } #[test] fn parse_valid_uri() { @@ -61,36 +838,730 @@ mod tests { } #[test] - fn multi_value_set_cookie_survives_conversion() { - // http::response::Builder::header APPENDS, so this is two Set-Cookie values. + #[cfg(all(feature = "fastly", target_arch = "wasm32"))] + fn raw_abi_commits_repeated_set_cookie_and_streams_body() { + let mut headers = HeaderMap::new(); + headers.append("set-cookie", "a=1".parse().expect("header")); + headers.append("set-cookie", "b=2".parse().expect("header")); + let mut committer = platform::FastlyCommitter; + let prepared = committer + .prepare(StatusCode::OK, &headers, true) + .expect("prepare raw response"); + let mut sink = committer.commit(prepared).expect("commit raw response"); + assert_eq!(sink.write(b"raw-egress"), Ok(10)); + assert_eq!(sink.finish(), Ok(())); + } + + #[test] + fn streams_empty_once_and_multiple_chunks_with_short_writes() { + for (body, plan, expected) in [ + (Body::empty(), VecDeque::new(), b"".as_slice()), + ( + Body::from("hello"), + VecDeque::from([Ok(2), Ok(3)]), + b"hello".as_slice(), + ), + ( + Body::stream(stream::iter([ + Bytes::from_static(b"hello "), + Bytes::from_static(b"world"), + ])), + VecDeque::new(), + b"hello world".as_slice(), + ), + ] { + let state = Rc::new(RefCell::new(SinkState { + write_plan: plan, + ..SinkState::default() + })); + let (result, reports) = transmit(body, Rc::clone(&state)); + assert_eq!(result, Ok(())); + assert_eq!(state.borrow().accepted, expected); + assert_eq!(state.borrow().events.last(), Some(&"finish")); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::Completed); + assert_eq!( + reports[0].bytes_written, + u64::try_from(expected.len()).expect("body length") + ); + } + } + + #[test] + fn zero_write_write_error_source_error_and_finish_error_abort_once() { + let cases = [ + ( + Body::from("x"), + VecDeque::from([Ok(0)]), + false, + ResponseEgressOutcome::TransportError, + ), + ( + Body::from("x"), + VecDeque::from([Err(())]), + false, + ResponseEgressOutcome::TransportError, + ), + ( + Body::from_stream(stream::iter([Err(EdgeError::internal(anyhow::anyhow!( + "private source detail" + )))])), + VecDeque::new(), + false, + ResponseEgressOutcome::SourceError, + ), + ( + Body::empty(), + VecDeque::new(), + true, + ResponseEgressOutcome::TransportError, + ), + ]; + for (body, write_plan, finish_error, expected) in cases { + let state = Rc::new(RefCell::new(SinkState { + abandon: MockTerminal::Error, + finish: if finish_error { + MockTerminal::Error + } else { + MockTerminal::Success + }, + write_plan, + ..SinkState::default() + })); + let (result, reports) = transmit(body, Rc::clone(&state)); + assert!(matches!(result, Err(TransmitFailure::Postcommit(_)))); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, expected); + let expected_terminal = if finish_error { "finish" } else { "abandon" }; + assert_eq!(state.borrow().events.last(), Some(&expected_terminal)); + } + } + + #[test] + fn accepted_prefix_at_deadline_is_accounted_before_deadline_wins() { + let start = MonotonicInstant::now(); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let reads = Arc::new(AtomicUsize::new(0)); + let clock_reads = Arc::clone(&reads); + let clock = MonotonicClock::new(move || { + if clock_reads.fetch_add(1, Ordering::SeqCst) < 4 { + start + } else { + deadline + } + }); + let report_observer = RecordingObserver::default(); + let mut attempt = attempt(&report_observer, start); + let state = Rc::new(RefCell::new(SinkState::default())); + let mut committer = MockCommitter::new(Rc::clone(&state)); + let result = transmit_prepared( + prepared(Body::from("abc")), + ResponseEgressPolicy { + write_deadline: Deadline::at_instant(deadline), + }, + &mut attempt, + &clock, + TransmitKind::Application, + &mut committer, + ); + assert_eq!( + result, + Err(TransmitFailure::Postcommit(DeliveryError::Deadline)) + ); + let reports = report_observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + assert_eq!(reports[0].bytes_written, 3); + } + + #[test] + fn deadline_at_commit_abandons_without_polling_or_writing() { + let start = MonotonicInstant::now(); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let reads = Arc::new(AtomicUsize::new(0)); + let clock_reads = Arc::clone(&reads); + let clock = MonotonicClock::new(move || { + if clock_reads.fetch_add(1, Ordering::SeqCst) < 2 { + start + } else { + deadline + } + }); + let polls = Rc::new(Cell::new(0_usize)); + let observed_polls = Rc::clone(&polls); + let body = Body::from_stream(poll_fn(move |_| { + observed_polls.set(observed_polls.get() + 1); + Poll::Pending + })); + let report_observer = RecordingObserver::default(); + let mut attempt = attempt(&report_observer, start); + let state = Rc::new(RefCell::new(SinkState::default())); + let mut committer = MockCommitter::new(Rc::clone(&state)); + let result = transmit_prepared( + prepared(body), + ResponseEgressPolicy { + write_deadline: Deadline::at_instant(deadline), + }, + &mut attempt, + &clock, + TransmitKind::Application, + &mut committer, + ); + + assert_eq!( + result, + Err(TransmitFailure::Postcommit(DeliveryError::Deadline)) + ); + assert_eq!(polls.get(), 0); + assert!(state.borrow().accepted.is_empty()); + assert_eq!(state.borrow().events, ["abandon"]); + assert_eq!( + report_observer.reports()[0].outcome, + ResponseEgressOutcome::DeadlineExceeded + ); + } + + #[test] + fn deadline_after_head_preparation_prevents_irreversible_commit() { + let start = MonotonicInstant::now(); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let reads = Arc::new(AtomicUsize::new(0)); + let clock_reads = Arc::clone(&reads); + let clock = MonotonicClock::new(move || { + if clock_reads.fetch_add(1, Ordering::SeqCst) == 0 { + start + } else { + deadline + } + }); + let report_observer = RecordingObserver::default(); + let mut attempt = attempt(&report_observer, start); + let state = Rc::new(RefCell::new(SinkState::default())); + let mut committer = MockCommitter::new(Rc::clone(&state)); + + let result = transmit_prepared( + prepared(Body::from("uncommitted")), + ResponseEgressPolicy { + write_deadline: Deadline::at_instant(deadline), + }, + &mut attempt, + &clock, + TransmitKind::Application, + &mut committer, + ); + + assert_eq!( + result, + Err(TransmitFailure::Precommit { + cause: ResponseEgressOutcome::DeadlineExceeded, + error: DeliveryError::Deadline, + }) + ); + assert!(state.borrow().commits.is_empty()); + assert!(report_observer.reports().is_empty()); + } + + #[test] + fn deadline_at_source_poll_abandons_before_writing_the_chunk() { + let start = MonotonicInstant::now(); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let reads = Arc::new(AtomicUsize::new(0)); + let clock_reads = Arc::clone(&reads); + let clock = MonotonicClock::new(move || { + if clock_reads.fetch_add(1, Ordering::SeqCst) < 4 { + start + } else { + deadline + } + }); + let polls = Rc::new(Cell::new(0_usize)); + let observed_polls = Rc::clone(&polls); + let body = Body::from_stream(poll_fn(move |_| { + observed_polls.set(observed_polls.get() + 1); + Poll::Ready(Some(Ok(Bytes::from_static(b"late")))) + })); + let report_observer = RecordingObserver::default(); + let mut attempt = attempt(&report_observer, start); + let state = Rc::new(RefCell::new(SinkState::default())); + let mut committer = MockCommitter::new(Rc::clone(&state)); + + let result = transmit_prepared( + prepared(body), + ResponseEgressPolicy { + write_deadline: Deadline::at_instant(deadline), + }, + &mut attempt, + &clock, + TransmitKind::Application, + &mut committer, + ); + + assert_eq!( + result, + Err(TransmitFailure::Postcommit(DeliveryError::Deadline)) + ); + assert_eq!(polls.get(), 1); + assert!(state.borrow().accepted.is_empty()); + assert_eq!(state.borrow().events, ["abandon"]); + let reports = report_observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + assert_eq!(reports[0].bytes_written, 0); + } + + #[test] + fn deadline_at_finish_wins_after_the_handle_is_consumed() { + let start = MonotonicInstant::now(); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let reads = Arc::new(AtomicUsize::new(0)); + let clock_reads = Arc::clone(&reads); + let clock = MonotonicClock::new(move || { + if clock_reads.fetch_add(1, Ordering::SeqCst) < 4 { + start + } else { + deadline + } + }); + let report_observer = RecordingObserver::default(); + let mut attempt = attempt(&report_observer, start); + let state = Rc::new(RefCell::new(SinkState::default())); + let mut committer = MockCommitter::new(Rc::clone(&state)); + + let result = transmit_prepared( + prepared(Body::empty()), + ResponseEgressPolicy { + write_deadline: Deadline::at_instant(deadline), + }, + &mut attempt, + &clock, + TransmitKind::Application, + &mut committer, + ); + + assert_eq!( + result, + Err(TransmitFailure::Postcommit(DeliveryError::Deadline)) + ); + assert_eq!(state.borrow().events, ["finish"]); + let reports = report_observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + } + + #[test] + fn head_suppression_releases_source_without_polling() { + struct DropSignal(Rc>); + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.set(self.0.get() + 1); + } + } + + let polls = Rc::new(Cell::new(0_usize)); + let drops = Rc::new(Cell::new(0_usize)); + let observed_polls = Rc::clone(&polls); + let signal = DropSignal(Rc::clone(&drops)); + let body = Body::from_stream(poll_fn(move |_| { + let _keep_alive = &signal; + observed_polls.set(observed_polls.get() + 1); + Poll::Pending + })); let response = response_builder() - .status(200) - .header("set-cookie", "a=1") - .header("set-cookie", "b=2") - .body(Body::empty()) + .status(StatusCode::OK) + .header("content-length", "99") + .body(body) .expect("response"); + let prepared = prepare_response_egress(&Method::HEAD, response).expect("prepared"); + let start = MonotonicInstant::now(); + let clock = stable_clock(start); + let observer = RecordingObserver::default(); + let mut attempt = attempt(&observer, start); + let state = Rc::new(RefCell::new(SinkState::default())); + let mut committer = MockCommitter::new(Rc::clone(&state)); + let result = transmit_prepared( + prepared, + future_policy(start), + &mut attempt, + &clock, + TransmitKind::Application, + &mut committer, + ); + assert_eq!(result, Ok(())); + assert_eq!(polls.get(), 0); + assert_eq!(drops.get(), 1); + assert!(state.borrow().accepted.is_empty()); + assert_eq!(observer.reports()[0].bytes_written, 0); + } - let fastly_response = from_core_response(response).expect("fastly response"); + #[test] + fn body_suppressed_response_disables_manual_framing() { + for (method, status) in [ + (Method::HEAD, StatusCode::OK), + (Method::GET, StatusCode::NOT_MODIFIED), + ] { + let response = response_builder() + .status(status) + .header("content-length", "99") + .body(Body::from("suppressed")) + .expect("response"); + let prepared = prepare_response_egress(&method, response).expect("prepared"); + let now = MonotonicInstant::now(); + let clock = stable_clock(now); + let observer = RecordingObserver::default(); + let mut attempt = attempt(&observer, now); + let state = Rc::new(RefCell::new(SinkState::default())); + let mut committer = MockCommitter::new(Rc::clone(&state)); + + let result = transmit_prepared( + prepared, + future_policy(now), + &mut attempt, + &clock, + TransmitKind::Application, + &mut committer, + ); - let cookies: Vec = fastly_response - .get_header_all("set-cookie") - .map(|value| value.to_str().expect("utf8").to_owned()) - .collect(); - assert_eq!(cookies, vec!["a=1".to_owned(), "b=2".to_owned()]); + assert_eq!(result, Ok(())); + let observed_state = state.borrow(); + let (committed_status, headers, transmits_body) = &observed_state.commits[0]; + assert_eq!(*committed_status, status); + assert_eq!(headers.get("content-length").expect("length"), "99"); + assert!(!transmits_body); + assert!(observed_state.accepted.is_empty()); + } } #[test] - fn stream_body_is_written_to_fastly_response() { + fn payload_response_allows_manual_framing() { let response = response_builder() - .status(200) - .body(Body::stream(stream::iter(vec![ - Bytes::from_static(b"hello "), - Bytes::from_static(b"world"), - ]))) + .status(StatusCode::OK) + .header("content-length", "3") + .body(Body::from("abc")) .expect("response"); + let prepared = prepare_response_egress(&Method::GET, response).expect("prepared"); + let now = MonotonicInstant::now(); + let clock = stable_clock(now); + let observer = RecordingObserver::default(); + let mut attempt = attempt(&observer, now); + let state = Rc::new(RefCell::new(SinkState::default())); + let mut committer = MockCommitter::new(Rc::clone(&state)); + + let result = transmit_prepared( + prepared, + future_policy(now), + &mut attempt, + &clock, + TransmitKind::Application, + &mut committer, + ); + + assert_eq!(result, Ok(())); + let observed_state = state.borrow(); + let (_, headers, transmits_body) = &observed_state.commits[0]; + assert_eq!(headers.get("content-length").expect("length"), "3"); + assert!(*transmits_body); + assert_eq!(observed_state.accepted, b"abc"); + } + + #[test] + fn framing_failure_sends_bounded_fallback_with_original_cause() { + let mut app = App::new( + RouterService::builder() + .get("/", invalid_length_response) + .build(), + ); + let observer = RecordingObserver::default(); + app.set_response_egress_observer(observer.clone()); + let request_start = app.monotonic_now(); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let egress = response_egress( + executor::block_on(app.dispatch_ingress( + request, + request_start, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("egress"), + ); + let state = Rc::new(RefCell::new(SinkState::default())); + let mut committer = MockCommitter::new(Rc::clone(&state)); + assert_eq!(send_egress_with(egress, &mut committer), Ok(())); + + let snapshot = state.borrow(); + assert_eq!(snapshot.commits.len(), 1); + assert_eq!(snapshot.commits[0].0, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(snapshot.accepted, INTERNAL_FALLBACK_BODY); + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::ConversionError); + assert_eq!(reports[0].body_kind, ResponseEgressBodyKind::Fallback); + assert_eq!( + reports[0].fallback_disposition, + Some(ResponseEgressFallbackDisposition::Completed) + ); + } + + #[test] + fn expired_application_deadline_uses_independent_fallback_budget() { + let mut app = App::new(RouterService::builder().get("/", ordinary_response).build()); + let now = MonotonicInstant::now(); + app.set_monotonic_clock(stable_clock(now)); + app.set_response_egress_policy(move |_, _| ResponseEgressPolicy { + write_deadline: Deadline::at_instant(now), + }); + let observer = RecordingObserver::default(); + app.set_response_egress_observer(observer.clone()); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let egress = response_egress( + executor::block_on(app.dispatch_ingress( + request, + now, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("egress"), + ); + let state = Rc::new(RefCell::new(SinkState::default())); + let mut committer = MockCommitter::new(Rc::clone(&state)); + assert_eq!(send_egress_with(egress, &mut committer), Ok(())); + assert_eq!(state.borrow().commits[0].0, StatusCode::GATEWAY_TIMEOUT); + assert_eq!(state.borrow().accepted, TIMEOUT_FALLBACK_BODY); + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + assert_eq!(reports[0].body_kind, ResponseEgressBodyKind::Fallback); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn head_fallback_suppresses_payload_and_policy_panic_stays_one_attempt() { + let mut app = App::new( + RouterService::builder() + .route("/", Method::HEAD, ordinary_response) + .build(), + ); + app.set_response_egress_policy(|_, _| panic!("private policy payload")); + let observer = RecordingObserver::default(); + app.set_response_egress_observer(observer.clone()); + let request_start = app.monotonic_now(); + let request = request_builder() + .method(Method::HEAD) + .uri("/") + .body(Body::empty()) + .expect("request"); + let egress = response_egress( + executor::block_on(app.dispatch_ingress( + request, + request_start, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("egress"), + ); + let state = Rc::new(RefCell::new(SinkState::default())); + let mut committer = MockCommitter::new(Rc::clone(&state)); + assert_eq!(send_egress_with(egress, &mut committer), Ok(())); + assert!(state.borrow().accepted.is_empty()); + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].body_kind, ResponseEgressBodyKind::Fallback); + assert_eq!(reports[0].bytes_written, 0); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::ConversionError); + assert_eq!( + reports[0].fallback_disposition, + Some(ResponseEgressFallbackDisposition::Completed) + ); + } + + #[test] + fn fallback_safety_deadline_aborts_and_preserves_original_cause() { + let mut app = App::new( + RouterService::builder() + .get("/", invalid_length_response) + .build(), + ); + let start = MonotonicInstant::now(); + let safety_deadline = start + .checked_add(RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET) + .expect("safety deadline"); + let reads = Arc::new(AtomicUsize::new(0)); + let clock_reads = Arc::clone(&reads); + app.set_monotonic_clock(MonotonicClock::new(move || { + if clock_reads.fetch_add(1, Ordering::SeqCst) < 4 { + start + } else { + safety_deadline + } + })); + let observer = RecordingObserver::default(); + app.set_response_egress_observer(observer.clone()); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let egress = response_egress( + executor::block_on(app.dispatch_ingress( + request, + start, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("egress"), + ); + let state = Rc::new(RefCell::new(SinkState::default())); + let mut committer = MockCommitter::new(Rc::clone(&state)); + assert_eq!( + send_egress_with(egress, &mut committer), + Err(DeliveryError::Deadline) + ); + assert!(state.borrow().accepted.is_empty()); + assert_eq!(state.borrow().events.last(), Some(&"abandon")); + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::ConversionError); + assert_eq!(reports[0].body_kind, ResponseEgressBodyKind::Fallback); + assert_eq!( + reports[0].fallback_disposition, + Some(ResponseEgressFallbackDisposition::Aborted) + ); + } + + #[test] + fn fallback_partial_write_and_finish_failure_preserve_conversion_cause() { + for finish_error in [false, true] { + let mut app = App::new( + RouterService::builder() + .get("/", invalid_length_response) + .build(), + ); + let observer = RecordingObserver::default(); + app.set_response_egress_observer(observer.clone()); + let request_start = app.monotonic_now(); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let egress = response_egress( + executor::block_on(app.dispatch_ingress( + request, + request_start, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("egress"), + ); + let state = Rc::new(RefCell::new(SinkState { + finish: if finish_error { + MockTerminal::Error + } else { + MockTerminal::Success + }, + write_plan: if finish_error { + VecDeque::new() + } else { + VecDeque::from([Ok(3), Err(())]) + }, + ..SinkState::default() + })); + let mut committer = MockCommitter::new(Rc::clone(&state)); + assert!(send_egress_with(egress, &mut committer).is_err()); + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::ConversionError); + assert_eq!(reports[0].body_kind, ResponseEgressBodyKind::Fallback); + assert_eq!( + reports[0].fallback_disposition, + Some(ResponseEgressFallbackDisposition::Aborted) + ); + if !finish_error { + assert_eq!(reports[0].bytes_written, 3); + } + } + } + + #[test] + fn head_preparation_error_uses_one_successful_fallback_attempt() { + let mut app = App::new(RouterService::builder().get("/", ordinary_response).build()); + let observer = RecordingObserver::default(); + app.set_response_egress_observer(observer.clone()); + let request_start = app.monotonic_now(); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let egress = response_egress( + executor::block_on(app.dispatch_ingress( + request, + request_start, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("egress"), + ); + let state = Rc::new(RefCell::new(SinkState::default())); + let mut committer = MockCommitter { + commit_plan: VecDeque::new(), + prepare_plan: VecDeque::from([Err(()), Ok(())]), + state: Rc::clone(&state), + }; + assert_eq!(send_egress_with(egress, &mut committer), Ok(())); + assert_eq!(state.borrow().commits.len(), 1); + assert_eq!( + state.borrow().commits[0].0, + StatusCode::INTERNAL_SERVER_ERROR + ); + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::ConversionError); + assert_eq!(reports[0].body_kind, ResponseEgressBodyKind::Fallback); + assert_eq!( + reports[0].fallback_disposition, + Some(ResponseEgressFallbackDisposition::Completed) + ); + } + + #[test] + fn downstream_commit_error_is_postcommit_and_does_not_send_fallback() { + let now = MonotonicInstant::now(); + let clock = stable_clock(now); + let observer = RecordingObserver::default(); + let mut attempt = attempt(&observer, now); + let state = Rc::new(RefCell::new(SinkState::default())); + let mut committer = MockCommitter { + commit_plan: VecDeque::from([MockCommit::Error]), + prepare_plan: VecDeque::new(), + state: Rc::clone(&state), + }; + + let result = transmit_prepared( + prepared(Body::from("application")), + future_policy(now), + &mut attempt, + &clock, + TransmitKind::Application, + &mut committer, + ); - let mut fastly_response = from_core_response(response).expect("fastly response"); - let body_bytes = fastly_response.take_body_bytes(); - assert_eq!(body_bytes, b"hello world"); + assert_eq!( + result, + Err(TransmitFailure::Postcommit(DeliveryError::Transport)) + ); + assert_eq!(state.borrow().commits.len(), 1); + assert_eq!(observer.reports().len(), 1); + assert_eq!( + observer.reports()[0].outcome, + ResponseEgressOutcome::TransportError + ); } } diff --git a/crates/edgezero-adapter-fastly/src/secret_store.rs b/crates/edgezero-adapter-fastly/src/secret_store.rs index e83b2b337..f8eca569b 100644 --- a/crates/edgezero-adapter-fastly/src/secret_store.rs +++ b/crates/edgezero-adapter-fastly/src/secret_store.rs @@ -4,23 +4,52 @@ //! `FastlySecretStore`, which opens a named Fastly `SecretStore` on //! each lookup. -#[cfg(feature = "fastly")] +use crate::chunked_config::{SyncHostCallError, exact_fastly_read, run_sync_host_call}; use async_trait::async_trait; -#[cfg(feature = "fastly")] use bytes::Bytes; -#[cfg(feature = "fastly")] +use edgezero_core::config_store::BoundedStoreRead; use edgezero_core::secret_store::{SecretError, SecretStore}; -#[cfg(feature = "fastly")] +use edgezero_core::{Deadline, MonotonicClock}; use fastly::secret_store::SecretStore as FastlyNativeSecretStore; /// Internal helper that opens a single named Fastly `SecretStore`. -#[cfg(feature = "fastly")] pub struct FastlyNamedStore { store: FastlyNativeSecretStore, } -#[cfg(feature = "fastly")] impl FastlyNamedStore { + fn get_bytes_bounded_sync( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + let lookup = run_sync_host_call(clock, deadline, || { + self.store.try_get(key).map_err(|err| { + SecretError::Internal(anyhow::anyhow!("secret lookup failed: {err}")) + }) + }) + .map_err(map_sync_secret_error)?; + + let Some(secret) = lookup else { + return Ok(BoundedStoreRead { + backend_bytes: 0, + value: None, + }); + }; + let plaintext = run_sync_host_call(clock, deadline, || { + secret.try_plaintext().map_err(|err| { + SecretError::Internal(anyhow::anyhow!("secret decryption failed: {err}")) + }) + }) + .map_err(map_sync_secret_error)?; + + exact_fastly_read(Some(plaintext), max_backend_bytes.min(max_value_bytes)) + .map_err(|_size_error| SecretError::ValueTooLarge) + } + pub(crate) fn get_bytes_sync(&self, key: &str) -> Result, SecretError> { let lookup = self .store @@ -53,16 +82,22 @@ impl FastlyNamedStore { })?; Ok(Self { store }) } + + fn open_bounded( + name: &str, + clock: &MonotonicClock, + deadline: Deadline, + ) -> Result { + run_sync_host_call(clock, deadline, || Self::open(name)).map_err(map_sync_secret_error) + } } /// Multi-store provider backed by Fastly's `SecretStore` API. /// /// Opens the named store per call — `FastlyNamedStore::open` is cheap /// (no network; just a handle) so there is no caching. -#[cfg(feature = "fastly")] pub struct FastlySecretStore; -#[cfg(feature = "fastly")] #[async_trait(?Send)] impl SecretStore for FastlySecretStore { #[inline] @@ -70,8 +105,70 @@ impl SecretStore for FastlySecretStore { let store = FastlyNamedStore::open(store_name)?; store.get_bytes_sync(key) } + + #[inline] + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + let store = FastlyNamedStore::open_bounded(store_name, clock, deadline)?; + store.get_bytes_bounded_sync(key, clock, deadline, max_backend_bytes, max_value_bytes) + } +} + +fn map_sync_secret_error(call_error: SyncHostCallError) -> SecretError { + match call_error { + SyncHostCallError::Backend(backend_error) => backend_error, + SyncHostCallError::DeadlineExceeded => SecretError::DeadlineExceeded, + } } // TODO: integration tests require the Fastly compute environment. // Test `FastlyNamedStore` and `FastlySecretStore` as part of the // Fastly adapter E2E test suite. + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use edgezero_core::{Deadline, MonotonicClock, MonotonicInstant}; + + use super::*; + + #[test] + fn bounded_secret_read_uses_injected_clock() { + let process_now = MonotonicInstant::now(); + let injected_now = process_now + .checked_sub(Duration::from_mins(1)) + .expect("injected instant"); + let clock = MonotonicClock::new(move || injected_now); + let deadline = Deadline::at_instant( + injected_now + .checked_add(Duration::from_secs(1)) + .expect("deadline instant"), + ); + let value = run_sync_host_call(&clock, deadline, || Ok::<_, SecretError>("value")) + .expect("the application clock is still before its deadline"); + assert_eq!(value, "value"); + + let start = MonotonicInstant::now(); + let terminal = start + .checked_add(Duration::from_secs(1)) + .expect("terminal instant"); + let now = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&now); + let advancing_clock = MonotonicClock::new(move || *clock_now.lock().expect("clock lock")); + let error = run_sync_host_call(&advancing_clock, Deadline::at_instant(terminal), || { + *now.lock().expect("clock lock") = terminal; + Err::<(), _>(SecretError::Unavailable) + }) + .expect_err("equality expiry must beat a ready provider error"); + assert!(matches!(error, SyncHostCallError::DeadlineExceeded)); + } +} diff --git a/crates/edgezero-adapter-fastly/src/templates/src/main.rs.hbs b/crates/edgezero-adapter-fastly/src/templates/src/main.rs.hbs index 42d97d16f..0b7c16ce8 100644 --- a/crates/edgezero-adapter-fastly/src/templates/src/main.rs.hbs +++ b/crates/edgezero-adapter-fastly/src/templates/src/main.rs.hbs @@ -4,12 +4,8 @@ )] #[cfg(target_arch = "wasm32")] -use fastly::{Error, Request, Response}; - -#[cfg(target_arch = "wasm32")] -#[fastly::main] -pub fn main(req: Request) -> Result { - edgezero_adapter_fastly::run_app::<{{proj_core_mod}}::App>(req) +pub fn main() -> Result<(), fastly::Error> { + edgezero_adapter_fastly::run_app::<{{proj_core_mod}}::App>() } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/edgezero-adapter-fastly/tests/contract.rs b/crates/edgezero-adapter-fastly/tests/contract.rs index 72b207ef9..842aeee0d 100644 --- a/crates/edgezero-adapter-fastly/tests/contract.rs +++ b/crates/edgezero-adapter-fastly/tests/contract.rs @@ -1,6 +1,5 @@ -#![cfg(all(feature = "fastly", target_arch = "wasm32"))] - // Compile-time check: FastlySecretStore implements SecretStore. +#[cfg(all(feature = "fastly", target_arch = "wasm32"))] mod secret_store_compile_check { use edgezero_adapter_fastly::secret_store::FastlySecretStore; use edgezero_core::secret_store::SecretStore; @@ -13,38 +12,105 @@ mod secret_store_compile_check { } #[cfg(test)] +#[cfg(all(feature = "fastly", feature = "test-utils", target_arch = "wasm32"))] +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "ingress contracts are grouped after the provider fixture tests" +)] mod tests { + use std::io::Cursor; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use bytes::Bytes; + use edgezero_adapter_fastly::build_app_for_test; use edgezero_adapter_fastly::context::FastlyRequestContext; - use edgezero_adapter_fastly::request::{ - FastlyService, into_core_request, into_core_request_with_registries, - }; - use edgezero_adapter_fastly::response::from_core_response; - use edgezero_core::app::App; + use edgezero_adapter_fastly::request::{FastlyService, into_core_request}; + use edgezero_core::app::{App, Hooks}; use edgezero_core::body::Body; - use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::config_store::{ + BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigStoreHandle, + finish_bounded_config_read, + }; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{Method, Response, StatusCode, response_builder}; - use edgezero_core::router::RouterService; + use edgezero_core::response_egress::ResponseEgressEnvelope; + use edgezero_core::router::{RouteMetadata, RouterService}; + use edgezero_core::time::{Deadline, MonotonicClock}; use fastly::Request as FastlyRequest; - use fastly::http::{Method as FastlyMethod, StatusCode as FastlyStatus}; - use futures::executor::block_on; - use futures::stream; - use std::sync::Arc; + use fastly::http::Method as FastlyMethod; + use futures::{executor::block_on, stream}; + + fn complete_envelope(envelope: ResponseEgressEnvelope) -> Response { + let Ok((prepared, _, mut attempt, clock)) = envelope.begin() else { + panic!("begin response egress"); + }; + assert!(attempt.begin_writing()); + assert!(attempt.complete(clock.now())); + prepared.into_response() + } struct FixedConfigStore(&'static str); + struct FailingConfiguration; + + #[expect( + clippy::missing_trait_methods, + reason = "test hook exercises only adapter startup failure" + )] + impl Hooks for FailingConfiguration { + fn configure(_app: &mut App) -> Result<(), EdgeError> { + Err(EdgeError::service_unavailable("configuration unavailable")) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[test] + fn failing_configuration() { + let Err(error) = build_app_for_test::() else { + panic!("configuration must fail before request receive"); + }; + assert_eq!(error.to_string(), "application configuration failed"); + assert!(format!("{error:#}").contains("configuration unavailable")); + } + #[async_trait::async_trait(?Send)] impl ConfigStore for FixedConfigStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.to_owned())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.into())) + } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + finish_bounded_config_read( + self.get(key).await, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) } } fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { - let body = Body::text(ctx.request().uri().to_string()); + let body = Body::text(ctx.uri().to_string()); let response = response_builder() .status(StatusCode::OK) .body(body) @@ -53,7 +119,7 @@ mod tests { } async fn mirror_body(ctx: RequestContext) -> Result { - let bytes = ctx.request().body().as_bytes().expect("buffered").to_vec(); + let bytes = ctx.body_bytes(1024 * 1024).await?.to_vec(); let response = response_builder() .status(StatusCode::OK) .body(Body::from(bytes)) @@ -84,7 +150,7 @@ mod tests { .await .ok() .flatten() - .unwrap_or_else(|| "missing".to_owned()), + .map_or_else(|| "missing".to_owned(), |value| value.to_string()), None => "missing".to_owned(), }; let response = response_builder() @@ -135,113 +201,1136 @@ mod tests { Some("1") ); - assert_eq!( - core_request.body().as_bytes().expect("buffered"), - b"payload" - ); - let context = FastlyRequestContext::get(&core_request).expect("context"); assert_eq!(context.client_ip, expected_ip); + + let body = + block_on(core_request.into_body().into_bytes_bounded(1024)).expect("request body"); + assert_eq!(body.as_ref(), b"payload"); } #[test] - fn registry_conversion_preserves_extensions_without_collecting_a_response() { - use edgezero_core::app::{StoreMetadata, StoresMetadata}; - use edgezero_core::store_registry::SecretRegistry; - let request = fastly_request(FastlyMethod::GET, "/stream", None); - let core = into_core_request_with_registries( - request, - StoresMetadata { - secrets: Some(StoreMetadata { - default: "fixture", - ids: &["fixture"], - }), - ..Default::default() - }, - |raw, extensions| { - extensions.insert(raw.get_url_str().to_owned()); - }, - ) - .expect("registry-aware conversion"); - assert!(core.extensions().get::().is_some()); + #[cfg(feature = "test-utils")] + fn dispatch_runs_router_and_returns_response() { + let app = build_test_app(); + let req = fastly_request(FastlyMethod::GET, "/uri", None); + + let response = complete_envelope( + FastlyService::new(&app) + .capture_egress_for_test(req) + .expect("egress envelope"), + ); + + assert_eq!(response.status(), StatusCode::OK); assert_eq!( - core.extensions().get::().unwrap(), - "http://example.com/stream" + block_on(response.into_body().into_bytes_bounded(64)).expect("response body"), + Bytes::from_static(b"http://example.com/uri") ); - let response = block_on(build_test_app().router().oneshot(core)).unwrap(); - assert!(matches!(response.body(), Body::Stream(_))); } #[test] - fn from_core_response_translates_status_headers_and_streaming_body() { - let response = response_builder() - .status(StatusCode::CREATED) - .header("x-edgezero-res", "1") - .body(Body::stream(stream::iter(vec![ - Bytes::from_static(b"hello"), - Bytes::from_static(b" "), - Bytes::from_static(b"world"), - ]))) - .expect("response"); + #[cfg(feature = "test-utils")] + fn dispatch_streaming_route_preserves_chunks() { + let app = build_test_app(); + let req = fastly_request(FastlyMethod::GET, "/stream", None); - let mut fastly_response = from_core_response(response).expect("fastly response"); + let response = complete_envelope( + FastlyService::new(&app) + .capture_egress_for_test(req) + .expect("egress envelope"), + ); - assert_eq!(fastly_response.get_status(), FastlyStatus::CREATED); - assert!(fastly_response.get_header("x-edgezero-res").is_some()); - assert_eq!(fastly_response.take_body_bytes(), b"hello world"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + block_on(response.into_body().into_bytes_bounded(64)).expect("response body"), + Bytes::from_static(b"chunk-1chunk-2") + ); } #[test] - fn dispatch_runs_router_and_returns_response() { - let app = build_test_app(); - let req = fastly_request(FastlyMethod::GET, "/uri", None); + #[cfg(feature = "test-utils")] + fn closed_lifecycle_orders_hooks_and_returns_state_after_delivery() { + #[derive(Clone)] + struct HookValue(&'static str); - let mut response = FastlyService::new(&app) - .dispatch(req) - .expect("fastly response"); + let events = Arc::new(Mutex::new(Vec::new())); + let handler_events = Arc::clone(&events); + let router = RouterService::builder() + .get("/hook", move |ctx: RequestContext| { + let request_events = Arc::clone(&handler_events); + async move { + request_events.lock().expect("events").push("handler"); + let value = ctx + .extensions() + .get::() + .map_or("missing", |value| value.0); + Ok::<_, EdgeError>(value) + } + }) + .build(); + let app = App::new(router); + let request = fastly_request(FastlyMethod::GET, "/ignored", None); + let prepare_events = Arc::clone(&events); + let finalize_events = Arc::clone(&events); + let delivery_events = Arc::clone(&events); - assert_eq!(response.get_status(), FastlyStatus::OK); - assert_eq!(response.take_body_bytes(), b"http://example.com/uri"); + let state = FastlyService::new(&app) + .run_request_with_hooks_for_test( + request, + move |raw, extensions| { + prepare_events.lock().expect("events").push("prepare"); + raw.set_url("http://example.com/hook"); + extensions.insert(HookValue("visible")); + }, + move |response| { + finalize_events.lock().expect("events").push("finalize"); + *response.status_mut() = StatusCode::CREATED; + response + .headers_mut() + .insert("x-finalized", "yes".parse().expect("header")); + 42_u8 + }, + move |envelope| { + delivery_events.lock().expect("events").push("deliver"); + let response = complete_envelope(envelope); + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!( + response.headers().get("x-finalized"), + Some(&"yes".parse().expect("header")) + ); + assert_eq!( + block_on(response.into_body().into_bytes_bounded(64)) + .expect("response body"), + Bytes::from_static(b"visible") + ); + Ok(()) + }, + ) + .expect("closed lifecycle"); + + assert_eq!(state, Some(42)); + assert_eq!( + *events.lock().expect("events"), + ["prepare", "handler", "finalize", "deliver"] + ); } #[test] - fn dispatch_streaming_route_preserves_chunks() { - let app = build_test_app(); - let req = fastly_request(FastlyMethod::GET, "/stream", None); + #[cfg(feature = "test-utils")] + fn detached_ingress_response_skips_routed_response_hook() { + use edgezero_core::ingress::AdmissionDecision; + + let mut app = build_test_app(); + app.set_ingress_admission_policy(|_head| AdmissionDecision::Refuse { + completion: edgezero_core::ResponseEgressCompletion::empty(), + response: response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .body(Body::from("refused")) + .expect("refusal"), + }); + let finalized = Arc::new(AtomicUsize::new(0)); + let finalized_hook = Arc::clone(&finalized); + let delivered = Arc::new(AtomicUsize::new(0)); + let delivered_hook = Arc::clone(&delivered); - let mut response = FastlyService::new(&app) - .dispatch(req) - .expect("fastly response"); + let state = FastlyService::new(&app) + .run_request_with_hooks_for_test( + fastly_request(FastlyMethod::GET, "/uri", None), + |_raw, _extensions| {}, + move |_response| { + finalized_hook.fetch_add(1, Ordering::SeqCst); + 7_u8 + }, + move |_envelope| { + delivered_hook.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .expect("detached lifecycle"); - assert_eq!(response.get_status(), FastlyStatus::OK); - assert_eq!(response.take_body_bytes(), b"chunk-1chunk-2"); + assert_eq!(state, None); + assert_eq!(finalized.load(Ordering::SeqCst), 0); + assert_eq!(delivered.load(Ordering::SeqCst), 1); } #[test] + #[cfg(feature = "test-utils")] fn dispatch_passes_request_body_to_handlers() { let app = build_test_app(); let req = fastly_request(FastlyMethod::POST, "/mirror", Some(b"echo")); - let mut response = FastlyService::new(&app) - .dispatch(req) - .expect("fastly response"); + let response = complete_envelope( + FastlyService::new(&app) + .capture_egress_for_test(req) + .expect("egress envelope"), + ); - assert_eq!(response.get_status(), FastlyStatus::OK); - assert_eq!(response.take_body_bytes(), b"echo"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + block_on(response.into_body().into_bytes_bounded(64)).expect("response body"), + Bytes::from_static(b"echo") + ); } #[test] + #[cfg(feature = "test-utils")] fn service_with_config_handle_injects_handle() { let app = build_test_app(); let req = fastly_request(FastlyMethod::GET, "/config", None); let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("hello from fastly test"))); - let mut response = FastlyService::new(&app) - .with_config_handle(handle) - .dispatch(req) - .expect("fastly response"); + let response = complete_envelope( + FastlyService::new(&app) + .with_config_handle(handle) + .capture_egress_for_test(req) + .expect("egress envelope"), + ); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + block_on(response.into_body().into_bytes_bounded(64)).expect("response body"), + Bytes::from_static(b"hello from fastly test") + ); + } + + #[cfg(feature = "test-utils")] + mod ingress_contract { + use std::collections::VecDeque; + use std::io::{self, Read}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use edgezero_adapter_fastly::request::{ + dispatch_ingress_reader_for_test, dispatch_ingress_source_error_for_test, + dispatch_ingress_with_for_test, + }; + use edgezero_core::http::{HeaderMap, HeaderValue}; + use edgezero_core::ingress::{ + AdmissionDecision, BufferedIngressResponse, IngressGrant, IngressHeadLimits, + }; + use edgezero_core::middleware::{Middleware, Next}; + use edgezero_core::response_egress::{ + DEFAULT_RESPONSE_WRITE_BUDGET, DetachedResponseEgressDecision, + ResponseEgressCompletion, ResponseEgressEnvelope, ResponseEgressObserver, + ResponseEgressOutcome, ResponseEgressReport, + }; + use edgezero_core::router::RouteResolution; + use edgezero_core::time::{MonotonicClock, MonotonicInstant}; + + use super::*; + + #[test] + fn admission_abort() { + let source_calls = Arc::new(AtomicUsize::new(0)); + let observed_source_calls = Arc::clone(&source_calls); + let delivery_calls = Arc::new(AtomicUsize::new(0)); + let observed_delivery_calls = Arc::clone(&delivery_calls); + let mut app = App::new(RouterService::builder().build()); + app.set_ingress_admission_policy(|_| AdmissionDecision::Abort); + + let error = dispatch_ingress_with_for_test( + &app, + Method::POST, + "/missing".parse().expect("URI"), + move || { + observed_source_calls.fetch_add(1, Ordering::SeqCst); + Ok(Cursor::new(Vec::::new())) + }, + move |_response| { + observed_delivery_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .expect_err("abort must return a platform error"); + + assert!(error.to_string().contains("ingress admission aborted")); + assert_eq!(source_calls.load(Ordering::SeqCst), 0); + assert_eq!(delivery_calls.load(Ordering::SeqCst), 0); + } + + struct DropSignal(Arc); + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + struct CountingMiddleware(Arc); + + #[derive(Clone)] + struct RecordingEgressObserver(Arc>>); - assert_eq!(response.get_status(), FastlyStatus::OK); - assert_eq!(response.take_body_bytes(), b"hello from fastly test"); + impl ResponseEgressObserver for RecordingEgressObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.0.lock().expect("reports lock").push(report.clone()); + } + } + + #[async_trait::async_trait(?Send)] + impl Middleware for CountingMiddleware { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } + } + + struct TrackedReader { + body_reads: Arc, + chunks: VecDeque, + expire_on_read: Option<(Arc>, MonotonicInstant)>, + grant_drops: Arc, + _source_drop: DropSignal, + } + + struct ErrorReader { + body_reads: Arc, + grant_drops: Arc, + _source_drop: DropSignal, + } + + #[expect( + clippy::missing_trait_methods, + reason = "the test source only needs the production wrapper's read method" + )] + impl Read for ErrorReader { + fn read(&mut self, _buf: &mut [u8]) -> io::Result { + if self.grant_drops.load(Ordering::SeqCst) != 0 { + return Err(io::Error::other("ingress grant dropped during body read")); + } + self.body_reads.fetch_add(1, Ordering::SeqCst); + Err(io::Error::other("fastly ingress source failure")) + } + } + + #[expect( + clippy::missing_trait_methods, + reason = "the test source only needs the production wrapper's read method" + )] + impl Read for TrackedReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.grant_drops.load(Ordering::SeqCst) != 0 { + return Err(io::Error::other("ingress grant dropped during body read")); + } + self.body_reads.fetch_add(1, Ordering::SeqCst); + if let Some((now, deadline)) = &self.expire_on_read { + *now.lock() + .map_err(|_poisoned| io::Error::other("clock lock poisoned"))? = *deadline; + } + let Some(chunk) = self.chunks.pop_front() else { + return Ok(0); + }; + if chunk.len() > buf.len() { + return Err(io::Error::other("test chunk exceeds read buffer")); + } + buf[..chunk.len()].copy_from_slice(&chunk); + Ok(chunk.len()) + } + } + + fn tracked_reader( + chunks: Vec, + grant_drops: &Arc, + source_drops: &Arc, + body_reads: &Arc, + expire_on_read: Option<(Arc>, MonotonicInstant)>, + ) -> TrackedReader { + TrackedReader { + body_reads: Arc::clone(body_reads), + chunks: chunks.into(), + expire_on_read, + grant_drops: Arc::clone(grant_drops), + _source_drop: DropSignal(Arc::clone(source_drops)), + } + } + + fn error_reader( + grant_drops: &Arc, + source_drops: &Arc, + body_reads: &Arc, + ) -> ErrorReader { + ErrorReader { + body_reads: Arc::clone(body_reads), + grant_drops: Arc::clone(grant_drops), + _source_drop: DropSignal(Arc::clone(source_drops)), + } + } + + fn guarded_fallback_app() -> (App, Arc, Arc) { + let handler_calls = Arc::new(AtomicUsize::new(0)); + let handler_counter = Arc::clone(&handler_calls); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .get("/known", move |_ctx: RequestContext| { + let request_handler_calls = Arc::clone(&handler_counter); + async move { + request_handler_calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("handler must not run") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + (App::new(router), handler_calls, middleware_calls) + } + + fn fallback_app( + max_body_bytes: usize, + read_budget: Duration, + grant_drops: &Arc, + ) -> (App, Arc, Arc) { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let observed_grant_drops = Arc::clone(grant_drops); + app.set_ingress_admission_policy(move |head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound + )); + AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes, + read_deadline: head.read_deadline_after(read_budget), + on_exceeded: terminal_response( + StatusCode::UNPROCESSABLE_ENTITY, + "overflow", + b"fastly overflow\0response", + ), + on_timeout: terminal_response( + StatusCode::GATEWAY_TIMEOUT, + "timeout", + b"fastly timeout response\n", + ), + } + }); + (app, handler_calls, middleware_calls) + } + + fn terminal_headers(marker: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + HeaderValue::from_static("application/octet-stream"), + ); + headers.insert("x-ingress-terminal", HeaderValue::from_static(marker)); + headers + } + + fn terminal_response( + status: StatusCode, + marker: &'static str, + body: &'static [u8], + ) -> BufferedIngressResponse { + BufferedIngressResponse::new(status, terminal_headers(marker), Bytes::from_static(body)) + } + + fn internal_error_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("content-length", HeaderValue::from_static("76")); + headers.insert("content-type", HeaderValue::from_static("application/json")); + headers + } + + fn assert_no_route_dispatch(handler_calls: &AtomicUsize, middleware_calls: &AtomicUsize) { + assert_eq!(handler_calls.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + } + + fn complete_response(envelope: ResponseEgressEnvelope) -> Response { + let Ok((prepared, _policy, mut attempt, clock)) = envelope.begin() else { + panic!("test egress envelope must prepare"); + }; + assert!(attempt.begin_writing()); + assert!(attempt.terminate(ResponseEgressOutcome::HostHandoff, clock.now())); + prepared.into_response() + } + + #[test] + fn normalized_ingress_error_returns_typed_response_without_application_observation() { + let reports = Arc::new(Mutex::new(Vec::new())); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let mut app = App::new(RouterService::builder().build()); + app.set_ingress_head_limits( + IngressHeadLimits::default() + .with_max_request_target_bytes(4) + .expect("target limit"), + ); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let observed_factory_calls = Arc::clone(&factory_calls); + let observed_completion_calls = Arc::clone(&completion_calls); + app.set_detached_response_egress_decision_factory(move |head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + let terminal_calls = Arc::clone(&observed_completion_calls); + DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::new(move |_report| { + terminal_calls.fetch_add(1, Ordering::SeqCst); + }), + deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET), + } + }); + + let egress = dispatch_ingress_reader_for_test( + &app, + Method::GET, + "/too-long".parse().expect("URI"), + Cursor::new(Vec::::new()), + ) + .expect("typed response"); + let response = complete_response(egress); + + assert_eq!(response.status(), StatusCode::URI_TOO_LONG); + assert!(reports.lock().expect("reports lock").is_empty()); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert_eq!(completion_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn normalized_ingress_error_decision_abort_uses_provider_error() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + app.set_ingress_head_limits( + IngressHeadLimits::default() + .with_max_request_target_bytes(4) + .expect("target limit"), + ); + let reports = Arc::new(Mutex::new(Vec::new())); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let observed_factory_calls = Arc::clone(&factory_calls); + app.set_detached_response_egress_decision_factory(move |_head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + DetachedResponseEgressDecision::Abort + }); + let source_calls = Arc::new(AtomicUsize::new(0)); + let observed_source_calls = Arc::clone(&source_calls); + let delivery_calls = Arc::new(AtomicUsize::new(0)); + let observed_delivery_calls = Arc::clone(&delivery_calls); + + let error = dispatch_ingress_with_for_test( + &app, + Method::GET, + "/too-long".parse().expect("URI"), + move || { + observed_source_calls.fetch_add(1, Ordering::SeqCst); + Ok(Cursor::new(Vec::::new())) + }, + move |_egress| { + observed_delivery_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .expect_err("detached abort must return a platform error"); + + assert!(error.to_string().contains("ingress admission aborted")); + assert_eq!(source_calls.load(Ordering::SeqCst), 0); + assert_eq!(delivery_calls.load(Ordering::SeqCst), 0); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert!(reports.lock().expect("reports lock").is_empty()); + } + + #[test] + fn admission_policy_error_decision_send_uses_detached_completion() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let decision_completion_calls = Arc::new(AtomicUsize::new(0)); + let decision_resource_drops = Arc::new(AtomicUsize::new(0)); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_decision_completion_calls = Arc::clone(&decision_completion_calls); + let observed_decision_resource_drops = Arc::clone(&decision_resource_drops); + let observed_grant_drops = Arc::clone(&grant_drops); + app.set_ingress_admission_policy(move |head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::Matched(_) + )); + let decision_resource = DropSignal(Arc::clone(&observed_decision_resource_drops)); + let terminal_calls = Arc::clone(&observed_decision_completion_calls); + AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::new(move |_report| { + let _resource = decision_resource; + terminal_calls.fetch_add(1, Ordering::SeqCst); + }), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes: 1, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text( + StatusCode::PAYLOAD_TOO_LARGE, + "too large", + ), + on_timeout: BufferedIngressResponse::text( + StatusCode::REQUEST_TIMEOUT, + "timeout", + ), + } + }); + let reports = Arc::new(Mutex::new(Vec::new())); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let observed_factory_calls = Arc::clone(&factory_calls); + let observed_completion_calls = Arc::clone(&completion_calls); + app.set_detached_response_egress_decision_factory(move |head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + let terminal_calls = Arc::clone(&observed_completion_calls); + DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::new(move |_report| { + terminal_calls.fetch_add(1, Ordering::SeqCst); + }), + deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET), + } + }); + let source_calls = Arc::new(AtomicUsize::new(0)); + let observed_source_calls = Arc::clone(&source_calls); + let response_status = Arc::new(Mutex::new(None)); + let observed_response_status = Arc::clone(&response_status); + + dispatch_ingress_with_for_test( + &app, + Method::GET, + "/known".parse().expect("URI"), + move || { + observed_source_calls.fetch_add(1, Ordering::SeqCst); + Ok(Cursor::new(Vec::::new())) + }, + move |egress| { + *observed_response_status.lock().expect("status lock") = + Some(complete_response(egress).status()); + Ok(()) + }, + ) + .expect("typed policy error response"); + + assert_eq!( + *response_status.lock().expect("status lock"), + Some(StatusCode::INTERNAL_SERVER_ERROR) + ); + assert_eq!(source_calls.load(Ordering::SeqCst), 0); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + assert_eq!(decision_completion_calls.load(Ordering::SeqCst), 0); + assert_eq!(decision_resource_drops.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert_eq!(completion_calls.load(Ordering::SeqCst), 1); + assert!(reports.lock().expect("reports lock").is_empty()); + } + + #[test] + fn admission_policy_error_decision_abort_uses_provider_error() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let decision_resource_drops = Arc::new(AtomicUsize::new(0)); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_decision_resource_drops = Arc::clone(&decision_resource_drops); + let observed_grant_drops = Arc::clone(&grant_drops); + app.set_ingress_admission_policy(move |head| { + AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::new({ + let resource = DropSignal(Arc::clone(&observed_decision_resource_drops)); + move |_report| drop(resource) + }), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes: 1, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text( + StatusCode::PAYLOAD_TOO_LARGE, + "too large", + ), + on_timeout: BufferedIngressResponse::text( + StatusCode::REQUEST_TIMEOUT, + "timeout", + ), + } + }); + let reports = Arc::new(Mutex::new(Vec::new())); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let observed_factory_calls = Arc::clone(&factory_calls); + app.set_detached_response_egress_decision_factory(move |_head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + DetachedResponseEgressDecision::Abort + }); + let source_calls = Arc::new(AtomicUsize::new(0)); + let observed_source_calls = Arc::clone(&source_calls); + let delivery_calls = Arc::new(AtomicUsize::new(0)); + let observed_delivery_calls = Arc::clone(&delivery_calls); + + let error = dispatch_ingress_with_for_test( + &app, + Method::GET, + "/known".parse().expect("URI"), + move || { + observed_source_calls.fetch_add(1, Ordering::SeqCst); + Ok(Cursor::new(Vec::::new())) + }, + move |_egress| { + observed_delivery_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .expect_err("detached abort must return a platform error"); + + assert!(error.to_string().contains("ingress admission aborted")); + assert_eq!(source_calls.load(Ordering::SeqCst), 0); + assert_eq!(delivery_calls.load(Ordering::SeqCst), 0); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + assert_eq!(decision_resource_drops.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert!(reports.lock().expect("reports lock").is_empty()); + } + + #[test] + fn post_admission_failure_uses_owned_error_egress() { + let reports = Arc::new(Mutex::new(Vec::new())); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_grant_drops = Arc::clone(&grant_drops); + let mut app = App::new( + RouterService::builder() + .get("/owned", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("handler must not run") + }) + .build(), + ); + app.set_ingress_admission_policy(move |head| AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + }); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + + let egress = dispatch_ingress_source_error_for_test( + &app, + Method::GET, + "/owned".parse().expect("URI"), + ) + .expect("owned error egress"); + let response = complete_response(egress); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + let observed = reports.lock().expect("reports lock"); + assert_eq!(observed.len(), 1); + let report = observed.first().expect("one report"); + assert_eq!(report.outcome, ResponseEgressOutcome::HostHandoff); + assert_eq!( + report.route.as_ref().map(RouteMetadata::pattern), + Some("/owned") + ); + } + + fn assert_terminal_response( + response: Response, + status: StatusCode, + expected_headers: &HeaderMap, + body: &[u8], + ) { + assert_eq!(response.status(), status); + assert_eq!(response.headers(), expected_headers); + assert_eq!( + block_on(response.into_body().into_bytes_bounded(body.len())) + .expect("terminal body"), + Bytes::copy_from_slice(body) + ); + } + + #[test] + fn exact_cap_preserves_not_found_and_method_not_allowed() { + for (path, expected_status) in [ + ("/missing", StatusCode::NOT_FOUND), + ("/known", StatusCode::METHOD_NOT_ALLOWED), + ] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_reads = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let reader = tracked_reader( + vec![Bytes::from_static(b"ab"), Bytes::from_static(b"cd")], + &grant_drops, + &source_drops, + &body_reads, + None, + ); + + let response = complete_response( + dispatch_ingress_reader_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + reader, + ) + .expect("egress envelope"), + ); + + assert_eq!(response.status(), expected_status); + if expected_status == StatusCode::METHOD_NOT_ALLOWED { + assert_eq!( + response.headers().get("allow").expect("Allow header"), + "GET" + ); + } + assert_eq!(body_reads.load(Ordering::SeqCst), 3); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn cap_plus_one_precedes_not_found_and_method_not_allowed() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_reads = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let reader = tracked_reader( + vec![Bytes::from_static(b"abcd"), Bytes::from_static(b"e")], + &grant_drops, + &source_drops, + &body_reads, + None, + ); + + let response = complete_response( + dispatch_ingress_reader_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + reader, + ) + .expect("egress envelope"), + ); + + assert_terminal_response( + response, + StatusCode::UNPROCESSABLE_ENTITY, + &terminal_headers("overflow"), + b"fastly overflow\0response", + ); + assert_eq!(body_reads.load(Ordering::SeqCst), 2); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn standard_service_enforces_fallback_overflow() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let request = fastly_request(FastlyMethod::POST, "/missing", Some(b"abcde")); + + let response = complete_response( + FastlyService::new(&app) + .capture_egress_for_test(request) + .expect("egress envelope"), + ); + + assert_terminal_response( + response, + StatusCode::UNPROCESSABLE_ENTITY, + &terminal_headers("overflow"), + b"fastly overflow\0response", + ); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn cooperative_pre_read_expiry_preserves_application_response() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_reads = Arc::new(AtomicUsize::new(0)); + let start = MonotonicInstant::now(); + let (mut app, handler_calls, middleware_calls) = + fallback_app(4, Duration::ZERO, &grant_drops); + app.set_monotonic_clock(MonotonicClock::new(move || start)); + let reader = tracked_reader( + vec![Bytes::from_static(b"body")], + &grant_drops, + &source_drops, + &body_reads, + None, + ); + + let response = complete_response( + dispatch_ingress_reader_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + reader, + ) + .expect("egress envelope"), + ); + + assert_terminal_response( + response, + StatusCode::GATEWAY_TIMEOUT, + &terminal_headers("timeout"), + b"fastly timeout response\n", + ); + assert_eq!(body_reads.load(Ordering::SeqCst), 0); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn cooperative_post_read_expiry_preserves_application_response() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_reads = Arc::new(AtomicUsize::new(0)); + let start = MonotonicInstant::now(); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let now = Arc::new(Mutex::new(start)); + let observed_now = Arc::clone(&now); + let (mut app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(1), &grant_drops); + app.set_monotonic_clock(MonotonicClock::new(move || { + *observed_now.lock().expect("clock lock") + })); + let reader = tracked_reader( + vec![Bytes::from_static(b"body")], + &grant_drops, + &source_drops, + &body_reads, + Some((now, deadline)), + ); + + let response = complete_response( + dispatch_ingress_reader_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + reader, + ) + .expect("egress envelope"), + ); + + assert_terminal_response( + response, + StatusCode::GATEWAY_TIMEOUT, + &terminal_headers("timeout"), + b"fastly timeout response\n", + ); + assert_eq!(body_reads.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn saturated_fallback_refuses_without_reading_body() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_reads = Arc::new(AtomicUsize::new(0)); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let observed_completion_calls = Arc::clone(&completion_calls); + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + app.set_ingress_admission_policy(move |head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound + )); + AdmissionDecision::Refuse { + completion: { + let calls = Arc::clone(&observed_completion_calls); + ResponseEgressCompletion::new(move |_report| { + calls.fetch_add(1, Ordering::SeqCst); + }) + }, + response: response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("x-ingress-refusal", "saturated") + .body(Body::from("fastly unavailable\n")) + .expect("refusal response"), + } + }); + let reader = tracked_reader( + vec![Bytes::from_static(b"body")], + &grant_drops, + &source_drops, + &body_reads, + None, + ); + + let response = complete_response( + dispatch_ingress_reader_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + reader, + ) + .expect("egress envelope"), + ); + + let mut expected_headers = HeaderMap::new(); + expected_headers.insert("x-ingress-refusal", HeaderValue::from_static("saturated")); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers(), &expected_headers); + assert_eq!( + block_on(response.into_body().into_bytes_bounded(64)).expect("response body"), + Bytes::from_static(b"fastly unavailable\n") + ); + assert_eq!(body_reads.load(Ordering::SeqCst), 0); + assert_eq!(grant_drops.load(Ordering::SeqCst), 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_eq!(completion_calls.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + // Fastly exposes the inbound body as synchronous `Read`; there is no + // pending future boundary at which a poll-then-drop cancellation can be + // represented. Source-error termination is the available lifecycle seam. + #[test] + fn synchronous_source_error_uses_fastly_response_boundary_and_releases_lifecycle() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_reads = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let reader = error_reader(&grant_drops, &source_drops, &body_reads); + + let response = complete_response( + dispatch_ingress_reader_for_test( + &app, + Method::POST, + "/missing".parse().expect("URI"), + reader, + ) + .expect("egress envelope"), + ); + + assert_terminal_response( + response, + StatusCode::INTERNAL_SERVER_ERROR, + &internal_error_headers(), + br#"{"error":{"status":500,"kind":"internal","message":"internal server error"}}"#, + ); + assert_eq!(body_reads.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } +} + +#[cfg(test)] +#[cfg(feature = "test-utils")] +#[cfg_attr( + all(feature = "fastly", target_arch = "wasm32"), + expect( + clippy::arbitrary_source_item_ordering, + reason = "the target-runtime probe follows shared batch contracts" + ) +)] +mod outbound_contract_tests { + use bytes::Bytes; + use edgezero_adapter_fastly::outbound::validate_batch_request_for_test; + use edgezero_core::body::Body; + use edgezero_core::http::{Method, Uri}; + use edgezero_core::outbound::OutboundRequest; + use futures_util::stream; + + fn request() -> OutboundRequest { + OutboundRequest::new( + Method::POST, + "https://example.com/bid".parse::().expect("URI"), + ) + .expect("request") + } + + #[test] + fn batch_preflight_rejects_streamed_slots_without_poisoning_siblings() { + let requests = [ + request().body(Bytes::from_static(b"first")), + request().body(Body::stream(stream::once(async { + Bytes::from_static(b"streamed") + }))), + request().stream_response(), + request().body(Bytes::from_static(b"last")), + ]; + let outcomes: Vec<_> = requests + .iter() + .map(validate_batch_request_for_test) + .collect(); + + let accepted: Vec<_> = outcomes.iter().map(Result::is_ok).collect(); + assert_eq!(accepted, vec![true, false, false, true]); + } + + #[cfg(all(feature = "fastly", target_arch = "wasm32"))] + mod runtime_tests { + use std::time::Duration; + + use edgezero_adapter_fastly::outbound::{ + FastlyOutboundClient, inject_dispatch_slack_for_test, + }; + use edgezero_core::error::EdgeError; + use edgezero_core::outbound::OutboundHttpClient as _; + use edgezero_core::time::Deadline; + use futures::executor::block_on; + + use super::*; + + #[test] + fn request_preparation_consumes_entry_budget() { + let _injection = inject_dispatch_slack_for_test(Duration::from_millis(26)); + let request = request() + .body(Bytes::from_static(b"body")) + .timeout(Duration::from_secs(1)); + + let results = block_on( + FastlyOutboundClient::new() + .start_batch_until(vec![request], Deadline::after(Duration::from_secs(1))) + .collect(), + ) + .expect("valid batch driver"); + assert_eq!( + results.termination, + edgezero_core::OutboundBatchTermination::Completed + ); + let slot = results.slots[0].as_ref().expect("resolved slot"); + + let Err(EdgeError::Internal { source }) = &slot.outcome else { + panic!("expected dispatch-slack failure, got {:?}", slot.outcome); + }; + assert_eq!( + source.to_string(), + "Fastly batch adapter overhead between batch_now and SDK arming (preflight + dynamic-backend lookup/creation + SDK setup) exceeded BATCH_DISPATCH_SLACK_MAX; refusing to arm SDK timers with stale duration" + ); + } + + #[test] + fn expired_observation_cutoff_leaves_slot_unresolved() { + let now = edgezero_core::MonotonicInstant::now(); + let client = + FastlyOutboundClient::with_clock(edgezero_core::MonotonicClock::new(move || now)); + let request = request().body(Bytes::from_static(b"body")); + + let results = block_on( + client + .start_batch_until(vec![request], Deadline::at_instant(now)) + .collect(), + ) + .expect("valid batch driver"); + + assert_eq!( + results.termination, + edgezero_core::OutboundBatchTermination::Cutoff + ); + assert!(matches!(results.slots.first(), Some(None))); + } } } diff --git a/crates/edgezero-adapter-spin/.cargo/config.toml b/crates/edgezero-adapter-spin/.cargo/config.toml index 788dbb508..773cb447b 100644 --- a/crates/edgezero-adapter-spin/.cargo/config.toml +++ b/crates/edgezero-adapter-spin/.cargo/config.toml @@ -1,9 +1,8 @@ [build] target = "wasm32-wasip2" -# Wasmtime runs the spin contract tests (no Fastly host imports needed). -# Only applies when cargo is invoked from inside this package directory. -# CI overrides via `CARGO_TARGET_WASM32_WASIP2_RUNNER` env var in -# `.github/workflows/test.yml`. +# Wasmtime 48.0.2 runs the Spin SDK resource tests with Preview 3 async and +# WASI HTTP imports enabled. Only applies when Cargo is invoked from this +# package directory. CI uses the same command through the target runner env. [target.'cfg(target_arch = "wasm32")'] -runner = "wasmtime run" +runner = "wasmtime run -W component-model-async=y -S p3=y -S http=y" diff --git a/crates/edgezero-adapter-spin/Cargo.toml b/crates/edgezero-adapter-spin/Cargo.toml index f1eeef4d9..7fb303786 100644 --- a/crates/edgezero-adapter-spin/Cargo.toml +++ b/crates/edgezero-adapter-spin/Cargo.toml @@ -13,16 +13,16 @@ workspace = true [features] default = [] spin = ["dep:spin-sdk"] +test-utils = [] cli = ["dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", "dep:rusqlite", "dep:toml", "dep:toml_edit", "dep:walkdir"] [dependencies] edgezero-core = { path = "../edgezero-core" } edgezero-adapter = { path = "../edgezero-adapter", optional = true, features = ["cli"] } anyhow = { workspace = true } +async-stream = { workspace = true } async-trait = { workspace = true } -brotli = { workspace = true } bytes = { workspace = true } -flate2 = { workspace = true } futures = { workspace = true } futures-util = { workspace = true } log = { workspace = true } diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index d68dd7dba..66f11c94a 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -17,13 +17,14 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, - TypedSecretEntry, register_adapter, + Adapter, AdapterAction, AdapterExecutionTarget, AdapterPushContext, ProvisionStores, + ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, register_adapter, }; use edgezero_adapter::scaffold::{ AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, }; +use edgezero_core::{Capability, CapabilitySupport, PlatformMetadata}; use walkdir::WalkDir; mod push_cloud; @@ -136,6 +137,35 @@ struct SpinCliAdapter; reason = "KV-backed config dropped Spin's `^[a-z][a-z0-9_]*$` key rule and the config-vs-secret collision check, so `validate_app_config_keys` falls back to the trait default `Ok(())`. `validate_typed_secrets` IS overridden below (secret-value canonicalisation + within-secrets uniqueness still apply). `validate_adapter_manifest` IS overridden below (Spin's multi-component disambiguation). `read_config_entry` and `read_config_entry_local` are both overridden below (four-branch SQLite-direct / Fermyon Cloud / non-Spin-backend dispatch)." )] impl Adapter for SpinCliAdapter { + fn capability(&self, capability: Capability) -> CapabilitySupport { + match capability { + Capability::IngressAdmission + | Capability::OutboundBatchCompletionOrder + | Capability::OutboundBatchSlotIsolation + | Capability::OutboundCacheBypass + | Capability::OutboundHeaderFidelity + | Capability::OutboundHttp => CapabilitySupport::Native, + Capability::ConfigReadDeadlines + | Capability::InboundReadDeadlines + | Capability::IngressAdmissionAbort + | Capability::LazyStreamedResponsePassthrough + | Capability::OutboundBatchCancellation + | Capability::OutboundDeadlines + | Capability::OutboundFlexiblePhaseBudget + | Capability::ResponseEgressAbort + | Capability::ResponseEgressBackpressure + | Capability::ResponseEgressCompletion + | Capability::ResponseWriteDeadlines + | Capability::StreamedUploadDeadlines => CapabilitySupport::BestEffort, + Capability::ConfigReadAllocationBounds + | Capability::OutboundAuthorityOverride + | Capability::OutboundCompleteResourceAccounting + | Capability::RawIngressFramingValidation + | Capability::RawIngressHeadLimits + | _ => CapabilitySupport::Unsupported, + } + } + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { // `spin cloud {login|logout|info}` is the native sign-in @@ -168,6 +198,34 @@ impl Adapter for SpinCliAdapter { } } + fn execute_target( + &self, + action: AdapterAction, + target: &AdapterExecutionTarget, + args: &[String], + ) -> Result<(), String> { + let manifest = target_manifest(target)?; + match action { + AdapterAction::Build => { + let artifact = build_from_manifest(&manifest, args)?; + log::info!("[edgezero] Spin build complete -> {}", artifact.display()); + Ok(()) + } + AdapterAction::Deploy => deploy_from_manifest(&manifest, args), + AdapterAction::Serve => serve_from_manifest(&manifest, args), + AdapterAction::AuthLogin + | AdapterAction::AuthLogout + | AdapterAction::AuthStatus + | AdapterAction::DeployStaging + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback + | _ => Err(format!( + "spin adapter cannot execute operational action {action:?} against a pinned runtime target" + )), + } + } + fn merged_id_kinds(&self) -> &'static [&'static str] { // Both KV and Config back to `spin_sdk::key_value::Store` via // the same `provision` path; declaring the same logical id @@ -180,6 +238,10 @@ impl Adapter for SpinCliAdapter { "spin" } + fn platform_metadata(&self) -> PlatformMetadata { + crate::SPIN_PLATFORM + } + fn provision( &self, manifest_root: &Path, @@ -1053,6 +1115,10 @@ fn ensure_kv_label_in_component( pub fn build(extra_args: &[String]) -> Result { let manifest = find_spin_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + build_from_manifest(&manifest, extra_args) +} + +fn build_from_manifest(manifest: &Path, extra_args: &[String]) -> Result { let manifest_dir = manifest .parent() .ok_or_else(|| "spin manifest has no parent directory".to_owned())?; @@ -1095,6 +1161,10 @@ pub fn build(extra_args: &[String]) -> Result { pub fn deploy(extra_args: &[String]) -> Result<(), String> { let manifest = find_spin_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + deploy_from_manifest(&manifest, extra_args) +} + +fn deploy_from_manifest(manifest: &Path, extra_args: &[String]) -> Result<(), String> { let manifest_dir = manifest .parent() .ok_or_else(|| "spin manifest has no parent directory".to_owned())?; @@ -1144,6 +1214,19 @@ fn find_spin_manifest(start: &Path) -> Result { Ok(candidates.remove(0)) } +fn target_manifest(target: &AdapterExecutionTarget) -> Result { + let manifest = target + .platform_manifest() + .map_or_else(|| target.app_root().join("spin.toml"), Path::to_path_buf); + if !manifest.is_file() { + return Err(format!( + "pinned spin manifest {} is not a regular file", + manifest.display() + )); + } + Ok(manifest) +} + fn locate_artifact( workspace_root: &Path, manifest_dir: &Path, @@ -1202,6 +1285,10 @@ fn register_ctor() { pub fn serve(extra_args: &[String]) -> Result<(), String> { let manifest = find_spin_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + serve_from_manifest(&manifest, extra_args) +} + +fn serve_from_manifest(manifest: &Path, extra_args: &[String]) -> Result<(), String> { let manifest_dir = manifest .parent() .ok_or_else(|| "spin manifest has no parent directory".to_owned())?; @@ -1222,6 +1309,7 @@ pub fn serve(extra_args: &[String]) -> Result<(), String> { #[cfg(test)] mod tests { use super::*; + use std::num::NonZeroU32; use tempfile::tempdir; // Shared fixture names. Pinning these as consts (instead of @@ -1237,6 +1325,162 @@ mod tests { const TEST_SECRET_ID: &str = "default"; const TEST_COMPONENT_ID: &str = "demo"; + #[test] + fn adapter_capability_matrix_matches_contracts() { + let expected = [ + ( + Capability::ConfigReadAllocationBounds, + CapabilitySupport::Unsupported, + ), + ( + Capability::ConfigReadDeadlines, + CapabilitySupport::BestEffort, + ), + ( + Capability::InboundReadDeadlines, + CapabilitySupport::BestEffort, + ), + (Capability::IngressAdmission, CapabilitySupport::Native), + ( + Capability::IngressAdmissionAbort, + CapabilitySupport::BestEffort, + ), + ( + Capability::RawIngressFramingValidation, + CapabilitySupport::Unsupported, + ), + ( + Capability::RawIngressHeadLimits, + CapabilitySupport::Unsupported, + ), + ( + Capability::ResponseEgressAbort, + CapabilitySupport::BestEffort, + ), + ( + Capability::ResponseEgressBackpressure, + CapabilitySupport::BestEffort, + ), + ( + Capability::ResponseEgressCompletion, + CapabilitySupport::BestEffort, + ), + ( + Capability::ResponseWriteDeadlines, + CapabilitySupport::BestEffort, + ), + (Capability::OutboundHttp, CapabilitySupport::Native), + ( + Capability::OutboundCompleteResourceAccounting, + CapabilitySupport::Unsupported, + ), + ( + Capability::OutboundHeaderFidelity, + CapabilitySupport::Native, + ), + (Capability::OutboundDeadlines, CapabilitySupport::BestEffort), + ( + Capability::OutboundFlexiblePhaseBudget, + CapabilitySupport::BestEffort, + ), + ( + Capability::OutboundAuthorityOverride, + CapabilitySupport::Unsupported, + ), + ( + Capability::OutboundBatchCancellation, + CapabilitySupport::BestEffort, + ), + ( + Capability::OutboundBatchCompletionOrder, + CapabilitySupport::Native, + ), + ( + Capability::OutboundBatchSlotIsolation, + CapabilitySupport::Native, + ), + (Capability::OutboundCacheBypass, CapabilitySupport::Native), + ( + Capability::StreamedUploadDeadlines, + CapabilitySupport::BestEffort, + ), + ( + Capability::LazyStreamedResponsePassthrough, + CapabilitySupport::BestEffort, + ), + ]; + + for (capability, support) in expected { + assert_eq!( + SPIN_ADAPTER.capability(capability), + support, + "{capability:?}" + ); + } + } + + #[test] + fn adapter_platform_metadata_distinguishes_generic_and_hosted_profiles() { + let generic = SPIN_ADAPTER.platform_metadata(); + + assert_eq!(generic, crate::SPIN_PLATFORM); + assert_eq!( + generic.memory_ceiling(), + edgezero_core::PlatformFact::unknown( + edgezero_core::PlatformUnknownReason::RuntimeConfigured + ) + ); + assert_eq!( + generic.inbound_request_population_bound(), + edgezero_core::PlatformFact::unknown( + edgezero_core::PlatformUnknownReason::RuntimeConfigured + ) + ); + assert_eq!( + generic.host_ingress_memory_accounting(), + edgezero_core::PlatformFact::unknown( + edgezero_core::PlatformUnknownReason::RuntimeConfigured + ) + ); + + let hosted = crate::AKAMAI_FUNCTIONS_PLATFORM; + let source = edgezero_core::PlatformResourceSource::HostedDefault { + provider: "Akamai Functions", + }; + assert_eq!( + hosted.memory_ceiling(), + edgezero_core::PlatformFact::known( + edgezero_core::MemoryCeiling::new( + 128 * 1024 * 1024, + edgezero_core::MemoryCeilingScope::PerExecution, + None, + ), + source, + ) + ); + assert_eq!( + hosted.inbound_request_population_bound(), + edgezero_core::PlatformFact::known( + edgezero_core::InboundRequestPopulationBound::new(NonZeroU32::MIN), + source, + ) + ); + assert_eq!( + hosted + .inbound_request_population_bound() + .value() + .expect("the hosted profile publishes the per-execution population") + .max_live_requests(), + NonZeroU32::MIN + ); + assert_eq!( + hosted.host_ingress_memory_accounting(), + edgezero_core::PlatformFact::unknown( + edgezero_core::PlatformUnknownReason::ProviderUnpublished + ) + ); + } + #[test] fn is_valid_spin_key_accepts_lowercase_with_digits_and_underscores() { assert!(is_valid_spin_key("foo")); diff --git a/crates/edgezero-adapter-spin/src/cli/push_sqlite.rs b/crates/edgezero-adapter-spin/src/cli/push_sqlite.rs index 16d31a327..b1922cc82 100644 --- a/crates/edgezero-adapter-spin/src/cli/push_sqlite.rs +++ b/crates/edgezero-adapter-spin/src/cli/push_sqlite.rs @@ -19,13 +19,13 @@ //! //! 1. **Vendored schema constants** — `SPIN_KV_CREATE_TABLE` and //! `SPIN_KV_SET` are copied byte-for-byte from -//! [spinframework/spin's `crates/key-value-spin/src/store.rs`](https://github.com/spinframework/spin/blob/main/crates/key-value-spin/src/store.rs). +//! [Spin 4.1.0's `crates/key-value-spin/src/store.rs`](https://github.com/spinframework/spin/blob/v4.1.0/crates/key-value-spin/src/store.rs). //! A unit test (`vendored_schema_matches_upstream_byte_for_byte`) //! pins both strings; a PRAGMA-shape test pins the resulting table //! columns. Drift in OUR copy fails CI. -//! 2. **Build-time SDK pin** — `Cargo.toml` pins `spin-sdk = "~6.0"`, -//! so a Spin minor bump that touches the schema fails the build -//! until the operator opts in and re-verifies. +//! 2. **Build-time SDK pin** — `Cargo.toml` pins `spin-sdk = "=7.0.0"`, +//! so an SDK bump requires an explicit dependency update and +//! schema re-verification. //! 3. **Run-time CLI check** — [`verify_spin_runtime_compat`] shells //! `spin --version` before the first write of a session and //! `log::warn!`s if the major version is outside our verified @@ -34,7 +34,7 @@ //! is told if their runtime is unknown. //! //! What the layered guards CANNOT catch: a Spin point-release that -//! changes the schema WITHOUT bumping past `~6.0` AND with a +//! changes the schema WITHOUT changing the SDK pin AND with a //! same-major CLI version. Operators must verify with `spin up` //! after the first push against a new Spin runtime; the warning //! above is a heads-up, not a guarantee. @@ -49,20 +49,14 @@ use rusqlite::{Connection, params}; /// Major version range of `spin` CLI / runtime we've verified the /// `spin_key_value` schema against. Spin's `crates/key-value-spin` /// has used this schema since at least Spin 2.x; we last verified -/// against Spin 3.x (CLI 3.6.3, factor-key-value 3.x on -/// spinframework/spin main, 2026-06-04). -/// -/// When Spin 4.0 (or whichever major-version-bump touches the -/// schema) lands, expect the verification to be repeated and this -/// constant updated. Until then, an operator running a Spin CLI -/// outside this range gets a `log::warn!` on first SQLite-direct -/// push. -const VERIFIED_SPIN_MAJOR_RANGE: &[u32] = &[2, 3]; +/// against Spin 4.1.0 on 2026-09-13. An operator running a Spin CLI +/// outside this range gets a `log::warn!` on first SQLite-direct push. +const VERIFIED_SPIN_MAJOR_RANGE: &[u32] = &[2, 3, 4]; /// EXACT `CREATE TABLE IF NOT EXISTS spin_key_value (…)` statement /// Spin's `crates/key-value-spin/src/store.rs::KeyValueSqlite::create_connection` /// runs. Source-of-truth: pulled from the file linked at the module -/// header on 2026-06-04 against an OPEN version of Spin's main branch. +/// header on 2026-09-13 against Spin 4.1.0. /// **Do not reformat:** the contract test below compares this string /// byte-for-byte against the upstream statement (whitespace included). pub(crate) const SPIN_KV_CREATE_TABLE: &str = "CREATE TABLE IF NOT EXISTS spin_key_value ( @@ -85,7 +79,7 @@ pub(crate) const SPIN_KV_SET: &str = /// hard-codes `.spin/sqlite_key_value.db` for its `type = "spin"` /// backend when no `path` is set in `runtime-config.toml`. Vendored /// from `crates/factor-key-value/src/runtime_config/spin.rs::path` in -/// spinframework/spin (June 2026). +/// spinframework/spin 4.1.0 (September 2026). pub(crate) const DEFAULT_SQLITE_RELATIVE_PATH: &str = ".spin/sqlite_key_value.db"; /// Resolve the `SQLite` path for a `[key_value_store.(platform).context("application configuration failed") +} + +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +fn build_target_app(platform: edgezero_core::PlatformMetadata) -> Result { + App::build::(platform) +} + +/// Test seam for the production application-assembly error mapping. +#[cfg(all(feature = "test-utils", feature = "spin", target_arch = "wasm32"))] +#[doc(hidden)] +#[inline] +pub fn build_app_for_test() -> anyhow::Result { + build_app_for_dispatch::(SPIN_PLATFORM) +} + /// Initialize the logger for Spin. /// -/// Currently a no-op — Spin manages its own logging internally. -/// When a real logger is needed for one target, split this into -/// `#[cfg(all(feature = "spin", target_arch = "wasm32"))]` / -/// `#[cfg(not(...))]` branches following the Fastly/Cloudflare pattern. -// TODO: wire in real Spin logger when available +/// This is a no-op. Host stdout/stderr capture does not install a Rust `log` +/// backend. Applications needing facade output must install their backend and +/// filters before the adapter entrypoint and set `Hooks::owns_logging()` to `true`. /// /// # Errors -/// Never; this is currently a no-op because Spin manages logging -/// internally. The signature still returns [`log::SetLoggerError`] so -/// the future "wire in a real logger" branch stays drop-in compatible. +/// Never; no logger is installed or reconfigured. #[inline] pub fn init_logger() -> Result<(), log::SetLoggerError> { Ok(()) @@ -94,50 +147,117 @@ pub fn init_logger() -> Result<(), log::SetLoggerError> { /// use my_core::App; /// /// #[http_service] -/// async fn handle(req: spin_sdk::http::Request) -> anyhow::Result { +/// async fn handle( +/// req: spin_sdk::http::Request, +/// ) -> anyhow::Result { /// edgezero_adapter_spin::run_app::(req).await /// } /// ``` /// -/// Returns the concrete [`SpinFullResponse`] (was `impl IntoResponse` up -/// through 2026-Q2). Source-compatible with the generated scaffold handler -/// signature because `SpinFullResponse: spin_sdk::http::IntoResponse`. +/// Returns the concrete raw [`SpinResponse`]. Its body writer and transmission-result observer +/// remain owned by the adapter's spawned response-egress coordinator. /// /// # Errors /// Returns [`anyhow::Error`] when the inner dispatch fails — transport, /// router, store binding, or response translation errors propagate here. #[cfg(all(feature = "spin", target_arch = "wasm32"))] #[inline] -pub async fn run_app(req: SpinRequest) -> anyhow::Result { - // Best-effort: every Spin `#[http_service]` re-enters this function, so a - // second `log::set_logger` call returns Err — drop the result instead of - // `.expect()` to avoid panicking on every subsequent request. Skipped - // entirely when the app owns logging. +pub async fn run_app(req: SpinRequest) -> anyhow::Result { + run_app_with_platform::(req, SPIN_PLATFORM).await +} + +/// Build and dispatch a Spin application with explicit runtime platform metadata. +/// +/// Use [`AKAMAI_FUNCTIONS_PLATFORM`] for Akamai Functions. Self-hosted Spin +/// deployments can construct metadata from their configured `max_instance_memory`. +/// +/// # Errors +/// Returns [`anyhow::Error`] when application configuration, request conversion, +/// routing, store binding, or response translation fails. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[inline] +pub async fn run_app_with_platform( + req: SpinRequest, + platform: edgezero_core::PlatformMetadata, +) -> anyhow::Result { if !A::owns_logging() { drop(init_logger()); } - let app = A::build_app(); + let app = build_app_for_dispatch::(platform)?; dispatch_app::(&app, req).await } -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -/// Dispatch a caller-owned app using `A::stores()` for fresh request bindings. +/// Dispatch a caller-owned app through canonical admission and response egress. /// /// This does not build or cache an app or install logging. The caller must pass -/// an app built from `A`; `App` erases its construction type, so this pairing +/// an app built from `A` with its selected platform; `App` erases its construction type, so this pairing /// remains a caller precondition. Retain only application-owned values and keep /// native handles and pending work request-local. Shared state must support /// overlapping invocations. /// /// # Errors -/// Returns conversion or dispatch errors from the existing adapter boundary. +/// Returns configuration, admission-abort, or adapter delivery errors. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] #[inline] -pub async fn dispatch_app( - app: &App, - req: SpinRequest, -) -> anyhow::Result { +pub async fn dispatch_app(app: &App, req: SpinRequest) -> anyhow::Result { let stores = A::stores(); let env = EnvConfig::from_env(); request::dispatch_with_registries(app, req, stores.config, stores.kv, stores.secrets, &env) .await } + +#[cfg(test)] +mod platform_tests { + use edgezero_core::app::{App, Hooks}; + use edgezero_core::error::EdgeError; + use edgezero_core::router::RouterService; + + macro_rules! platform_hooks { + ($hooks:ty, $platform:expr) => { + #[expect( + clippy::missing_trait_methods, + reason = "test hook exercises only target metadata propagation" + )] + impl Hooks for $hooks { + fn configure(app: &mut App) -> Result<(), EdgeError> { + if app.platform() == $platform { + Ok(()) + } else { + Err(EdgeError::service_unavailable("wrong application platform")) + } + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + }; + } + + struct GenericPlatformConfiguration; + struct HostedPlatformConfiguration; + + platform_hooks!(GenericPlatformConfiguration, crate::SPIN_PLATFORM); + platform_hooks!( + HostedPlatformConfiguration, + crate::AKAMAI_FUNCTIONS_PLATFORM + ); + + #[test] + fn application_configuration_receives_generic_spin_platform_metadata() { + let app = super::build_target_app::(crate::SPIN_PLATFORM) + .expect("configured application"); + + assert_eq!(app.platform(), crate::SPIN_PLATFORM); + } + + #[test] + fn application_configuration_receives_explicit_hosted_platform_metadata() { + let app = super::build_target_app::( + crate::AKAMAI_FUNCTIONS_PLATFORM, + ) + .expect("configured application"); + + assert_eq!(app.platform(), crate::AKAMAI_FUNCTIONS_PLATFORM); + } +} diff --git a/crates/edgezero-adapter-spin/src/outbound.rs b/crates/edgezero-adapter-spin/src/outbound.rs new file mode 100644 index 000000000..bd8a40b87 --- /dev/null +++ b/crates/edgezero-adapter-spin/src/outbound.rs @@ -0,0 +1,1875 @@ +#![cfg_attr( + any(test, all(feature = "spin", target_arch = "wasm32")), + expect( + clippy::arbitrary_source_item_ordering, + reason = "target and test helper modules stay adjacent to the imports that define their boundaries" + ) +)] +#![cfg_attr( + all(feature = "spin", target_arch = "wasm32"), + expect( + clippy::pub_use, + reason = "the target-gated implementation keeps Spin SDK imports out of native builds" + ) +)] + +use edgezero_core::error::EdgeError; +#[cfg(any(feature = "test-utils", all(feature = "spin", target_arch = "wasm32")))] +use edgezero_core::outbound::{OutboundRequest, validate_for_dispatch}; + +#[cfg(all(feature = "spin", any(feature = "test-utils", target_arch = "wasm32")))] +use edgezero_core::error::{BadGatewayReason, BudgetSource}; +#[cfg(all(feature = "spin", any(feature = "test-utils", target_arch = "wasm32")))] +use edgezero_core::time::Deadline; +#[cfg(all(feature = "spin", any(feature = "test-utils", target_arch = "wasm32")))] +use spin_sdk::wasip3::http::types::ErrorCode; + +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +mod exchange { + use std::future::{Future, IntoFuture}; + use std::task::Poll; + use std::time::Duration; + + use async_stream::stream; + use edgezero_core::body::BodyStream; + use edgezero_core::error::EdgeError; + use futures_util::{StreamExt as _, future::poll_fn}; + + pub(super) const READY_ITEM_YIELD_QUOTA: usize = 64; + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub(super) enum UploadCompletion { + Complete, + ReaderGone, + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub(super) struct RequestTimeouts { + between_bytes: u64, + connect: u64, + first_byte: u64, + } + + impl RequestTimeouts { + pub(super) fn between_bytes(self) -> u64 { + self.between_bytes + } + + pub(super) fn connect(self) -> u64 { + self.connect + } + + pub(super) fn first_byte(self) -> u64 { + self.first_byte + } + } + + pub(super) fn request_timeouts(remaining: Duration) -> RequestTimeouts { + let full_remaining = duration_nanos(remaining); + RequestTimeouts { + between_bytes: full_remaining, + connect: full_remaining, + first_byte: full_remaining, + } + } + + pub(super) fn should_emit_response_chunk(length: usize) -> bool { + length != 0 + } + + pub(super) fn duration_nanos(duration: Duration) -> u64 { + u64::try_from(duration.as_nanos()) + .unwrap_or(u64::MAX) + .max(1) + } + + pub(super) async fn cooperative_yield_once() { + let mut yielded = false; + poll_fn(move |context| { + if yielded { + Poll::Ready(()) + } else { + yielded = true; + context.waker().wake_by_ref(); + Poll::Pending + } + }) + .await; + } + + pub(super) async fn cooperate_after_response_read(ready_reads: &mut usize) { + *ready_reads = ready_reads.saturating_add(1); + if *ready_reads >= READY_ITEM_YIELD_QUOTA { + *ready_reads = 0; + cooperative_yield_once().await; + } + } + + pub(super) fn cooperative_stream(mut source: BodyStream) -> BodyStream { + stream! { + let mut ready_items = 0_usize; + while let Some(item) = source.next().await { + let terminal = item.is_err(); + yield item; + if terminal { + return; + } + ready_items = ready_items.saturating_add(1); + if ready_items >= READY_ITEM_YIELD_QUOTA { + cooperative_yield_once().await; + ready_items = 0; + } + } + } + .boxed_local() + } + + pub(super) async fn settle_reset_content( + mut source: BodyStream, + declared_body: bool, + ) -> Result<(), EdgeError> { + if !declared_body && let Some(item) = source.next().await { + item?; + } + Ok(()) + } + + pub(super) async fn run_exchange( + send_future: Send, + pump_future: Pump, + request_done_reader: RequestDone, + map_error: MapError, + ) -> Result + where + Send: Future>, + Pump: Future>, + RequestDone: IntoFuture>, + MapError: Fn(&HostError) -> EdgeError, + { + #[derive(Clone, Copy)] + enum State { + AwaitingRequestDone, + ReaderGone, + RequestComplete, + Uploading, + } + + enum Outcome { + PumpError(EdgeError), + RequestDoneError(HostError), + Send(Result), + } + + let mut state = State::Uploading; + let mut retained_send = None; + let mut send = Box::pin(send_future); + let mut pump = Box::pin(pump_future); + let mut request_done = Box::pin(request_done_reader.into_future()); + + let outcome = poll_fn(|context| { + loop { + match state { + State::Uploading => match pump.as_mut().poll(context) { + Poll::Ready(Err(error)) => return Poll::Ready(Outcome::PumpError(error)), + Poll::Ready(Ok(UploadCompletion::Complete)) => { + state = State::AwaitingRequestDone; + } + Poll::Ready(Ok(UploadCompletion::ReaderGone)) => { + state = State::ReaderGone; + } + Poll::Pending => match send.as_mut().poll(context) { + Poll::Ready(result) => return Poll::Ready(Outcome::Send(result)), + Poll::Pending => return Poll::Pending, + }, + }, + State::AwaitingRequestDone => match request_done.as_mut().poll(context) { + Poll::Ready(Err(error)) => { + return Poll::Ready(Outcome::RequestDoneError(error)); + } + Poll::Ready(Ok(())) => state = State::RequestComplete, + Poll::Pending => { + if retained_send.is_none() + && let Poll::Ready(result) = send.as_mut().poll(context) + { + retained_send = Some(result); + } + return Poll::Pending; + } + }, + State::ReaderGone | State::RequestComplete => { + if let Some(result) = retained_send.take() { + return Poll::Ready(Outcome::Send(result)); + } + match send.as_mut().poll(context) { + Poll::Ready(result) => return Poll::Ready(Outcome::Send(result)), + Poll::Pending => return Poll::Pending, + } + } + } + } + }) + .await; + + drop(request_done); + drop(pump); + drop(send); + match outcome { + Outcome::PumpError(error) => Err(error), + Outcome::RequestDoneError(error) | Outcome::Send(Err(error)) => Err(map_error(&error)), + Outcome::Send(Ok(output)) => Ok(output), + } + } +} + +#[cfg(any( + test, + all(feature = "spin", target_arch = "wasm32", feature = "test-utils") +))] +use exchange::READY_ITEM_YIELD_QUOTA; +#[cfg(test)] +use exchange::duration_nanos; +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +use exchange::{ + UploadCompletion, cooperate_after_response_read, cooperative_stream, cooperative_yield_once, + request_timeouts, run_exchange, settle_reset_content, should_emit_response_chunk, +}; + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +mod spin_impl { + use std::num::NonZeroU64; + use std::time::Duration; + + use async_stream::stream; + use async_trait::async_trait; + use bytes::Bytes; + use edgezero_core::body::{Body, BodyStream}; + #[cfg(feature = "test-utils")] + use edgezero_core::error::BudgetSource; + use edgezero_core::error::{BadGatewayReason, EdgeError}; + use edgezero_core::http::header::ACCEPT_ENCODING; + use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}; + use edgezero_core::outbound::{ + OutboundBatch, OutboundHttpClient, OutboundRequest, OutboundRequestParts, OutboundResponse, + ResponseBodyDisposition, ResponseBodyPolicy, ResponseHeaderLimiter, ResponseMode, + apply_response_body_policy, collect_response_stream, insert_proxy_header, + normalize_for_dispatch, normalize_response_headers, + }; + use edgezero_core::time::{ + Deadline, DispatchBudget, MonotonicClock, MonotonicInstant, dispatch_budget, + }; + use futures_util::StreamExt as _; + use futures_util::future::{Either, FutureExt as _, LocalBoxFuture, select}; + use futures_util::stream::once; + use spin_sdk::time::sleep; + use spin_sdk::wasip3::http::client; + use spin_sdk::wasip3::http::types::{ + ErrorCode, Fields, Method as WasiMethod, Request, RequestOptions, RequestOptionsError, + Response, Scheme, + }; + use spin_sdk::wasip3::http_compat::BodyWriter; + use spin_sdk::wasip3::wit_bindgen::{FutureWriter, StreamResult}; + use spin_sdk::wasip3::wit_future; + + use super::{ + UploadCompletion, cooperate_after_response_read, cooperative_yield_once, + map_spin_send_error, run_exchange, settle_reset_content, should_emit_response_chunk, + timeout_error, + }; + + const RESPONSE_READ_BYTES: usize = 16 * 1024; + + /// Native outbound HTTP implementation for Spin's WASI HTTP 0.3 host. + #[derive(Clone)] + pub struct SpinOutboundClient { + clock: MonotonicClock, + } + + struct PreparedRequest { + budget: DispatchBudget, + parts: OutboundRequestParts, + } + + impl SpinOutboundClient { + /// Builds a client using the process-default monotonic clock. + #[must_use] + #[inline] + pub fn new() -> Self { + Self::with_clock(MonotonicClock::default()) + } + + /// Builds a client that evaluates every outbound lifetime against `clock`. + #[must_use] + #[inline] + pub fn with_clock(clock: MonotonicClock) -> Self { + Self { clock } + } + + fn prepare( + request: OutboundRequest, + started_at: MonotonicInstant, + ) -> Result { + super::validate_request(&request)?; + Self::prepare_validated(request, started_at, None) + } + + fn prepare_batch( + request: OutboundRequest, + started_at: MonotonicInstant, + observation_cutoff: Deadline, + ) -> Result { + super::validate_batch_request(&request)?; + Self::prepare_validated(request, started_at, Some(observation_cutoff)) + } + + fn prepare_validated( + mut request: OutboundRequest, + started_at: MonotonicInstant, + observation_cutoff: Option, + ) -> Result { + let budget = dispatch_budget(&request, started_at, observation_cutoff)?; + normalize_for_dispatch(&mut request)?; + Ok(PreparedRequest { + budget, + parts: request.into_parts(), + }) + } + + async fn execute(&self, prepared: PreparedRequest) -> Result { + let PreparedRequest { budget, parts } = prepared; + let OutboundRequestParts { + body, + mut headers, + max_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_request_body_bytes, + max_response_header_bytes, + max_response_header_count, + method, + response_mode, + uri, + .. + } = parts; + + if !headers.contains_key(ACCEPT_ENCODING) { + headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity")); + } + + let fields = request_fields(&headers)?; + let options = request_options(budget, &self.clock)?; + let (writer, contents, trailers) = BodyWriter::new(); + let (request, request_done) = + Request::new(fields, Some(contents), trailers, Some(options)); + set_request_target(&request, &method, &uri)?; + let exchange = async move { + let upload = pump_request_body( + body, + max_request_body_bytes, + writer, + budget, + self.clock.clone(), + ); + let send = client::send(request); + run_exchange(send, upload, request_done, |error| { + map_spin_send_error(error, budget.deadline, budget.cause, self.clock.now()) + }) + .await + }; + let response = race_deadline(exchange, budget, &self.clock).await??; + + process_response( + response, + method, + response_mode, + budget, + max_decoder_bytes, + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, + max_response_header_bytes, + max_response_header_count, + self.clock.clone(), + ) + .await + } + } + + impl Default for SpinOutboundClient { + #[inline] + fn default() -> Self { + Self::new() + } + } + + #[async_trait(?Send)] + impl OutboundHttpClient for SpinOutboundClient { + #[inline] + async fn send(&self, request: OutboundRequest) -> Result { + let started_at = self.clock.now(); + let prepared = Self::prepare(request, started_at)?; + self.execute(prepared).await + } + + #[inline] + fn start_batch_until( + &self, + requests: Vec, + observation_cutoff: Deadline, + ) -> OutboundBatch { + let batch_started_at = self.clock.now(); + let slot_count = requests.len(); + if observation_cutoff.is_expired_at(batch_started_at) { + return OutboundBatch::cutoff(slot_count); + } + + let mut completed = Vec::new(); + let mut pending_slots = Vec::>::new(); + for (index, request) in requests.into_iter().enumerate() { + match Self::prepare_batch(request, batch_started_at, observation_cutoff) { + Ok(prepared) => { + let client = self.clone(); + pending_slots.push( + async move { + let outcome = client.execute(prepared).await; + (index, client.clock.now(), outcome) + } + .boxed_local(), + ); + } + Err(error) => completed.push((index, self.clock.now(), Err(error))), + } + } + + OutboundBatch::from_futures( + slot_count, + batch_started_at, + observation_cutoff, + completed, + pending_slots, + ) + } + } + + fn request_fields(headers: &HeaderMap) -> Result { + let fields = Fields::new(); + for (name, value) in headers { + fields + .append(name.as_str(), value.as_bytes()) + .map_err(|_error| { + EdgeError::bad_request("Spin rejected an outbound request header") + })?; + } + Ok(fields) + } + + fn request_options( + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result { + let options = RequestOptions::new(); + let remaining = budget_remaining(budget, clock)?; + let timeouts = super::request_timeouts(remaining); + set_timeout_option( + "connect", + &options.set_connect_timeout(Some(timeouts.connect())), + )?; + set_timeout_option( + "first-byte", + &options.set_first_byte_timeout(Some(timeouts.first_byte())), + )?; + set_timeout_option( + "between-bytes", + &options.set_between_bytes_timeout(Some(timeouts.between_bytes())), + )?; + Ok(options) + } + + fn set_timeout_option( + name: &str, + outcome: &Result<(), RequestOptionsError>, + ) -> Result<(), EdgeError> { + match outcome { + Ok(()) => Ok(()), + Err(RequestOptionsError::NotSupported) => { + log::debug!("Spin host does not support the outbound {name} timeout option"); + Ok(()) + } + Err(RequestOptionsError::Immutable) => Err(EdgeError::internal(anyhow::anyhow!( + "Spin outbound {name} timeout option is immutable" + ))), + Err(RequestOptionsError::Other(_)) => Err(EdgeError::internal(anyhow::anyhow!( + "Spin host rejected the outbound {name} timeout option" + ))), + } + } + + fn set_request_target(request: &Request, method: &Method, uri: &Uri) -> Result<(), EdgeError> { + request + .set_method(&wasi_method(method)) + .map_err(|()| EdgeError::bad_request("Spin rejected the outbound request method"))?; + let scheme = match uri.scheme_str() { + Some("http") => Scheme::Http, + Some("https") => Scheme::Https, + Some(other) => Scheme::Other(other.to_owned()), + None => return Err(EdgeError::bad_request("outbound request URI has no scheme")), + }; + request + .set_scheme(Some(&scheme)) + .map_err(|()| EdgeError::bad_request("Spin rejected the outbound request scheme"))?; + let authority = uri + .authority() + .ok_or_else(|| EdgeError::bad_request("outbound request URI has no authority"))?; + request + .set_authority(Some(authority.as_str())) + .map_err(|()| EdgeError::bad_request("Spin rejected the outbound request authority"))?; + request + .set_path_with_query(Some( + uri.path_and_query() + .map_or("/", |path_and_query| path_and_query.as_str()), + )) + .map_err(|()| EdgeError::bad_request("Spin rejected the outbound request path"))?; + Ok(()) + } + + fn wasi_method(method: &Method) -> WasiMethod { + match *method { + Method::GET => WasiMethod::Get, + Method::HEAD => WasiMethod::Head, + Method::POST => WasiMethod::Post, + Method::PUT => WasiMethod::Put, + Method::DELETE => WasiMethod::Delete, + Method::CONNECT => WasiMethod::Connect, + Method::OPTIONS => WasiMethod::Options, + Method::TRACE => WasiMethod::Trace, + Method::PATCH => WasiMethod::Patch, + _ => WasiMethod::Other(method.as_str().to_owned()), + } + } + + async fn pump_request_body( + body: Body, + maximum: u64, + writer: BodyWriter, + budget: DispatchBudget, + clock: MonotonicClock, + ) -> Result { + let BodyWriter { + mut stream_writer, + result_writer, + .. + } = writer; + let mut total = 0_u64; + let mut source = match body { + Body::Once(bytes) => once(async move { Ok(bytes) }).boxed_local(), + Body::Stream(source) => source, + }; + + // `BodyWriter` configures `result_writer` to publish a typed host error on drop. + // Deadline returns can therefore signal failure without awaiting after budget expiry. + while let Some(item) = source.next().await { + budget_remaining(budget, &clock)?; + let bytes = match item { + Ok(bytes) => bytes, + Err(error) => { + drop(stream_writer); + signal_upload_failure(result_writer, ErrorCode::InternalError(None)).await; + return Err(error); + } + }; + let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); + let Some(next_total) = total.checked_add(length) else { + drop(stream_writer); + signal_upload_failure(result_writer, ErrorCode::HttpRequestBodySize(None)).await; + return Err(EdgeError::bad_request( + "outbound request body size accounting overflow", + )); + }; + if next_total > maximum { + drop(stream_writer); + signal_upload_failure( + result_writer, + ErrorCode::HttpRequestBodySize(Some(next_total)), + ) + .await; + return Err(EdgeError::bad_request( + "outbound request body exceeded configured limit", + )); + } + let unwritten = stream_writer.write_all(bytes.to_vec()).await; + budget_remaining(budget, &clock)?; + if !unwritten.is_empty() { + drop(stream_writer); + drop(result_writer.write(Ok(None)).await); + return Ok(UploadCompletion::ReaderGone); + } + total = next_total; + cooperative_yield_once().await; + } + + drop(stream_writer); + if result_writer.write(Ok(None)).await.is_err() { + return Ok(UploadCompletion::ReaderGone); + } + Ok(UploadCompletion::Complete) + } + + async fn signal_upload_failure( + writer: FutureWriter, ErrorCode>>, + error: ErrorCode, + ) { + match writer.write(Err(error)).await { + Ok(()) | Err(_) => {} + } + } + + #[expect( + clippy::too_many_arguments, + reason = "the adapter consumes independent response-policy fields" + )] + async fn process_response( + response: Response, + request_method: Method, + response_mode: ResponseMode, + budget: DispatchBudget, + max_decoder_bytes: u64, + max_brotli_window_bits: u8, + max_chunk_bytes: Option, + max_decoded_response_bytes: Option, + max_encoded_response_bytes: Option, + max_response_header_bytes: Option, + max_response_header_count: Option, + clock: MonotonicClock, + ) -> Result { + budget_remaining(budget, &clock)?; + let response_clock = clock.clone(); + let status = StatusCode::from_u16(response.get_status_code()).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "Spin returned an invalid upstream status code", + BadGatewayReason::Protocol, + ) + })?; + let mut headers = response_headers(&response)?; + let mut header_limiter = + ResponseHeaderLimiter::new(max_response_header_bytes, max_response_header_count); + header_limiter.observe(&headers)?; + let normalization = normalize_response_headers(&request_method, status, &mut headers)?; + let disposition = normalization.body_disposition; + insert_proxy_header( + &mut headers, + &mut header_limiter, + HeaderValue::from_static("spin"), + )?; + + let native = response_stream(response, budget, clock.clone()); + if disposition == ResponseBodyDisposition::FramingBodyless { + return Ok(OutboundResponse::new( + request_method, + status, + headers, + Body::empty(), + response_clock, + normalization.content_type_provenance, + )); + } + + let declared_reset_body = matches!( + disposition, + ResponseBodyDisposition::ResetContent { + declared_body: true + } + ); + if matches!(disposition, ResponseBodyDisposition::ResetContent { .. }) { + settle_reset_content(native, declared_reset_body).await?; + return Ok(OutboundResponse::new( + request_method, + status, + headers, + Body::empty(), + response_clock, + normalization.content_type_provenance, + )); + } + + let shaped = apply_response_body_policy( + native, + &mut headers, + ResponseBodyPolicy { + max_brotli_window_bits, + max_chunk_bytes, + max_decoded_response_bytes, + max_decoder_bytes, + max_encoded_response_bytes, + response_mode, + }, + )?; + let deadline_bound = deadline_stream(super::cooperative_stream(shaped), budget, clock); + let body = match response_mode { + ResponseMode::Buffered { max_bytes } => { + Body::from(collect_response_stream(deadline_bound, max_bytes).await?) + } + ResponseMode::Streamed => Body::from_stream(deadline_bound), + }; + Ok(OutboundResponse::new( + request_method, + status, + headers, + body, + response_clock, + normalization.content_type_provenance, + )) + } + + fn response_headers(response: &Response) -> Result { + let fields = response.get_headers(); + let mut headers = HeaderMap::new(); + for (raw_name, raw_value) in fields.copy_all() { + let header_name = HeaderName::from_bytes(raw_name.as_bytes()).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "Spin returned an invalid upstream header name", + BadGatewayReason::Protocol, + ) + })?; + let header_value = HeaderValue::from_bytes(&raw_value).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "Spin returned an invalid upstream header value", + BadGatewayReason::Protocol, + ) + })?; + headers.append(header_name, header_value); + } + Ok(headers) + } + + fn default_response_result() -> Result<(), ErrorCode> { + Err(ErrorCode::InternalError(Some( + "response body consumer dropped before completion".to_owned(), + ))) + } + + fn response_stream( + response: Response, + budget: DispatchBudget, + clock: MonotonicClock, + ) -> BodyStream { + let (completion_writer, result_reader) = wit_future::new(default_response_result); + let (mut body_reader, trailer_reader) = Response::consume_body(response, result_reader); + stream! { + let mut ready_reads = 0_usize; + loop { + let read = body_reader.read(Vec::with_capacity(RESPONSE_READ_BYTES)); + let (result, chunk) = match race_deadline(read, budget, &clock).await { + Ok(value) => value, + Err(error) => { + yield Err(error); + return; + } + }; + match result { + StreamResult::Complete(length) => { + if should_emit_response_chunk(length) { + yield Ok(Bytes::from(chunk)); + } + cooperate_after_response_read(&mut ready_reads).await; + } + StreamResult::Dropped => { + let trailer_result = race_deadline( + async move { trailer_reader.await }, + budget, + &clock, + ) + .await; + match trailer_result { + Ok(Ok(_trailers)) => {} + Ok(Err(error)) => { + yield Err(map_spin_send_error( + &error, + budget.deadline, + budget.cause, + clock.now(), + )); + return; + } + Err(error) => { + yield Err(error); + return; + } + } + let completion = completion_writer.write(Ok(())); + match race_deadline(completion, budget, &clock).await { + Ok(Ok(())) => return, + Ok(Err(_closed)) => { + yield Err(EdgeError::bad_gateway_with_reason( + "Spin response completion reader closed", + BadGatewayReason::Protocol, + )); + return; + } + Err(error) => { + yield Err(error); + return; + } + } + } + StreamResult::Cancelled => { + yield Err(EdgeError::bad_gateway_with_reason( + "Spin cancelled the upstream response body read", + BadGatewayReason::Transport, + )); + return; + } + } + } + } + .boxed_local() + } + + fn deadline_stream( + mut source: BodyStream, + budget: DispatchBudget, + clock: MonotonicClock, + ) -> BodyStream { + stream! { + loop { + let next_item = match race_deadline(source.next(), budget, &clock).await { + Ok(item) => item, + Err(error) => { + yield Err(error); + return; + } + }; + match next_item { + Some(Ok(bytes)) => yield Ok(bytes), + Some(Err(error)) => { + yield Err(error); + return; + } + None => return, + } + } + } + .boxed_local() + } + + async fn race_deadline( + future: impl Future, + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result { + let remaining = budget_remaining(budget, clock)?; + let timer = sleep(remaining); + futures_util::pin_mut!(future, timer); + let output = match select(future, timer).await { + Either::Left((output, _timer)) => output, + Either::Right(((), _future)) => return Err(timeout_error(budget.cause)), + }; + budget_remaining(budget, clock)?; + Ok(output) + } + + fn budget_remaining( + budget: DispatchBudget, + clock: &MonotonicClock, + ) -> Result { + budget + .deadline + .remaining_at(clock.now()) + .map(|remaining| remaining.min(budget.duration)) + .ok_or_else(|| timeout_error(budget.cause)) + } + + /// Runs request-option, response-clock, and fairness checks in the hosted contract binary. + #[cfg(feature = "test-utils")] + #[doc(hidden)] + #[inline] + pub async fn deferred_clock_paths_hold_for_test() -> bool { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let start = MonotonicInstant::now(); + let Ok(request) = OutboundRequest::get("https://example.com/") else { + return false; + }; + let Ok(budget) = dispatch_budget(&request.timeout(Duration::from_millis(10)), start, None) + else { + return false; + }; + let observed = start.checked_add(Duration::from_millis(3)).unwrap_or(start); + let option_observations = Arc::new(AtomicUsize::new(0)); + let observed_options = Arc::clone(&option_observations); + let option_clock = MonotonicClock::new(move || { + observed_options.fetch_add(1, Ordering::SeqCst); + observed + }); + if request_options(budget, &option_clock).is_err() + || option_observations.load(Ordering::SeqCst) != 1 + { + return false; + } + + let mut ready_reads = 0_usize; + for _ in 0..super::READY_ITEM_YIELD_QUOTA { + cooperate_after_response_read(&mut ready_reads).await; + } + if ready_reads != 0 { + return false; + } + + let stream_observations = Arc::new(AtomicUsize::new(0)); + let observed_stream = Arc::clone(&stream_observations); + let deadline = budget.deadline.instant(); + let stream_clock = MonotonicClock::new(move || { + if observed_stream.fetch_add(1, Ordering::SeqCst) == 0 { + start + } else { + deadline + } + }); + let source = once(async { Ok(Bytes::from_static(b"body")) }).boxed_local(); + let mut body = deadline_stream(source, budget, stream_clock); + + matches!( + body.next().await, + Some(Err(EdgeError::GatewayTimeout { .. })) + ) + } + + /// Exercises original Content-Type evidence through hosted response conversion. + #[cfg(feature = "test-utils")] + #[doc(hidden)] + #[inline] + pub async fn converted_response_content_type_provenance_holds_for_test() -> bool { + content_type_tests::check_converted_response_content_type_provenance() + .await + .is_ok() + } + + /// Proves response conversion retains the client clock in the hosted contract binary. + #[cfg(feature = "test-utils")] + #[doc(hidden)] + #[inline] + pub async fn converted_response_retains_injected_clock_for_test() -> bool { + use std::sync::{Arc, Mutex}; + + fn set_clock(clock: &Mutex, observed_at: MonotonicInstant) -> bool { + let Ok(mut current) = clock.lock() else { + return false; + }; + *current = observed_at; + true + } + + let start = MonotonicInstant::now(); + let duration = Duration::from_secs(1); + let budget = DispatchBudget { + cause: BudgetSource::PerCallTimeout, + deadline: Deadline::at_instant(start.checked_add(duration).unwrap_or(start)), + duration, + }; + let clock_now = Arc::new(Mutex::new(start)); + let clock_observed = Arc::clone(&clock_now); + let clock = + MonotonicClock::new(move || clock_observed.lock().map_or(start, |current| *current)); + let (writer, contents, trailers) = BodyWriter::new(); + let (native, response_done) = Response::new(Fields::new(), Some(contents), trailers); + if native + .set_status_code(StatusCode::NO_CONTENT.as_u16()) + .is_err() + { + return false; + } + let Ok(response) = process_response( + native, + Method::GET, + ResponseMode::Streamed, + budget, + 32 * 1024 * 1024, + 24, + None, + None, + None, + None, + None, + clock, + ) + .await + else { + return false; + }; + drop((writer, response_done)); + if !set_clock(&clock_now, budget.deadline.instant()) { + return false; + } + + matches!( + response.into_bytes_bounded_until(1, budget.deadline).await, + Err(EdgeError::GatewayTimeout { .. }) + ) + } + + #[cfg(test)] + mod clock_tests { + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + + use edgezero_core::error::BudgetSource; + use edgezero_core::time::Deadline; + use futures::executor::block_on; + use futures_util::stream; + + use super::*; + + fn scripted_clock(script: Vec) -> MonotonicClock { + let observations = Arc::new(Mutex::new(VecDeque::from(script))); + MonotonicClock::new(move || { + observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + }) + } + + fn test_budget(start: MonotonicInstant, duration: Duration) -> DispatchBudget { + DispatchBudget { + cause: BudgetSource::PerCallTimeout, + deadline: Deadline::at_instant(start.checked_add(duration).expect("deadline")), + duration, + } + } + + #[test] + fn method_entry_and_preflight_elapsed_use_the_injected_clock() { + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(9)) + .expect("completed instant"); + let client = SpinOutboundClient::with_clock(scripted_clock(vec![start, completed])); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = block_on( + client + .start_batch_until( + vec![request], + Deadline::at_instant( + start + .checked_add(Duration::from_secs(1)) + .expect("cutoff instant"), + ), + ) + .collect(), + ) + .expect("valid batch driver"); + + let slot = results.slots[0].as_ref().expect("resolved slot"); + assert_eq!(slot.elapsed, Duration::from_millis(9)); + assert!(matches!(slot.outcome, Err(EdgeError::BadRequest { .. }))); + } + + #[test] + fn backwards_clock_fails_slot_without_invalid_elapsed() { + let start = MonotonicInstant::now(); + let earlier = start + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let client = SpinOutboundClient::with_clock(scripted_clock(vec![start, earlier])); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = block_on( + client + .start_batch_until( + vec![request], + Deadline::at_instant( + start + .checked_add(Duration::from_secs(1)) + .expect("cutoff instant"), + ), + ) + .collect(), + ) + .expect("valid batch driver"); + + let slot = results.slots[0].as_ref().expect("resolved slot"); + assert_eq!(slot.elapsed, Duration::ZERO); + assert!(matches!(slot.outcome, Err(EdgeError::Internal { .. }))); + } + + #[test] + fn backwards_clock_cannot_expand_the_selected_budget() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let earlier = start + .checked_sub(Duration::from_millis(5)) + .expect("earlier instant"); + let clock = scripted_clock(vec![earlier]); + + assert_eq!( + budget_remaining(budget, &clock).expect("remaining budget"), + budget.duration + ); + } + + #[test] + fn request_option_preparation_consumes_injected_budget() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let observed = start + .checked_add(Duration::from_millis(3)) + .expect("observed instant"); + let observations = Arc::new(Mutex::new(VecDeque::from([observed]))); + let clock_observations = Arc::clone(&observations); + let clock = MonotonicClock::new(move || { + clock_observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + }); + + let _options = request_options(budget, &clock).expect("request options"); + + assert!(observations.lock().expect("clock observations").is_empty()); + } + + #[test] + fn response_stream_retains_clock_for_post_ready_expiry() { + let start = MonotonicInstant::now(); + let budget = test_budget(start, Duration::from_millis(10)); + let clock = scripted_clock(vec![start, budget.deadline.instant()]); + let source = stream::once(async { Ok(Bytes::from_static(b"body")) }).boxed_local(); + let mut body = deadline_stream(source, budget, clock); + + let error = block_on(body.next()) + .expect("terminal item") + .expect_err("post-ready expiry"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[cfg(feature = "test-utils")] + #[test] + fn converted_response_retains_injected_clock() { + assert!(block_on( + converted_response_retains_injected_clock_for_test() + )); + } + + #[test] + fn converted_response_preserves_original_content_type_provenance() { + block_on(super::content_type_tests::check_converted_response_content_type_provenance()) + .expect("converted content-type provenance"); + } + } + + #[cfg(any(test, feature = "test-utils"))] + mod content_type_tests { + use edgezero_core::error::BudgetSource; + use edgezero_core::http::header::{CONNECTION, CONTENT_LENGTH, CONTENT_TYPE}; + use edgezero_core::outbound::{ContentTypeIneligibility, ContentTypeProvenance}; + use spin_sdk::wasip3::http::types::HeaderError; + + use super::*; + + type ContentTypeCase = (&'static [&'static [u8]], ContentTypeProvenance, usize); + + fn content_type_cases() -> &'static [ContentTypeCase] { + use ContentTypeIneligibility::{Missing, MultipleValues, NonText}; + use ContentTypeProvenance::{Ineligible, SingleTextUnchanged}; + + &[ + (&[], Ineligible(Missing), 0), + (&[b"text/plain"], SingleTextUnchanged, 1), + (&[b"text/plain; label=caf\xc3\xa9"], SingleTextUnchanged, 1), + ( + &[b"text/plain", b"text/plain"], + Ineligible(MultipleValues), + 2, + ), + ( + &[b"text/plain", b"application/json"], + Ineligible(MultipleValues), + 2, + ), + (&[b"text/plain", b"\xff"], Ineligible(MultipleValues), 1), + (&[b"\xff"], Ineligible(NonText), 0), + (&[b"text/plain, application/json"], SingleTextUnchanged, 1), + (&[b"not a MIME type"], SingleTextUnchanged, 1), + ] + } + + async fn convert_response( + values: &[&[u8]], + status: StatusCode, + mode: ResponseMode, + expected: ContentTypeProvenance, + retained_count: usize, + ) -> Result<(), EdgeError> { + let start = MonotonicInstant::now(); + let fields = Fields::new(); + for value in values { + fields + .append(CONTENT_TYPE.as_str(), value) + .map_err(EdgeError::internal)?; + } + if status == StatusCode::RESET_CONTENT { + fields + .append(CONTENT_LENGTH.as_str(), b"1") + .map_err(EdgeError::internal)?; + } + let (writer, contents, trailers) = BodyWriter::new(); + let (native, response_done) = Response::new(fields, Some(contents), trailers); + native + .set_status_code(status.as_u16()) + .map_err(|()| EdgeError::bad_gateway("invalid test response status"))?; + let deadline = start + .checked_add(Duration::from_secs(1)) + .ok_or_else(|| EdgeError::bad_gateway("test deadline overflow"))?; + let result = process_response( + native, + Method::GET, + mode, + DispatchBudget { + cause: BudgetSource::PerCallTimeout, + deadline: Deadline::at_instant(deadline), + duration: Duration::from_secs(1), + }, + 1024, + 24, + None, + None, + None, + None, + None, + MonotonicClock::new(move || start), + ) + .await; + let checked = result + .and_then(|response| check_response(response, values, expected, retained_count)); + drop((writer, response_done)); + checked + } + + fn check_response( + mut response: OutboundResponse, + values: &[&[u8]], + expected: ContentTypeProvenance, + retained_count: usize, + ) -> Result<(), EdgeError> { + use ContentTypeIneligibility::Modified; + use ContentTypeProvenance::{Ineligible, SingleTextUnchanged}; + + if response.content_type_provenance() != expected + || response.headers().get_all(CONTENT_TYPE).iter().count() != retained_count + { + return Err(EdgeError::bad_gateway( + "converted test response lost original content-type provenance", + )); + } + if expected == SingleTextUnchanged + && response + .headers() + .get(CONTENT_TYPE) + .map(HeaderValue::as_bytes) + != values.first().copied() + { + return Err(EdgeError::bad_gateway( + "converted test response changed content-type bytes", + )); + } + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + let after_mutation = if expected == SingleTextUnchanged { + Ineligible(Modified) + } else { + expected + }; + if response.content_type_provenance() != after_mutation { + return Err(EdgeError::bad_gateway( + "test response mutation upgraded original provenance", + )); + } + Ok(()) + } + + pub(super) async fn check_converted_response_content_type_provenance() + -> Result<(), EdgeError> { + if !matches!( + Fields::new().append(CONNECTION.as_str(), b"content-type"), + Err(HeaderError::Forbidden) + ) { + return Err(EdgeError::bad_gateway( + "test host must forbid connection fields", + )); + } + for &(values, expected, retained_count) in content_type_cases() { + for (status, mode) in [ + (StatusCode::OK, ResponseMode::Streamed), + (StatusCode::NO_CONTENT, ResponseMode::Streamed), + ( + StatusCode::NO_CONTENT, + ResponseMode::Buffered { max_bytes: 64 }, + ), + (StatusCode::RESET_CONTENT, ResponseMode::Streamed), + ( + StatusCode::RESET_CONTENT, + ResponseMode::Buffered { max_bytes: 64 }, + ), + ] { + convert_response(values, status, mode, expected, retained_count).await?; + } + } + Ok(()) + } + } +} + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub use spin_impl::SpinOutboundClient; +#[cfg(all(feature = "spin", target_arch = "wasm32", feature = "test-utils"))] +pub use spin_impl::{ + converted_response_content_type_provenance_holds_for_test, + converted_response_retains_injected_clock_for_test, deferred_clock_paths_hold_for_test, +}; + +#[cfg(all(feature = "spin", any(feature = "test-utils", target_arch = "wasm32")))] +fn map_spin_send_error( + error: &ErrorCode, + deadline: Deadline, + cause: BudgetSource, + observed_at: edgezero_core::MonotonicInstant, +) -> EdgeError { + if deadline.is_expired_at(observed_at) { + return timeout_error(cause); + } + + match error { + ErrorCode::DnsTimeout + | ErrorCode::ConnectionTimeout + | ErrorCode::ConnectionReadTimeout + | ErrorCode::ConnectionWriteTimeout + | ErrorCode::HttpResponseTimeout => timeout_error(BudgetSource::Unspecified), + ErrorCode::HttpRequestDenied + | ErrorCode::HttpRequestBodySize(_) + | ErrorCode::HttpRequestUriTooLong + | ErrorCode::HttpRequestHeaderSectionSize(_) + | ErrorCode::HttpRequestHeaderSize(_) => { + EdgeError::bad_request("outbound request was rejected by the Spin HTTP host") + } + ErrorCode::HttpRequestLengthRequired + | ErrorCode::HttpRequestMethodInvalid + | ErrorCode::HttpRequestUriInvalid + | ErrorCode::HttpRequestTrailerSectionSize(_) + | ErrorCode::HttpRequestTrailerSize(_) + | ErrorCode::ConfigurationError => EdgeError::internal(anyhow::anyhow!( + "Spin rejected an adapter-owned outbound request invariant" + )), + ErrorCode::DnsError(_) + | ErrorCode::DestinationNotFound + | ErrorCode::DestinationUnavailable + | ErrorCode::DestinationIpProhibited + | ErrorCode::DestinationIpUnroutable + | ErrorCode::ConnectionRefused + | ErrorCode::ConnectionLimitReached + | ErrorCode::TlsProtocolError + | ErrorCode::TlsCertificateError + | ErrorCode::TlsAlertReceived(_) => EdgeError::bad_gateway_with_reason( + "outbound destination could not be reached", + BadGatewayReason::Unreachable, + ), + ErrorCode::ConnectionTerminated => EdgeError::bad_gateway_with_reason( + "outbound connection failed after dispatch", + BadGatewayReason::Transport, + ), + ErrorCode::HttpResponseIncomplete + | ErrorCode::HttpResponseHeaderSectionSize(_) + | ErrorCode::HttpResponseHeaderSize(_) + | ErrorCode::HttpResponseBodySize(_) + | ErrorCode::HttpResponseTrailerSectionSize(_) + | ErrorCode::HttpResponseTrailerSize(_) + | ErrorCode::HttpResponseTransferCoding(_) + | ErrorCode::HttpResponseContentCoding(_) + | ErrorCode::HttpUpgradeFailed + | ErrorCode::HttpProtocolError + | ErrorCode::LoopDetected => EdgeError::bad_gateway_with_reason( + "upstream response violated the HTTP protocol", + BadGatewayReason::Protocol, + ), + ErrorCode::InternalError(_) => EdgeError::bad_gateway_with_reason( + "Spin outbound HTTP host failed", + BadGatewayReason::Unspecified, + ), + } +} + +#[cfg(all(feature = "spin", any(feature = "test-utils", target_arch = "wasm32")))] +fn timeout_error(cause: BudgetSource) -> EdgeError { + EdgeError::gateway_timeout_caused("outbound request deadline expired", cause) +} + +#[cfg(any(feature = "test-utils", all(feature = "spin", target_arch = "wasm32")))] +fn validate_batch_request(request: &OutboundRequest) -> Result<(), EdgeError> { + validate_request(request)?; + if request.is_stream_body() { + return Err(EdgeError::bad_request( + "outbound batches require buffered request bodies; use send for a streamed upload", + )); + } + if request.is_stream_response() { + return Err(EdgeError::bad_request( + "outbound batches require buffered responses; use send for a streamed response", + )); + } + Ok(()) +} + +#[cfg(any(feature = "test-utils", all(feature = "spin", target_arch = "wasm32")))] +fn validate_request(request: &OutboundRequest) -> Result<(), EdgeError> { + validate_for_dispatch(request)?; + if request.has_host_authority_override() { + return Err(EdgeError::bad_request( + "Spin cannot separate the outbound Host authority from the connection target", + )); + } + Ok(()) +} + +/// Runs the target-neutral Spin batch preflight contract in native tests. +/// +/// # Errors +/// Returns the same portable validation or batch-shape error as production dispatch. +#[cfg(feature = "test-utils")] +#[inline] +pub fn validate_batch_request_for_test(request: &OutboundRequest) -> Result<(), EdgeError> { + validate_batch_request(request) +} + +/// Runs the target-neutral Spin request policy before any provider dispatch. +/// +/// # Errors +/// Returns the same portable validation or unsupported-authority error as production dispatch. +#[cfg(feature = "test-utils")] +#[inline] +pub fn validate_request_for_test(request: &OutboundRequest) -> Result<(), EdgeError> { + validate_request(request) +} + +/// Exposes the real pinned SDK classifier to WASI resource contract tests. +#[cfg(all(feature = "spin", feature = "test-utils"))] +#[must_use] +#[inline] +pub fn map_spin_send_error_for_test( + error: &ErrorCode, + deadline: Deadline, + cause: BudgetSource, + observed_at: edgezero_core::MonotonicInstant, +) -> EdgeError { + map_spin_send_error(error, deadline, cause, observed_at) +} + +#[cfg(test)] +mod exchange_tests { + use std::cell::Cell; + use std::collections::VecDeque; + use std::future::Future; + use std::pin::Pin; + use std::rc::Rc; + use std::task::{Context, Poll}; + use std::time::Duration; + + use futures::executor::block_on; + + use super::{ + EdgeError, READY_ITEM_YIELD_QUOTA, UploadCompletion, cooperate_after_response_read, + cooperative_stream, cooperative_yield_once, duration_nanos, request_timeouts, run_exchange, + settle_reset_content, should_emit_response_chunk, + }; + + struct ScriptedFuture { + drops: Rc>, + polls: Rc>, + steps: VecDeque>, + } + + impl Unpin for ScriptedFuture {} + + impl ScriptedFuture { + fn new( + steps: impl IntoIterator>, + polls: Rc>, + drops: Rc>, + ) -> Self { + Self { + drops, + polls, + steps: steps.into_iter().collect(), + } + } + } + + impl Future for ScriptedFuture { + type Output = Output; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + self.polls.set(self.polls.get().saturating_add(1)); + let step = self.steps.pop_front().expect("scripted future exhausted"); + if step.is_pending() { + cx.waker().wake_by_ref(); + } + step + } + } + + impl Drop for ScriptedFuture { + fn drop(&mut self) { + self.drops.set(self.drops.get().saturating_add(1)); + } + } + + fn counters() -> (Rc>, Rc>) { + (Rc::new(Cell::new(0)), Rc::new(Cell::new(0))) + } + + fn map_host_error(_error: &&'static str) -> EdgeError { + EdgeError::bad_gateway("scripted host failure") + } + + #[test] + fn continuously_ready_chunks_yield_after_each_item() { + use futures_util::StreamExt as _; + use futures_util::stream; + use futures_util::task::noop_waker; + + let processed = Rc::new(Cell::new(0_u32)); + let observed = Rc::clone(&processed); + let mut source = stream::iter([(), (), ()]); + let mut pump = Box::pin(async move { + while source.next().await.is_some() { + observed.set(observed.get().saturating_add(1)); + cooperative_yield_once().await; + } + }); + let waker = noop_waker(); + let mut context = Context::from_waker(&waker); + + assert!(pump.as_mut().poll(&mut context).is_pending()); + assert_eq!(processed.get(), 1); + assert!(pump.as_mut().poll(&mut context).is_pending()); + assert_eq!(processed.get(), 2); + assert!(pump.as_mut().poll(&mut context).is_pending()); + assert_eq!(processed.get(), 3); + assert!(pump.as_mut().poll(&mut context).is_ready()); + } + + #[test] + fn request_timeouts_use_full_remaining_budget_for_every_phase() { + let timeouts = request_timeouts(Duration::from_millis(40)); + assert_eq!(timeouts.connect(), 40_000_000); + assert_eq!(timeouts.first_byte(), 40_000_000); + assert_eq!(timeouts.between_bytes(), 40_000_000); + } + + #[test] + fn duration_nanos_floors_and_saturates_without_wrapping() { + assert_eq!(duration_nanos(Duration::ZERO), 1); + assert_eq!(duration_nanos(Duration::from_nanos(1)), 1); + assert_eq!(duration_nanos(Duration::from_nanos(999)), 999); + assert_eq!(duration_nanos(Duration::MAX), u64::MAX); + } + + #[test] + fn cooperative_stream_stops_after_the_first_error() { + use bytes::Bytes; + use futures_util::StreamExt as _; + use futures_util::stream; + + let source = stream::iter([ + Err(EdgeError::bad_gateway("terminal")), + Ok(Bytes::from_static(b"must not escape")), + ]) + .boxed_local(); + let mut cooperative = cooperative_stream(source); + + assert!(matches!( + block_on(cooperative.next()), + Some(Err(EdgeError::BadGateway { .. })) + )); + assert!(block_on(cooperative.next()).is_none()); + } + + #[test] + fn zero_length_complete_does_not_emit_an_empty_response_chunk() { + assert!(!should_emit_response_chunk(0)); + assert!(should_emit_response_chunk(1)); + } + + #[test] + fn reset_content_settlement_is_bounded_and_releases_source() { + use bytes::Bytes; + use futures_util::StreamExt as _; + use futures_util::stream::poll_fn; + + struct DropSignal(Rc>); + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.set(self.0.get().saturating_add(1)); + } + } + + let polls = Rc::new(Cell::new(0_u32)); + let drops = Rc::new(Cell::new(0_u32)); + let observed_polls = Rc::clone(&polls); + let signal = DropSignal(Rc::clone(&drops)); + let source = poll_fn(move |_context| { + let _keep_signal_alive = &signal; + observed_polls.set(observed_polls.get().saturating_add(1)); + Poll::Ready(Some(Ok(Bytes::from_static(b"illegal")))) + }) + .boxed_local(); + + block_on(settle_reset_content(source, false)).expect("205 settlement"); + + assert_eq!(polls.get(), 1); + assert_eq!(drops.get(), 1); + + let declared_polls = Rc::new(Cell::new(0_u32)); + let declared_drops = Rc::new(Cell::new(0_u32)); + let observed_declared_polls = Rc::clone(&declared_polls); + let declared_signal = DropSignal(Rc::clone(&declared_drops)); + let declared_source = poll_fn(move |_context| { + let _keep_signal_alive = &declared_signal; + observed_declared_polls.set(observed_declared_polls.get().saturating_add(1)); + Poll::Pending::>> + }) + .boxed_local(); + + block_on(settle_reset_content(declared_source, true)).expect("declared 205 settlement"); + + assert_eq!(declared_polls.get(), 0); + assert_eq!(declared_drops.get(), 1); + + let error_drops = Rc::new(Cell::new(0_u32)); + let error_signal = DropSignal(Rc::clone(&error_drops)); + let error_source = poll_fn(move |_context| { + let _keep_signal_alive = &error_signal; + Poll::Ready(Some(Err(EdgeError::bad_gateway("205 source failed")))) + }) + .boxed_local(); + + block_on(settle_reset_content(error_source, false)).expect_err("205 source error"); + + assert_eq!(error_drops.get(), 1); + } + + #[test] + fn zero_length_response_reads_force_a_pending_boundary_at_the_quota() { + use futures_util::task::noop_waker; + + let processed = Rc::new(Cell::new(0_usize)); + let observed = Rc::clone(&processed); + let mut task = Box::pin(async move { + let mut ready_reads = 0_usize; + for _ in 0..READY_ITEM_YIELD_QUOTA.saturating_add(1) { + observed.set(observed.get().saturating_add(1)); + cooperate_after_response_read(&mut ready_reads).await; + } + }); + let waker = noop_waker(); + let mut context = Context::from_waker(&waker); + + assert!(task.as_mut().poll(&mut context).is_pending()); + assert_eq!(processed.get(), READY_ITEM_YIELD_QUOTA); + assert!(task.as_mut().poll(&mut context).is_ready()); + assert_eq!(processed.get(), READY_ITEM_YIELD_QUOTA.saturating_add(1)); + } + + #[test] + fn cooperative_stream_forces_a_pending_boundary_at_its_quota() { + use std::iter::repeat_with; + + use bytes::Bytes; + use futures_util::StreamExt as _; + use futures_util::stream; + use futures_util::task::noop_waker; + + let mut source = cooperative_stream( + stream::iter( + repeat_with(|| Ok::(Bytes::new())) + .take(READY_ITEM_YIELD_QUOTA.saturating_add(1)), + ) + .boxed_local(), + ); + let waker = noop_waker(); + let mut context = Context::from_waker(&waker); + + for _ in 0..READY_ITEM_YIELD_QUOTA { + assert!(matches!( + source.as_mut().poll_next(&mut context), + Poll::Ready(Some(Ok(_))) + )); + } + assert!(source.as_mut().poll_next(&mut context).is_pending()); + assert!(matches!( + source.as_mut().poll_next(&mut context), + Poll::Ready(Some(Ok(_))) + )); + } + + #[test] + fn upload_failure_wins_over_simultaneously_ready_send() { + let (send_polls, send_drops) = counters(); + let (pump_polls, pump_drops) = counters(); + let (done_polls, done_drops) = counters(); + let send = ScriptedFuture::new( + [Poll::Ready(Ok(7_u8))], + Rc::clone(&send_polls), + Rc::clone(&send_drops), + ); + let pump = ScriptedFuture::new( + [Poll::Ready(Err(EdgeError::bad_request("upload failed")))], + Rc::clone(&pump_polls), + Rc::clone(&pump_drops), + ); + let done = ScriptedFuture::new( + [Poll::Ready(Ok(()))], + Rc::clone(&done_polls), + Rc::clone(&done_drops), + ); + + let outcome = block_on(run_exchange(send, pump, done, map_host_error)); + + assert!(matches!(outcome, Err(EdgeError::BadRequest { .. }))); + assert_eq!(pump_polls.get(), 1); + assert_eq!(send_polls.get(), 0); + assert_eq!(done_polls.get(), 0); + assert_eq!(send_drops.get(), 1); + assert_eq!(pump_drops.get(), 1); + assert_eq!(done_drops.get(), 1); + } + + #[test] + fn ready_send_after_pending_upload_is_authoritative() { + let (send_polls, send_drops) = counters(); + let (pump_polls, pump_drops) = counters(); + let (done_polls, done_drops) = counters(); + let send = ScriptedFuture::new( + [Poll::Ready(Ok(7_u8))], + Rc::clone(&send_polls), + Rc::clone(&send_drops), + ); + let pump = ScriptedFuture::new( + [ + Poll::Pending, + Poll::Ready(Err(EdgeError::bad_request("late upload failure"))), + ], + Rc::clone(&pump_polls), + Rc::clone(&pump_drops), + ); + let done = ScriptedFuture::new( + [Poll::Ready(Ok(()))], + Rc::clone(&done_polls), + Rc::clone(&done_drops), + ); + + let outcome = block_on(run_exchange(send, pump, done, map_host_error)); + + assert_eq!(outcome.expect("send result"), 7); + assert_eq!(pump_polls.get(), 1); + assert_eq!(send_polls.get(), 1); + assert_eq!(done_polls.get(), 0); + assert_eq!(send_drops.get(), 1); + assert_eq!(pump_drops.get(), 1); + assert_eq!(done_drops.get(), 1); + } + + #[test] + fn request_done_error_wins_over_retained_send() { + let (send_polls, send_drops) = counters(); + let (pump_polls, pump_drops) = counters(); + let (done_polls, done_drops) = counters(); + let send = ScriptedFuture::new( + [Poll::Ready(Ok(7_u8))], + Rc::clone(&send_polls), + Rc::clone(&send_drops), + ); + let pump = ScriptedFuture::new( + [Poll::Ready(Ok(UploadCompletion::Complete))], + pump_polls, + Rc::clone(&pump_drops), + ); + let done = ScriptedFuture::new( + [Poll::Pending, Poll::Ready(Err("request completion failed"))], + Rc::clone(&done_polls), + Rc::clone(&done_drops), + ); + + let observed_send_drops = Rc::clone(&send_drops); + let observed_pump_drops = Rc::clone(&pump_drops); + let observed_done_drops = Rc::clone(&done_drops); + let outcome = block_on(run_exchange(send, pump, done, move |_error| { + assert_eq!(observed_send_drops.get(), 1); + assert_eq!(observed_pump_drops.get(), 1); + assert_eq!(observed_done_drops.get(), 1); + EdgeError::bad_gateway("scripted host failure") + })); + + assert!(matches!(outcome, Err(EdgeError::BadGateway { .. }))); + assert_eq!(send_polls.get(), 1); + assert_eq!(done_polls.get(), 2); + } + + #[test] + fn request_done_success_releases_retained_send_without_repolling_it() { + let (send_polls, send_drops) = counters(); + let (pump_polls, pump_drops) = counters(); + let (done_polls, done_drops) = counters(); + let send = ScriptedFuture::new([Poll::Ready(Ok(7_u8))], Rc::clone(&send_polls), send_drops); + let pump = ScriptedFuture::new( + [Poll::Ready(Ok(UploadCompletion::Complete))], + pump_polls, + pump_drops, + ); + let done = ScriptedFuture::new( + [Poll::Pending, Poll::Ready(Ok(()))], + Rc::clone(&done_polls), + done_drops, + ); + + let outcome = block_on(run_exchange(send, pump, done, map_host_error)); + + assert_eq!(outcome.expect("retained send result"), 7); + assert_eq!(send_polls.get(), 1); + assert_eq!(done_polls.get(), 2); + } + + #[test] + fn reader_gone_never_polls_request_done() { + let (send_polls, send_drops) = counters(); + let (pump_polls, pump_drops) = counters(); + let (done_polls, done_drops) = counters(); + let send = ScriptedFuture::new( + [Poll::Pending, Poll::Ready(Ok(7_u8))], + Rc::clone(&send_polls), + send_drops, + ); + let pump = ScriptedFuture::new( + [Poll::Ready(Ok(UploadCompletion::ReaderGone))], + pump_polls, + pump_drops, + ); + let done = ScriptedFuture::new([Poll::Ready(Ok(()))], Rc::clone(&done_polls), done_drops); + + let outcome = block_on(run_exchange(send, pump, done, map_host_error)); + + assert_eq!(outcome.expect("early response"), 7); + assert_eq!(send_polls.get(), 2); + assert_eq!(done_polls.get(), 0); + } +} diff --git a/crates/edgezero-adapter-spin/src/proxy.rs b/crates/edgezero-adapter-spin/src/proxy.rs deleted file mode 100644 index 81754c772..000000000 --- a/crates/edgezero-adapter-spin/src/proxy.rs +++ /dev/null @@ -1,79 +0,0 @@ -use crate::decompress::decompress_body; -use crate::response::collect_body_bytes; -use async_trait::async_trait; -use bytes::Bytes; -use edgezero_core::body::Body; -use edgezero_core::error::EdgeError; -use edgezero_core::http::{HeaderValue, header}; -use edgezero_core::proxy::{PROXY_HEADER, ProxyClient, ProxyRequest, ProxyResponse}; -use spin_sdk::http::body::IncomingBodyExt as _; -use spin_sdk::http::{FullBody, Request as SpinRequest, send}; - -/// A proxy client that uses Spin's outbound HTTP (`spin_sdk::http::send`) -/// to forward requests to upstream services. -pub struct SpinProxyClient; - -#[async_trait(?Send)] -impl ProxyClient for SpinProxyClient { - #[inline] - async fn send(&self, request: ProxyRequest) -> Result { - let (method, uri, headers, body, _extensions) = request.into_parts(); - - let mut builder = SpinRequest::builder().method(method).uri(uri.to_string()); - - for (name, value) in &headers { - builder = builder.header(name, value); - } - - let request_body_bytes = collect_body_bytes(body).await?; - - let spin_request = builder - .body(FullBody::new(Bytes::from(request_body_bytes))) - .map_err(|err| { - EdgeError::internal(anyhow::anyhow!("failed to build proxy request: {err}")) - })?; - - let spin_response = send(spin_request).await.map_err(|err| { - EdgeError::internal(anyhow::anyhow!("Spin outbound HTTP error: {err}")) - })?; - - let (response_parts, response_body) = spin_response.into_parts(); - - let encoding = response_parts - .headers - .get(header::CONTENT_ENCODING) - .and_then(|value| value.to_str().ok()) - .map(str::to_ascii_lowercase); - - let response_body_bytes = response_body.bytes().await.map_err(|err| { - EdgeError::internal(anyhow::anyhow!("failed to read proxy response body: {err}")) - })?; - let decompressed = decompress_body(response_body_bytes.to_vec(), encoding.as_deref())?; - let mut proxy_response = - ProxyResponse::new(response_parts.status, Body::from(decompressed)); - - for (name, value) in &response_parts.headers { - proxy_response - .headers_mut() - .insert(name.clone(), value.clone()); - } - - // Strip encoding headers after decompression so downstream - // handlers see plain bytes (consistent with Fastly/Cloudflare). - if matches!(encoding.as_deref(), Some("gzip" | "br")) { - proxy_response - .headers_mut() - .remove(header::CONTENT_ENCODING); - proxy_response.headers_mut().remove(header::CONTENT_LENGTH); - } - - // `HeaderValue::from_static("spin")` is infallible at compile time so - // it cannot panic at runtime — replaces the previous - // `.parse().expect(...)` which tripped expect_used under restriction. - proxy_response - .headers_mut() - .insert(PROXY_HEADER, HeaderValue::from_static("spin")); - - Ok(proxy_response) - } -} diff --git a/crates/edgezero-adapter-spin/src/request.rs b/crates/edgezero-adapter-spin/src/request.rs index bf9543bf0..a1afef3fc 100644 --- a/crates/edgezero-adapter-spin/src/request.rs +++ b/crates/edgezero-adapter-spin/src/request.rs @@ -1,29 +1,61 @@ use std::collections::BTreeMap; +use std::future::Future; +#[cfg(feature = "test-utils")] +use std::future::pending; use std::sync::Arc; +use std::time::Duration; +#[cfg(feature = "test-utils")] +use std::{ + io, + sync::atomic::{AtomicUsize, Ordering}, + task::Poll, +}; -use anyhow::Context as _; +#[cfg(feature = "test-utils")] +use bytes::Bytes; -use crate::SpinFullResponse; +use crate::SpinResponse; use crate::config_store::SpinConfigStore; use crate::context::{SpinRequestContext, parse_client_addr}; use crate::key_value_store::{DEFAULT_MAX_LIST_KEYS, SpinKvStore}; -use crate::proxy::SpinProxyClient; -use crate::response::from_core_response; +use crate::response::from_egress_response; use crate::secret_store::SpinSecretStore; use edgezero_core::app::{App, StoreMetadata}; use edgezero_core::body::Body; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; -use edgezero_core::http::{Request, request_builder}; +#[cfg(feature = "test-utils")] +use edgezero_core::http::Uri; +#[cfg(any(test, feature = "test-utils"))] +use edgezero_core::http::request_builder; +use edgezero_core::http::{Method, Request, RequestParts}; +use edgezero_core::ingress::{ + IngressBeginOutcome, IngressDispatchOutcome, IngressFraming, IngressHeadAccounting, + IngressHeadParts, PreparedIngress, +}; use edgezero_core::key_value_store::KvHandle; -use edgezero_core::proxy::ProxyHandle; +use edgezero_core::outbound::HttpClient; +use edgezero_core::response_egress::ResponseEgressEnvelope; use edgezero_core::secret_store::SecretHandle; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, }; +use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; +#[cfg(any(test, feature = "test-utils"))] +use futures::executor::block_on; +use futures_util::future::{Either, select}; +#[cfg(feature = "test-utils")] +use futures_util::stream::Empty; +#[cfg(feature = "test-utils")] +use futures_util::stream::poll_fn; +use futures_util::stream::unfold; +use futures_util::{StreamExt as _, TryStreamExt as _}; use spin_sdk::http::Request as SpinRequest; use spin_sdk::http::body::IncomingBodyExt as _; +use spin_sdk::time::sleep; + +use crate::outbound::SpinOutboundClient; /// Per-dispatch store wiring assembled before the request enters the router. /// The struct itself is `pub(crate)` because `dispatch_with_handles` takes it @@ -39,18 +71,49 @@ pub(crate) struct Stores { secrets: Option, } +#[cfg(feature = "test-utils")] +struct DropSignal(Arc); + +#[cfg(feature = "test-utils")] +impl Drop for DropSignal { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +fn detached_error_envelope( + app: &App, + error: EdgeError, + request_method: Method, + request_start: MonotonicInstant, +) -> anyhow::Result { + match app.detached_ingress_error_egress(error, request_method, request_start) { + IngressDispatchOutcome::Response(envelope) => Ok(*envelope), + IngressDispatchOutcome::Aborted | _ => Err(anyhow::anyhow!("ingress admission aborted")), + } +} + /// Convert a Spin `Request` into an `EdgeZero` core `Request`. /// -/// Reads the full body into a buffered `Body::Once`, inserts -/// `SpinRequestContext` and a `ProxyHandle` into extensions. +/// Preserves the body as a lazy stream, inserts `SpinRequestContext`, and adds +/// an outbound [`HttpClient`] to extensions. /// /// # Errors /// Returns [`EdgeError::bad_request`] if the request body cannot be read or /// the core `Request` cannot be built from the resulting parts. #[inline] +#[expect( + clippy::unused_async, + reason = "the public converter retains its established async API while request bodies remain lazy" +)] pub async fn into_core_request(req: SpinRequest) -> Result { let (parts, body) = req.into_parts(); + let mut request = into_core_request_head(parts, MonotonicClock::default()); + *request.body_mut() = Body::from_external_stream(body.stream()); + Ok(request) +} +fn into_core_request_head(parts: RequestParts, outbound_clock: MonotonicClock) -> Request { let client_addr = parts .headers .get("spin-client-addr") @@ -62,23 +125,7 @@ pub async fn into_core_request(req: SpinRequest) -> Result { .and_then(|value| value.to_str().ok()) .map(str::to_owned); - let mut builder = request_builder().method(parts.method).uri(parts.uri); - for (name, value) in &parts.headers { - builder = builder.header(name, value); - } - - // Inbound body size is not capped at the adapter level. The Spin runtime - // enforces its own request body limit (configurable via `spin.toml`), which - // is consistent with how the Fastly and Cloudflare adapters delegate inbound - // size enforcement to their respective platform runtimes. - let body_bytes = body - .bytes() - .await - .map_err(|err| EdgeError::bad_request(format!("failed to read request body: {err}")))?; - - let mut request = builder - .body(Body::from(body_bytes.to_vec())) - .map_err(|err| EdgeError::bad_request(format!("failed to build request: {err}")))?; + let mut request = Request::from_parts(parts, Body::empty()); SpinRequestContext::insert( &mut request, @@ -89,9 +136,249 @@ pub async fn into_core_request(req: SpinRequest) -> Result { ); request .extensions_mut() - .insert(ProxyHandle::with_client(SpinProxyClient)); + .insert(outbound_client(outbound_clock)); - Ok(request) + request +} + +fn into_core_request_head_for_app(parts: RequestParts, app: &App) -> Request { + into_core_request_head(parts, app.monotonic_clock()) +} + +fn outbound_client(clock: MonotonicClock) -> HttpClient { + HttpClient::with_client(SpinOutboundClient::with_clock(clock)) +} + +fn deadline_body_with_timer( + source: Source, + deadline: Deadline, + monotonic_clock: MonotonicClock, + make_timer: MakeTimer, +) -> Body +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, + Timer: Future + 'static, + MakeTimer: Fn(Duration) -> Timer + Clone + 'static, +{ + let boxed_stream = source.map_err(Into::into).boxed_local(); + let stream = unfold(Some(boxed_stream), move |stream_state| { + let clock = monotonic_clock.clone(); + let timer_factory = make_timer.clone(); + async move { + let mut body_stream = stream_state?; + let Some(remaining) = deadline.remaining_at(clock.now()) else { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + }; + let timer = timer_factory(remaining); + let next_item = body_stream.next(); + futures_util::pin_mut!(timer, next_item); + let item = match select(timer, next_item).await { + Either::Left(((), _next_item)) => { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + } + Either::Right((item, _timer)) => item, + }; + if deadline.is_expired_at(clock.now()) { + return Some(( + Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )), + None, + )); + } + match item { + Some(Ok(bytes)) => Some((Ok(bytes), Some(body_stream))), + Some(Err(error)) => Some((Err(EdgeError::internal(error)), None)), + None => None, + } + } + }); + Body::from_stream(stream) +} + +/// Runs the terminal source-release probe used by the WASM contract suite. +#[cfg(feature = "test-utils")] +#[must_use] +#[inline] +pub fn deadline_body_releases_source_for_test() -> bool { + let dropped = Arc::new(AtomicUsize::new(0)); + let signal = DropSignal(Arc::clone(&dropped)); + let source = poll_fn(move |_cx| { + let _keep_alive = &signal; + Poll::>>::Pending + }); + let start = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || start); + let body = deadline_body_with_timer(source, Deadline::at_instant(start), clock, |_| { + pending::<()>() + }); + let Some(mut body_stream) = body.into_stream() else { + return false; + }; + let Some(Err(error)) = block_on(body_stream.next()) else { + return false; + }; + matches!(error, EdgeError::RequestTimeout { .. }) && dropped.load(Ordering::SeqCst) == 1 +} + +/// Dispatches an observable source through the production ingress body wrapper without a +/// provider timer. Tests that exercise timer expiry use +/// [`dispatch_ingress_stream_with_timer_for_test`] instead. +#[cfg(feature = "test-utils")] +#[doc(hidden)] +#[inline] +pub async fn dispatch_ingress_stream_for_test( + app: &App, + method: Method, + uri: Uri, + source: Source, +) -> anyhow::Result +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, +{ + let request_start = app.monotonic_now(); + let core_request = request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .map_err(EdgeError::internal)?; + dispatch_ingress_stream_with_timer_and( + app, + core_request, + Stores::default(), + request_start, + move || Ok(source), + |_| pending::<()>(), + Ok, + ) + .await +} + +/// Exercises admission ordering with observable source construction and delivery closures. +#[cfg(feature = "test-utils")] +#[doc(hidden)] +#[inline] +pub async fn dispatch_ingress_with_for_test( + app: &App, + method: Method, + uri: Uri, + make_source: MakeSource, + deliver: Deliver, +) -> anyhow::Result +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, + MakeSource: FnOnce() -> Result, + Deliver: FnOnce(ResponseEgressEnvelope) -> Result, +{ + let request_start = app.monotonic_now(); + let core_request = request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .map_err(EdgeError::internal)?; + dispatch_ingress_stream_with_timer_and( + app, + core_request, + Stores::default(), + request_start, + make_source, + |_| pending::<()>(), + deliver, + ) + .await +} + +/// Exercises a source-construction failure after admission through the production seam. +#[cfg(feature = "test-utils")] +#[doc(hidden)] +#[inline] +pub async fn dispatch_ingress_source_error_for_test( + app: &App, + method: Method, + uri: Uri, +) -> anyhow::Result { + let request_start = app.monotonic_now(); + let core_request = request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .map_err(EdgeError::internal)?; + dispatch_ingress_stream_with_timer_and( + app, + core_request, + Stores::default(), + request_start, + || -> Result>, EdgeError> { + Err(EdgeError::internal(anyhow::anyhow!( + "spin source construction failed" + ))) + }, + |_| pending::<()>(), + Ok, + ) + .await +} + +/// Dispatches an observable source through the production ingress deadline race. +#[cfg(feature = "test-utils")] +#[doc(hidden)] +#[inline] +pub async fn dispatch_ingress_stream_with_timer_for_test( + app: &App, + method: Method, + uri: Uri, + source: Source, + make_timer: MakeTimer, +) -> anyhow::Result +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, + Timer: Future + 'static, + MakeTimer: Fn(Duration) -> Timer + Clone + 'static, +{ + let request_start = app.monotonic_now(); + let core_request = request_builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .map_err(EdgeError::internal)?; + dispatch_ingress_stream_with_timer_and( + app, + core_request, + Stores::default(), + request_start, + move || Ok(source), + make_timer, + Ok, + ) + .await +} + +/// Dispatches a native Spin request through the production outer request seam. +/// +/// # Errors +/// Returns the same request-conversion, routing, or response-conversion error as standard dispatch. +#[cfg(feature = "test-utils")] +#[doc(hidden)] +#[inline] +pub async fn dispatch_request_for_test( + app: &App, + req: SpinRequest, +) -> anyhow::Result { + dispatch_with_handles_and(app, req, Stores::default(), app.monotonic_now(), Ok).await } /// Dispatch a Spin request through the `EdgeZero` router using the `"default"` @@ -106,15 +393,16 @@ pub async fn into_core_request(req: SpinRequest) -> Result { /// cannot be converted, the router dispatch fails, or response translation /// fails. #[inline] -pub async fn dispatch(app: &App, req: SpinRequest) -> anyhow::Result { - dispatch_with_kv_label(app, req, "default").await +pub async fn dispatch(app: &App, req: SpinRequest) -> anyhow::Result { + let request_start = app.monotonic_now(); + dispatch_with_kv_label_at(app, req, "default", request_start).await } /// Dispatch a Spin request through the `EdgeZero` router and return /// a Spin-compatible response, opening the KV store under `kv_label`. /// /// Injects all available stores into request extensions: -/// - `ConfigStoreHandle` backed by `SpinConfigStore` opened on `kv_label` +/// - `ConfigStoreHandle` backed by `SpinConfigStore` bound to `kv_label` /// (KV-backed since 2026-Q3; was variables-backed before that). The /// same label opens the config-store backend as the KV store on this /// low-level path — registry-aware callers should use [`run_app`] @@ -128,29 +416,176 @@ pub async fn dispatch(app: &App, req: SpinRequest) -> anyhow::Result anyhow::Result { +) -> anyhow::Result { + let request_start = app.monotonic_now(); + dispatch_with_kv_label_at(app, req, kv_label, request_start).await +} + +async fn dispatch_with_kv_label_at( + app: &App, + req: SpinRequest, + kv_label: &str, + request_start: MonotonicInstant, +) -> anyhow::Result { let stores = Stores { - config_store: resolve_config_handle(kv_label).await?, + config_store: Some(config_handle(kv_label.to_owned())), kv: resolve_kv_handle(kv_label, false).await?, secrets: resolve_secret_handle(true), ..Default::default() }; - dispatch_with_handles(app, req, stores).await + dispatch_with_handles(app, req, stores, request_start).await } pub(crate) async fn dispatch_with_handles( app: &App, req: SpinRequest, stores: Stores, -) -> anyhow::Result { - let mut core_request = into_core_request(req).await?; + request_start: MonotonicInstant, +) -> anyhow::Result { + dispatch_with_handles_and(app, req, stores, request_start, from_egress_response).await +} + +async fn dispatch_with_handles_and( + app: &App, + req: SpinRequest, + stores: Stores, + request_start: MonotonicInstant, + deliver: Deliver, +) -> anyhow::Result +where + Deliver: FnOnce(ResponseEgressEnvelope) -> Result, +{ + let (parts, native_body) = req.into_parts(); + let core_request = into_core_request_head_for_app(parts, app); + dispatch_ingress_stream_and( + app, + core_request, + stores, + request_start, + move || Ok(native_body.stream()), + deliver, + ) + .await +} + +async fn dispatch_ingress_stream_and( + app: &App, + head_request: Request, + stores: Stores, + request_start: MonotonicInstant, + make_source: MakeSource, + deliver: Deliver, +) -> anyhow::Result +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, + MakeSource: FnOnce() -> Result, + Deliver: FnOnce(ResponseEgressEnvelope) -> Result, +{ + dispatch_ingress_stream_with_timer_and( + app, + head_request, + stores, + request_start, + make_source, + sleep, + deliver, + ) + .await +} + +async fn dispatch_ingress_stream_with_timer_and< + Source, + SourceError, + MakeSource, + Timer, + MakeTimer, + Output, + Deliver, +>( + app: &App, + mut head_request: Request, + stores: Stores, + request_start: MonotonicInstant, + make_source: MakeSource, + make_timer: MakeTimer, + deliver: Deliver, +) -> anyhow::Result +where + Source: futures_util::Stream> + 'static, + SourceError: Into + 'static, + MakeSource: FnOnce() -> Result, + Timer: Future + 'static, + MakeTimer: Fn(Duration) -> Timer + Clone + 'static, + Deliver: FnOnce(ResponseEgressEnvelope) -> Result, +{ + head_request + .extensions_mut() + .insert(outbound_client(app.monotonic_clock())); + let request_method = head_request.method().clone(); + let head_parts = IngressHeadParts::from_request( + &head_request, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + if let Err(error) = head_parts.validate_normalized(app.ingress_head_limits()) { + let egress = detached_error_envelope(app, error, request_method, request_start)?; + return Ok(deliver(egress)?); + } + let prepared = match app.begin_ingress(head_parts, request_start) { + Ok(outcome) => match outcome { + IngressBeginOutcome::Admitted(prepared) => prepared, + IngressBeginOutcome::Refused(response) => { + return Ok(deliver(response)?); + } + IngressBeginOutcome::Aborted => { + return Err(anyhow::anyhow!("ingress admission aborted")); + } + _ => { + let egress = detached_error_envelope( + app, + EdgeError::internal(anyhow::anyhow!("unsupported ingress admission outcome")), + request_method, + request_start, + )?; + return Ok(deliver(egress)?); + } + }, + Err(error) => { + let egress = detached_error_envelope(app, error, request_method, request_start)?; + return Ok(deliver(egress)?); + } + }; + let source = match make_source() { + Ok(source) => source, + Err(error) => return Ok(deliver(app.admitted_error_egress(prepared, error))?), + }; + *head_request.body_mut() = deadline_body_with_timer( + source, + prepared.read_deadline(), + prepared.monotonic_clock(), + make_timer, + ); + dispatch_core_request(app, head_request, stores, prepared, deliver).await +} + +async fn dispatch_core_request( + app: &App, + mut core_request: Request, + stores: Stores, + prepared: PreparedIngress, + deliver: Deliver, +) -> anyhow::Result +where + Deliver: FnOnce(ResponseEgressEnvelope) -> Result, +{ // Hard-cutoff: see fastly's `dispatch_core_request` // for the rationale. Only registries go into extensions — // legacy bare handles are synthesised into a one-id registry @@ -165,8 +600,8 @@ pub(crate) async fn dispatch_with_handles( if let Some(registry) = secret_registry { core_request.extensions_mut().insert(registry); } - let response = app.router().oneshot(core_request).await?; - Ok(from_core_response(response).await?) + let response = app.dispatch_admitted(prepared, core_request).await; + Ok(deliver(response)?) } /// Dispatch with per-id store registries built from baked metadata. @@ -175,7 +610,7 @@ pub(crate) async fn dispatch_with_handles( /// - KV: **Multi** — each declared id opens its own [`SpinKvStore`] under the /// label resolved from `EDGEZERO__STORES__KV____NAME`. Optional /// `EDGEZERO__STORES__KV____MAX_LIST_KEYS` overrides the paging cap. -/// - Config: **Multi** — each declared id opens its own [`SpinConfigStore`] +/// - Config: **Multi** — each declared id binds its own [`SpinConfigStore`] /// under the label resolved from `EDGEZERO__STORES__CONFIG____NAME`. /// KV-backed under the hood (was variables-backed up through 2026-Q2). /// - Secrets: **Single** — every declared id maps to the one shared @@ -187,9 +622,10 @@ pub(crate) async fn dispatch_with_registries( kv_meta: Option, secret_meta: Option, env: &EnvConfig, -) -> anyhow::Result { +) -> anyhow::Result { + let request_start = app.monotonic_now(); let kv_registry = build_kv_registry(kv_meta, env).await?; - let config_registry = build_config_registry(config_meta, env).await?; + let config_registry = build_config_registry(config_meta, env); let secret_registry = build_secret_registry(secret_meta, env); dispatch_with_handles( app, @@ -200,6 +636,7 @@ pub(crate) async fn dispatch_with_registries( secret_registry, ..Default::default() }, + request_start, ) .await } @@ -279,40 +716,25 @@ async fn build_kv_registry( Ok(StoreRegistry::from_parts(by_id, meta.default.to_owned())) } -async fn build_config_registry( +fn build_config_registry( config_meta: Option, env: &EnvConfig, -) -> anyhow::Result> { - let Some(meta) = config_meta else { - return Ok(None); - }; - // Spin is `Multi` for config (KV-backed): each declared id opens its - // own `key_value::Store` under the label resolved from - // `EDGEZERO__STORES__CONFIG____NAME`. Mirrors `build_kv_registry` - // so missing `key_value_stores = [...]` declarations surface at - // dispatch setup, not on first config read. - // Preserve the `ConfigStoreError` as the anyhow source so the - // caller can downcast to distinguish `Internal` (structural — - // label not declared / registered) from `Unavailable` (transient - // hostcall failure). `with_context` chains rather than - // stringifying. +) -> Option { + let meta = config_meta?; + // Bind every declared id without host calls. Opening inside `get_bounded` + // lets admission run first and subjects the open to extraction's deadline. let mut by_id: BTreeMap = BTreeMap::new(); for id in meta.ids { let label = env.store_name("config", id); - let store = SpinConfigStore::open(label.clone()) - .await - .with_context(|| format!("config store id `{id}` (label `{label}`) failed to open"))?; by_id.insert( (*id).to_owned(), ConfigStoreBinding { - handle: ConfigStoreHandle::new(Arc::new(store)), + handle: config_handle(label), default_key: env.store_key("config", id), }, ); } - // Every id is required to open (any failure returns Err above), so - // `from_parts` is guaranteed to have the default id present. - Ok(StoreRegistry::from_parts(by_id, meta.default.to_owned())) + StoreRegistry::from_parts(by_id, meta.default.to_owned()) } fn build_secret_registry( @@ -337,15 +759,8 @@ fn build_secret_registry( StoreRegistry::from_parts(by_id, meta.default.to_owned()) } -async fn resolve_config_handle(label: &str) -> anyhow::Result> { - // Low-level path (`dispatch` / `dispatch_with_kv_label`): open the - // KV-backed config store under the same label used for KV. Registry - // callers (`dispatch_with_registries`) use `build_config_registry` - // instead, which resolves per-id labels via env. - let store = SpinConfigStore::open(label.to_owned()) - .await - .with_context(|| format!("low-level dispatch: open config store `{label}`"))?; - Ok(Some(ConfigStoreHandle::new(Arc::new(store)))) +fn config_handle(label: String) -> ConfigStoreHandle { + ConfigStoreHandle::new(Arc::new(SpinConfigStore::for_label(label))) } async fn resolve_kv_handle(kv_label: &str, kv_required: bool) -> anyhow::Result> { @@ -376,18 +791,47 @@ fn resolve_secret_handle(secrets_enabled: bool) -> Option { #[cfg(test)] mod synthesis_tests { use super::*; - use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::config_store::{ + BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigStoreHandle, + finish_bounded_config_read, + }; use edgezero_core::key_value_store::{KvStore, NoopKvStore}; + use edgezero_core::router::RouterService; use edgezero_core::secret_store::{NoopSecretStore, SecretHandle}; use std::collections::BTreeMap; - use std::sync::Arc; + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + use std::time::Duration; struct StubConfig; #[async_trait::async_trait(?Send)] impl ConfigStore for StubConfig { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + finish_bounded_config_read( + self.get(key).await, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + } } fn kv_handle() -> KvHandle { @@ -424,6 +868,53 @@ mod synthesis_tests { ); } + #[test] + fn production_head_conversion_installs_the_exact_application_outbound_clock() { + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(7)) + .expect("completed instant"); + let observations = Arc::new(Mutex::new(VecDeque::from([start, completed]))); + let clock_observations = Arc::clone(&observations); + let mut app = App::new(RouterService::builder().build()); + app.set_monotonic_clock(MonotonicClock::new(move || { + clock_observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + })); + let request = request_builder() + .method(Method::GET) + .uri("http://example.test/clock") + .body(Body::empty()) + .expect("request"); + let (parts, _body) = request.into_parts(); + let core_request = into_core_request_head_for_app(parts, &app); + let client = core_request + .extensions() + .get::() + .cloned() + .expect("HTTP client"); + let outbound_request = edgezero_core::OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = block_on(client.send_all_until( + vec![outbound_request], + Deadline::after(Duration::from_secs(1)), + )) + .expect("valid batch driver"); + + assert_eq!( + results.slots[0] + .as_ref() + .expect("resolved outbound slot") + .elapsed, + Duration::from_millis(7) + ); + } + #[test] fn synthesis_registry_wins_over_bare_handle_when_both_wired() { let mut by_id: BTreeMap = BTreeMap::new(); @@ -488,9 +979,8 @@ mod synthesis_tests { /// Spec 12.7 / plan line 1526: `EDGEZERO__STORES__CONFIG____KEY` /// must surface as `ConfigStoreBinding.default_key`. /// - /// `build_config_registry` calls `SpinConfigStore::open` which requires - /// the Spin executor and cannot be unit-tested here; this test exercises - /// the env-resolution layer that `build_config_registry` reads from. + /// `build_config_registry` binds labels without opening the host store; + /// this test exercises the env-resolution layer used by that builder. /// Platform-integration coverage relies on the E2 smoke scripts. #[test] fn config_default_key_env_override_resolved() { diff --git a/crates/edgezero-adapter-spin/src/response.rs b/crates/edgezero-adapter-spin/src/response.rs index 7a6525111..e23839fed 100644 --- a/crates/edgezero-adapter-spin/src/response.rs +++ b/crates/edgezero-adapter-spin/src/response.rs @@ -1,74 +1,1368 @@ +#![cfg_attr( + target_arch = "wasm32", + expect( + clippy::arbitrary_source_item_ordering, + reason = "the platform module follows response construction, cancellation, and entrypoint lifecycle order" + ) +)] + +use std::panic::{AssertUnwindSafe, catch_unwind}; +#[cfg(test)] +use std::time::Duration; + use bytes::Bytes; -use edgezero_core::body::Body; +use edgezero_core::body::{Body, BodyStream}; use edgezero_core::error::EdgeError; -use edgezero_core::http::Response; +use edgezero_core::http::{HeaderMap, Method, Response, StatusCode}; +use edgezero_core::response_egress::{ + RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET, ResponseEgressAttempt, + ResponseEgressFallbackDisposition, ResponseEgressOutcome, ResponseEgressPolicy, +}; +use edgezero_core::response_egress_framing::{ + PreparedResponseEgress, prepare_response_egress, response_egress_source_error_category, +}; +use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; use futures_util::StreamExt as _; -use spin_sdk::http::{FullBody, Response as SpinResponse}; - -use crate::SpinFullResponse; - -/// Maximum body size (16 MiB) when collecting a streamed body into a buffer. -/// Prevents unbounded memory growth from malicious or misconfigured upstreams. -/// -/// Note: this cap only applies to `Body::Stream` variants. `Body::Once` is -/// already materialised in memory and bypasses this check. The proxy module -/// uses a separate, larger limit ([`MAX_DECOMPRESSED_SIZE`](crate::proxy) = -/// 64 MiB) because proxy responses are untrusted external data that may -/// decompress to a much larger size. -const MAX_BODY_SIZE: usize = 16 * 1024 * 1024; - -/// Collect a `Body` into a `Vec`, consuming streamed chunks if necessary. -/// -/// Stream bodies are capped at [`MAX_BODY_SIZE`] bytes. If the accumulated -/// size exceeds the limit, collection stops and an error is returned. -pub(crate) async fn collect_body_bytes(body: Body) -> Result, EdgeError> { +use futures_util::future::{FutureExt as _, LocalBoxFuture}; +use futures_util::stream; + +const INTERNAL_FALLBACK_BODY: &[u8] = b"internal server error"; +const TIMEOUT_FALLBACK_BODY: &[u8] = b"response write deadline exceeded"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DeliveryError { + Conversion, + Deadline, + Source, + Transport, +} + +impl DeliveryError { + const fn from_outcome(outcome: ResponseEgressOutcome) -> Self { + match outcome { + ResponseEgressOutcome::ConversionError => Self::Conversion, + ResponseEgressOutcome::DeadlineExceeded => Self::Deadline, + ResponseEgressOutcome::SourceError => Self::Source, + ResponseEgressOutcome::ClientDisconnected + | ResponseEgressOutcome::Completed + | ResponseEgressOutcome::HostHandoff + | ResponseEgressOutcome::RequestCancelled + | ResponseEgressOutcome::TransportError + | ResponseEgressOutcome::Unspecified + | _ => Self::Transport, + } + } + + const fn outcome(self) -> ResponseEgressOutcome { + match self { + Self::Conversion => ResponseEgressOutcome::ConversionError, + Self::Deadline => ResponseEgressOutcome::DeadlineExceeded, + Self::Source => ResponseEgressOutcome::SourceError, + Self::Transport => ResponseEgressOutcome::TransportError, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EgressKind { + Application, + Fallback, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum IoFailureKind { + Deadline, + Transport, +} + +impl IoFailureKind { + const fn delivery_error(self) -> DeliveryError { + match self { + Self::Deadline => DeliveryError::Deadline, + Self::Transport => DeliveryError::Transport, + } + } +} + +#[derive(Debug)] +struct IoFailure { + accepted: usize, + kind: IoFailureKind, +} + +#[derive(Debug)] +struct WriteProgress { + accepted: usize, + remaining: Vec, +} + +enum StartOutcome { + Precommit { + attempt: Box, + cause: ResponseEgressOutcome, + }, + Started(Response), +} + +trait SpinEgressCommitter { + type Io: SpinEgressIo + 'static; + type Response; + + fn commit( + &mut self, + status: StatusCode, + headers: &HeaderMap, + transmits_body: bool, + ) -> Result<(Self::Response, Self::Io), DeliveryError>; + + fn spawn(&mut self, task: LocalBoxFuture<'static, ()>); +} + +#[async_trait::async_trait(?Send)] +trait SpinEgressIo { + fn abort(&mut self); + + async fn finish( + &mut self, + deadline: Deadline, + clock: &MonotonicClock, + ) -> Result<(), IoFailureKind>; + + async fn next_body( + &mut self, + source: &mut BodyStream, + deadline: Deadline, + clock: &MonotonicClock, + ) -> Result>, IoFailureKind>; + + async fn write( + &mut self, + bytes: Vec, + deadline: Deadline, + clock: &MonotonicClock, + ) -> Result; +} + +async fn transmit_committed( + body: Body, + policy: ResponseEgressPolicy, + mut attempt: ResponseEgressAttempt, + clock: MonotonicClock, + kind: EgressKind, + mut io: Io, +) -> Result<(), DeliveryError> +where + Io: SpinEgressIo, +{ + let result = pump_body(body, policy.write_deadline, &mut attempt, &clock, &mut io).await; + match result { + Ok(()) => { + settle_success(kind, &mut attempt, clock.now()); + Ok(()) + } + Err(error) => { + abort(&mut io); + let observed_at = terminal_observed_at(error, policy.write_deadline, &clock); + settle_failure(kind, &mut attempt, error.outcome(), observed_at); + Err(error) + } + } +} + +async fn pump_body( + body: Body, + deadline: Deadline, + attempt: &mut ResponseEgressAttempt, + clock: &MonotonicClock, + io: &mut Io, +) -> Result<(), DeliveryError> +where + Io: SpinEgressIo, +{ + ensure_deadline(deadline, clock)?; + let mut source = body_source(body); + loop { + let next = io + .next_body(&mut source, deadline, clock) + .await + .map_err(IoFailureKind::delivery_error)?; + ensure_deadline(deadline, clock)?; + let Some(item) = next else { + break; + }; + let chunk = item.map_err(|error| { + log::warn!( + "response-egress Spin body source failed after commit: {}", + response_egress_source_error_category(&error) + ); + DeliveryError::Source + })?; + write_chunk(chunk.to_vec(), deadline, attempt, clock, io).await?; + } + io.finish(deadline, clock) + .await + .map_err(IoFailureKind::delivery_error)?; + ensure_deadline(deadline, clock) +} + +async fn write_chunk( + mut remaining: Vec, + deadline: Deadline, + attempt: &mut ResponseEgressAttempt, + clock: &MonotonicClock, + io: &mut Io, +) -> Result<(), DeliveryError> +where + Io: SpinEgressIo, +{ + while !remaining.is_empty() { + ensure_deadline(deadline, clock)?; + let requested = remaining.len(); + match io.write(remaining, deadline, clock).await { + Ok(progress) => { + account_prefix(progress.accepted, requested, attempt, clock.now())?; + if progress.remaining.len() != requested.saturating_sub(progress.accepted) { + return Err(DeliveryError::Transport); + } + if progress.accepted == 0 { + return Err(DeliveryError::Transport); + } + remaining = progress.remaining; + ensure_deadline(deadline, clock)?; + } + Err(failure) => { + account_prefix(failure.accepted, requested, attempt, clock.now())?; + return Err(failure.kind.delivery_error()); + } + } + } + Ok(()) +} + +fn account_prefix( + accepted: usize, + requested: usize, + attempt: &mut ResponseEgressAttempt, + observed_at: MonotonicInstant, +) -> Result<(), DeliveryError> { + if accepted > requested { + return Err(DeliveryError::Transport); + } + let accepted_u64 = u64::try_from(accepted).map_err(|_error| DeliveryError::Transport)?; + if !attempt.account_bytes(accepted_u64, observed_at) { + return Err(DeliveryError::Transport); + } + Ok(()) +} + +fn abort(io: &mut Io) +where + Io: SpinEgressIo, +{ + if catch_unwind(AssertUnwindSafe(|| io.abort())).is_err() { + log::error!("response-egress Spin abort panicked"); + } +} + +fn body_source(body: Body) -> BodyStream { match body { - Body::Once(bytes) => Ok(bytes.to_vec()), - Body::Stream(mut stream) => { - let mut collected = Vec::new(); - while let Some(chunk) = stream.next().await { - match chunk { - Ok(bytes) => { - // `usize::saturating_add` keeps the bound check - // honest against pathological inputs without - // triggering arithmetic_side_effects. - if collected.len().saturating_add(bytes.len()) > MAX_BODY_SIZE { - return Err(EdgeError::internal(anyhow::anyhow!( - "body exceeds maximum size of {MAX_BODY_SIZE} bytes" - ))); + Body::Once(bytes) => stream::once(async move { Ok(bytes) }).boxed_local(), + Body::Stream(source) => source, + } +} + +fn ensure_deadline(deadline: Deadline, clock: &MonotonicClock) -> Result<(), DeliveryError> { + if deadline.is_expired_at(clock.now()) { + Err(DeliveryError::Deadline) + } else { + Ok(()) + } +} + +fn terminal_observed_at( + error: DeliveryError, + deadline: Deadline, + clock: &MonotonicClock, +) -> MonotonicInstant { + let observed_at = clock.now(); + if error == DeliveryError::Deadline { + observed_at.max(deadline.instant()) + } else { + observed_at + } +} + +fn settle_failure( + kind: EgressKind, + attempt: &mut ResponseEgressAttempt, + outcome: ResponseEgressOutcome, + observed_at: MonotonicInstant, +) { + match kind { + EgressKind::Application => { + attempt.terminate(outcome, observed_at); + } + EgressKind::Fallback => { + attempt.finish_fallback(ResponseEgressFallbackDisposition::Aborted, observed_at); + } + } +} + +fn settle_success( + kind: EgressKind, + attempt: &mut ResponseEgressAttempt, + observed_at: MonotonicInstant, +) { + match kind { + EgressKind::Application => { + attempt.terminate(ResponseEgressOutcome::HostHandoff, observed_at); + } + EgressKind::Fallback => { + attempt.finish_fallback(ResponseEgressFallbackDisposition::Completed, observed_at); + } + } +} + +#[cfg(target_arch = "wasm32")] +mod platform { + use futures_util::StreamExt as _; + + use super::{ + BodyStream, Bytes, Deadline, DeliveryError, EdgeError, EgressKind, HeaderMap, IoFailure, + IoFailureKind, LocalBoxFuture, MonotonicClock, SpinEgressCommitter, SpinEgressIo, + StartOutcome, StatusCode, WriteProgress, ensure_deadline, start_fallback, start_prepared, + }; + use std::future::{Future, IntoFuture as _, pending}; + use std::pin::Pin; + use std::task::Poll; + + use edgezero_core::response_egress::ResponseEgressEnvelope; + use futures_util::future::poll_fn; + use spin_sdk::time::sleep; + use spin_sdk::wasip3::http::types::{ErrorCode, Fields}; + use spin_sdk::wasip3::http_compat::{BodyResult, BodyWriter}; + use spin_sdk::wasip3::spawn; + use spin_sdk::wit_bindgen::rt::async_support::{ + FutureRead, FutureWrite, FutureWriteCancel, FutureWriter, StreamResult, StreamWrite, + StreamWriter, + }; + + use crate::SpinResponse; + + const fn public_message(error: DeliveryError) -> &'static str { + match error { + DeliveryError::Conversion => "response conversion failed", + DeliveryError::Deadline => "response write deadline exceeded", + DeliveryError::Source => "response body source failed", + DeliveryError::Transport => "response transport failed", + } + } + + struct WasiCommitter; + + impl SpinEgressCommitter for WasiCommitter { + type Io = WasiEgressIo; + type Response = SpinResponse; + + fn commit( + &mut self, + status: StatusCode, + headers: &HeaderMap, + transmits_body: bool, + ) -> Result<(Self::Response, Self::Io), DeliveryError> { + let entries = headers + .iter() + .map(|(name, value)| (name.as_str().to_owned(), value.as_bytes().to_vec())) + .collect::>(); + let fields = Fields::from_list(&entries).map_err(|_error| DeliveryError::Conversion)?; + let (body_writer, contents, body_result) = BodyWriter::new(); + let BodyWriter { + stream_writer, + result_writer, + .. + } = body_writer; + let response_contents = transmits_body.then_some(contents); + let (response, response_result) = + SpinResponse::new(fields, response_contents, body_result); + response + .set_status_code(status.as_u16()) + .map_err(|()| DeliveryError::Conversion)?; + Ok(( + response, + WasiEgressIo { + response_result: Box::pin(response_result.into_future()), + result_writer: Some(result_writer), + stream_writer: Some(stream_writer), + }, + )) + } + + fn spawn(&mut self, task: LocalBoxFuture<'static, ()>) { + spawn(task); + } + } + + struct WasiEgressIo { + response_result: Pin>>>, + result_writer: Option>, + stream_writer: Option>, + } + + enum RaceEvent { + Deadline, + Operation(Output), + Response(Result<(), ErrorCode>), + } + + async fn race_operation( + deadline: Deadline, + clock: &MonotonicClock, + mut response_result: Pin<&mut FutureRead>>, + mut operation: Pin<&mut Operation>, + ) -> RaceEvent + where + Operation: Future, + { + let Some(remaining) = deadline.remaining_at(clock.now()) else { + return RaceEvent::Deadline; + }; + let timer = sleep(remaining); + futures_util::pin_mut!(timer); + poll_fn(|context| { + if timer.as_mut().poll(context).is_ready() { + return Poll::Ready(RaceEvent::Deadline); + } + if let Poll::Ready(result) = response_result.as_mut().poll(context) { + return Poll::Ready(RaceEvent::Response(result)); + } + operation.as_mut().poll(context).map(RaceEvent::Operation) + }) + .await + } + + impl WasiEgressIo { + fn cancel_response_result(&mut self) { + drop(self.response_result.as_mut().cancel()); + } + + fn cancelled_write( + write: Pin<&mut StreamWrite<'_, u8>>, + requested: usize, + kind: IoFailureKind, + ) -> IoFailure { + let (_status, buffer) = write.cancel(); + IoFailure { + accepted: requested.saturating_sub(buffer.remaining()), + kind, + } + } + } + + #[async_trait::async_trait(?Send)] + impl SpinEgressIo for WasiEgressIo { + fn abort(&mut self) { + drop(self.stream_writer.take()); + drop(self.result_writer.take()); + } + + async fn finish( + &mut self, + deadline: Deadline, + clock: &MonotonicClock, + ) -> Result<(), IoFailureKind> { + ensure_deadline(deadline, clock).map_err(|_error| IoFailureKind::Deadline)?; + drop(self.stream_writer.take()); + let result_writer = self.result_writer.take().ok_or(IoFailureKind::Transport)?; + let result_write = result_writer.write(Ok(None)); + futures_util::pin_mut!(result_write); + let event = race_operation( + deadline, + clock, + self.response_result.as_mut(), + result_write.as_mut(), + ) + .await; + match event { + RaceEvent::Deadline => { + drop(cancel_result_write(result_write.as_mut())); + self.cancel_response_result(); + return Err(IoFailureKind::Deadline); + } + RaceEvent::Response(_result) => { + drop(cancel_result_write(result_write.as_mut())); + return Err(IoFailureKind::Transport); + } + RaceEvent::Operation(Ok(())) => {} + RaceEvent::Operation(Err(_error)) => return Err(IoFailureKind::Transport), + } + if deadline.is_expired_at(clock.now()) { + self.cancel_response_result(); + return Err(IoFailureKind::Deadline); + } + + let pending = pending::<()>(); + futures_util::pin_mut!(pending); + match race_operation( + deadline, + clock, + self.response_result.as_mut(), + pending.as_mut(), + ) + .await + { + RaceEvent::Deadline => { + self.cancel_response_result(); + Err(IoFailureKind::Deadline) + } + RaceEvent::Response(Ok(())) if !deadline.is_expired_at(clock.now()) => Ok(()), + RaceEvent::Response(Ok(()) | Err(_)) => { + if deadline.is_expired_at(clock.now()) { + Err(IoFailureKind::Deadline) + } else { + Err(IoFailureKind::Transport) + } + } + RaceEvent::Operation(()) => Err(IoFailureKind::Transport), + } + } + + async fn next_body( + &mut self, + source: &mut BodyStream, + deadline: Deadline, + clock: &MonotonicClock, + ) -> Result>, IoFailureKind> { + ensure_deadline(deadline, clock).map_err(|_error| IoFailureKind::Deadline)?; + let next = source.next(); + futures_util::pin_mut!(next); + match race_operation( + deadline, + clock, + self.response_result.as_mut(), + next.as_mut(), + ) + .await + { + RaceEvent::Deadline => { + self.cancel_response_result(); + Err(IoFailureKind::Deadline) + } + RaceEvent::Response(_result) => Err(IoFailureKind::Transport), + RaceEvent::Operation(item) if deadline.is_expired_at(clock.now()) => { + drop(item); + self.cancel_response_result(); + Err(IoFailureKind::Deadline) + } + RaceEvent::Operation(item) => Ok(item), + } + } + + async fn write( + &mut self, + bytes: Vec, + deadline: Deadline, + clock: &MonotonicClock, + ) -> Result { + if ensure_deadline(deadline, clock).is_err() { + return Err(IoFailure { + accepted: 0, + kind: IoFailureKind::Deadline, + }); + } + let requested = bytes.len(); + let (result, cancel_response) = { + let stream_writer = self.stream_writer.as_mut().ok_or(IoFailure { + accepted: 0, + kind: IoFailureKind::Transport, + })?; + let write = stream_writer.write(bytes); + futures_util::pin_mut!(write); + let event = race_operation( + deadline, + clock, + self.response_result.as_mut(), + write.as_mut(), + ) + .await; + match event { + RaceEvent::Deadline => ( + Err(Self::cancelled_write( + write.as_mut(), + requested, + IoFailureKind::Deadline, + )), + true, + ), + RaceEvent::Response(_result) => ( + Err(Self::cancelled_write( + write.as_mut(), + requested, + IoFailureKind::Transport, + )), + false, + ), + RaceEvent::Operation((status, buffer)) => { + let accepted = requested.saturating_sub(buffer.remaining()); + if deadline.is_expired_at(clock.now()) { + ( + Err(IoFailure { + accepted, + kind: IoFailureKind::Deadline, + }), + true, + ) + } else { + let result = match status { + StreamResult::Complete(_) => Ok(WriteProgress { + accepted, + remaining: buffer.into_vec(), + }), + StreamResult::Cancelled | StreamResult::Dropped => Err(IoFailure { + accepted, + kind: IoFailureKind::Transport, + }), + }; + (result, false) } - collected.extend_from_slice(&bytes); } - Err(err) => return Err(EdgeError::internal(err)), } + }; + if cancel_response { + self.cancel_response_result(); } - Ok(collected) + result + } + } + + fn cancel_result_write( + write: Pin<&mut FutureWrite>, + ) -> Option> { + match write.cancel() { + FutureWriteCancel::AlreadySent | FutureWriteCancel::Dropped(_) => None, + FutureWriteCancel::Cancelled(_value, writer) => Some(writer), } } + + /// Converts an owned response-egress envelope into a raw `WASIp3` response and spawns the sole + /// body/transmission coordinator. + /// + /// # Errors + /// Returns a category-only error if both the application response and bounded precommit fallback + /// fail before the raw response is returned to the Spin export. + pub(crate) fn from_egress_response( + egress: ResponseEgressEnvelope, + ) -> Result { + let request_method = egress.request_method().clone(); + let mut committer = WasiCommitter; + let result = match egress.begin() { + Ok((prepared, policy, attempt, clock)) => { + match start_prepared( + prepared, + policy, + attempt, + clock.clone(), + EgressKind::Application, + &mut committer, + ) { + StartOutcome::Started(response) => Ok(response), + StartOutcome::Precommit { + attempt: failed_attempt, + cause, + } => start_fallback( + &request_method, + cause, + *failed_attempt, + &clock, + &mut committer, + ), + } + } + Err(failure) => { + let (attempt, clock, cause) = failure.into_parts(); + start_fallback(&request_method, cause, attempt, &clock, &mut committer) + } + }; + result.map_err(|error| EdgeError::internal(anyhow::anyhow!(public_message(error)))) + } } -/// Convert an `EdgeZero` core `Response` into a Spin SDK `Response`. -/// -/// Both `Body::Once` and `Body::Stream` are converted to a buffered -/// byte body. Streaming bodies are collected into a single `Vec`. -/// -/// # Errors -/// Returns [`EdgeError::internal`] if the response body cannot be collected -/// (stream error or size cap exceeded) or if the resulting Spin response -/// cannot be built from the collected bytes. -#[inline] -pub async fn from_core_response(response: Response) -> Result { +#[cfg(target_arch = "wasm32")] +pub(crate) use platform::from_egress_response; + +fn start_prepared( + prepared: PreparedResponseEgress, + policy: ResponseEgressPolicy, + mut attempt: ResponseEgressAttempt, + clock: MonotonicClock, + kind: EgressKind, + committer: &mut Committer, +) -> StartOutcome +where + Committer: SpinEgressCommitter, +{ + if policy.write_deadline.is_expired_at(clock.now()) { + return StartOutcome::Precommit { + attempt: Box::new(attempt), + cause: ResponseEgressOutcome::DeadlineExceeded, + }; + } + let transmits_body = prepared.transmits_body(); + let response = prepared.into_response(); let (parts, body) = response.into_parts(); + let (platform_response, mut io) = + match committer.commit(parts.status, &parts.headers, transmits_body) { + Ok(pair) => pair, + Err(error) => { + return StartOutcome::Precommit { + attempt: Box::new(attempt), + cause: error.outcome(), + }; + } + }; + if policy.write_deadline.is_expired_at(clock.now()) { + abort(&mut io); + return StartOutcome::Precommit { + attempt: Box::new(attempt), + cause: ResponseEgressOutcome::DeadlineExceeded, + }; + } + if !attempt.begin_writing() { + abort(&mut io); + return StartOutcome::Precommit { + attempt: Box::new(attempt), + cause: ResponseEgressOutcome::ConversionError, + }; + } + let task = AssertUnwindSafe(transmit_committed(body, policy, attempt, clock, kind, io)) + .catch_unwind() + .map(|result| { + if result.is_err() { + log::error!("response-egress Spin coordinator panicked"); + } + }) + .boxed_local(); + committer.spawn(task); + StartOutcome::Started(platform_response) +} + +fn start_fallback( + request_method: &Method, + original_cause: ResponseEgressOutcome, + mut attempt: ResponseEgressAttempt, + clock: &MonotonicClock, + committer: &mut Committer, +) -> Result +where + Committer: SpinEgressCommitter, +{ + if !attempt.begin_fallback(original_cause) { + attempt.terminate(ResponseEgressOutcome::ConversionError, clock.now()); + return Err(DeliveryError::Conversion); + } + let started_at = clock.now(); + let fallback_deadline = started_at + .checked_add(RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET) + .unwrap_or(started_at); + let prepared = prepare_response_egress(request_method, fallback_response(original_cause)) + .map_err(|_error| { + attempt.finish_fallback(ResponseEgressFallbackDisposition::Aborted, clock.now()); + DeliveryError::from_outcome(original_cause) + })?; + match start_prepared( + prepared, + ResponseEgressPolicy { + write_deadline: Deadline::at_instant(fallback_deadline), + }, + attempt, + clock.clone(), + EgressKind::Fallback, + committer, + ) { + StartOutcome::Started(response) => Ok(response), + StartOutcome::Precommit { + attempt: mut failed_attempt, + cause: _fallback_cause, + } => { + failed_attempt.finish_fallback(ResponseEgressFallbackDisposition::Aborted, clock.now()); + Err(DeliveryError::from_outcome(original_cause)) + } + } +} + +fn fallback_response(cause: ResponseEgressOutcome) -> Response { + let (status, body) = if cause == ResponseEgressOutcome::DeadlineExceeded { + (StatusCode::GATEWAY_TIMEOUT, TIMEOUT_FALLBACK_BODY) + } else { + (StatusCode::INTERNAL_SERVER_ERROR, INTERNAL_FALLBACK_BODY) + }; + let mut response = Response::new(Body::from(body)); + *response.status_mut() = status; + response +} + +#[cfg(test)] +mod tests { + use std::cell::{Cell, RefCell}; + use std::collections::VecDeque; + use std::future::{Future as _, pending}; + use std::rc::Rc; + use std::sync::{Arc, Mutex}; + use std::task::{Context, Poll}; + + use super::*; + use edgezero_core::http::{HeaderMap, Method, Version, response_builder}; + use edgezero_core::response_egress::{ + ResponseEgressBodyKind, ResponseEgressCompletion, ResponseEgressHead, + ResponseEgressObserver, ResponseEgressObserverHandle, ResponseEgressReport, + }; + use futures::executor::block_on; + use futures_util::future::LocalBoxFuture; + use futures_util::stream; + use futures_util::task::noop_waker_ref; + + #[derive(Clone, Default)] + struct RecordingObserver(Arc>>); + + impl RecordingObserver { + fn reports(&self) -> Vec { + self.0.lock().expect("reports lock").clone() + } + } + + impl ResponseEgressObserver for RecordingObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.0.lock().expect("reports lock").push(report.clone()); + } + } + + #[derive(Clone, Copy)] + enum MockWrite { + Accept(usize), + Fail(IoFailureKind, usize), + } + + #[derive(Default)] + struct MockState { + aborts: usize, + accepted: Vec, + drops: usize, + events: Vec<&'static str>, + finish: Option, + next_body: Option, + pending: Option, + writes: VecDeque, + } + + struct MockIo(Rc>); + + #[derive(Clone, Copy, Eq, PartialEq)] + enum PendingAt { + Finish, + Source, + Write, + } + + impl Drop for MockIo { + fn drop(&mut self) { + let drops = self.0.borrow().drops; + self.0.borrow_mut().drops = drops.saturating_add(1); + } + } + + #[derive(Clone)] + struct MockResponse { + status: StatusCode, + transmits_body: bool, + } + + struct MockCommitter { + io: Rc>, + responses: Rc>>, + tasks: VecDeque>, + } + + impl MockCommitter { + fn new(io: Rc>) -> Self { + Self { + io, + responses: Rc::new(RefCell::new(Vec::new())), + tasks: VecDeque::new(), + } + } + + fn run_task(&mut self) { + let task = self.tasks.pop_front().expect("spawned task"); + block_on(task); + } + } + + impl SpinEgressCommitter for MockCommitter { + type Io = MockIo; + type Response = MockResponse; + + fn commit( + &mut self, + status: StatusCode, + _headers: &HeaderMap, + transmits_body: bool, + ) -> Result<(Self::Response, Self::Io), DeliveryError> { + let response = MockResponse { + status, + transmits_body, + }; + self.responses.borrow_mut().push(response.clone()); + Ok((response, MockIo(Rc::clone(&self.io)))) + } + + fn spawn(&mut self, task: LocalBoxFuture<'static, ()>) { + self.tasks.push_back(task); + } + } + + #[async_trait::async_trait(?Send)] + impl SpinEgressIo for MockIo { + fn abort(&mut self) { + let mut state = self.0.borrow_mut(); + state.aborts = state.aborts.saturating_add(1); + state.events.push("abort"); + } + + async fn finish( + &mut self, + _deadline: Deadline, + _clock: &MonotonicClock, + ) -> Result<(), IoFailureKind> { + let should_wait = { + let mut state = self.0.borrow_mut(); + state.events.push("finish"); + state.pending == Some(PendingAt::Finish) + }; + if should_wait { + pending::<()>().await; + } + self.0.borrow().finish.map_or(Ok(()), Err) + } + + async fn next_body( + &mut self, + source: &mut BodyStream, + _deadline: Deadline, + _clock: &MonotonicClock, + ) -> Result>, IoFailureKind> { + let (should_wait, failure) = { + let mut state = self.0.borrow_mut(); + state.events.push("source"); + (state.pending == Some(PendingAt::Source), state.next_body) + }; + if should_wait { + pending::<()>().await; + } + if let Some(next_failure) = failure { + return Err(next_failure); + } + Ok(source.next().await) + } - let mut builder = SpinResponse::builder().status(parts.status); + async fn write( + &mut self, + mut bytes: Vec, + _deadline: Deadline, + _clock: &MonotonicClock, + ) -> Result { + let (plan, should_wait) = { + let mut state = self.0.borrow_mut(); + state.events.push("write"); + ( + state.writes.pop_front(), + state.pending == Some(PendingAt::Write), + ) + }; + if should_wait { + pending::<()>().await; + } + let (accepted, failure) = match plan { + Some(MockWrite::Accept(accepted)) => (accepted, None), + Some(MockWrite::Fail(kind, accepted)) => (accepted, Some(kind)), + None => (bytes.len(), None), + }; + let remaining = if accepted <= bytes.len() { + let remaining = bytes.split_off(accepted); + self.0.borrow_mut().accepted.extend_from_slice(&bytes); + remaining + } else { + Vec::new() + }; + let progress = WriteProgress { + accepted, + remaining, + }; + match failure { + Some(kind) => Err(IoFailure { accepted, kind }), + None => Ok(progress), + } + } + } - for (name, value) in &parts.headers { - builder = builder.header(name, value); + fn stable_clock(now: MonotonicInstant) -> MonotonicClock { + MonotonicClock::new(move || now) } - let collected = collect_body_bytes(body).await?; + fn future_policy(now: MonotonicInstant) -> ResponseEgressPolicy { + ResponseEgressPolicy { + write_deadline: Deadline::at_instant( + now.checked_add(Duration::from_secs(10)).expect("deadline"), + ), + } + } + + fn new_attempt(observer: &RecordingObserver, now: MonotonicInstant) -> ResponseEgressAttempt { + let headers = HeaderMap::new(); + let head = + ResponseEgressHead::new(StatusCode::OK, Version::HTTP_11, &headers, None, now, None); + ResponseEgressAttempt::new( + &head, + now, + ResponseEgressCompletion::empty(), + ResponseEgressObserverHandle::new(observer.clone()), + MonotonicClock::default(), + ) + } + + fn attempt(observer: &RecordingObserver, now: MonotonicInstant) -> ResponseEgressAttempt { + let mut attempt = new_attempt(observer, now); + assert!(attempt.begin_writing()); + attempt + } + + fn transmit( + body: Body, + state: Rc>, + ) -> (Result<(), DeliveryError>, Vec) { + let now = MonotonicInstant::now(); + let observer = RecordingObserver::default(); + let result = block_on(transmit_committed( + body, + future_policy(now), + attempt(&observer, now), + stable_clock(now), + EgressKind::Application, + MockIo(state), + )); + (result, observer.reports()) + } + + #[test] + fn coordinator_streams_once_and_multiple_chunks_with_partial_writes() { + for (body, writes, expected_bytes) in [ + (Body::empty(), VecDeque::new(), 0_u64), + ( + Body::from("hello"), + VecDeque::from([MockWrite::Accept(2), MockWrite::Accept(3)]), + 5, + ), + ( + Body::stream(stream::iter([ + Bytes::from_static(b"ab"), + Bytes::from_static(b"cd"), + ])), + VecDeque::new(), + 4, + ), + ] { + let state = Rc::new(RefCell::new(MockState { + writes, + ..MockState::default() + })); + + let (result, reports) = transmit(body, Rc::clone(&state)); + + assert_eq!(result, Ok(())); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::HostHandoff); + assert_eq!(reports[0].bytes_written, expected_bytes); + assert_eq!(state.borrow().aborts, 0); + assert_eq!(state.borrow().events.last(), Some(&"finish")); + } + } + + #[test] + fn cancellation_accounts_the_accepted_prefix_before_deadline_wins() { + let state = Rc::new(RefCell::new(MockState { + writes: VecDeque::from([MockWrite::Fail(IoFailureKind::Deadline, 2)]), + ..MockState::default() + })); + + let (result, reports) = transmit(Body::from("hello"), Rc::clone(&state)); + + assert_eq!(result, Err(DeliveryError::Deadline)); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + assert_eq!(reports[0].bytes_written, 2); + assert_eq!(reports[0].elapsed, Duration::from_secs(10)); + assert_eq!(state.borrow().aborts, 1); + } + + #[test] + fn zero_progress_source_error_and_finish_error_each_abort_once() { + let zero_state = Rc::new(RefCell::new(MockState { + writes: VecDeque::from([MockWrite::Accept(0)]), + ..MockState::default() + })); + let (zero_result, zero_reports) = transmit(Body::from("x"), Rc::clone(&zero_state)); + assert_eq!(zero_result, Err(DeliveryError::Transport)); + assert_eq!( + zero_reports[0].outcome, + ResponseEgressOutcome::TransportError + ); + assert_eq!(zero_state.borrow().aborts, 1); + + let source_state = Rc::new(RefCell::new(MockState::default())); + let source_body = Body::from_stream(stream::iter([Err(EdgeError::internal( + anyhow::anyhow!("private source detail"), + ))])); + let (source_result, source_reports) = transmit(source_body, Rc::clone(&source_state)); + assert_eq!(source_result, Err(DeliveryError::Source)); + assert_eq!( + source_reports[0].outcome, + ResponseEgressOutcome::SourceError + ); + assert_eq!(source_state.borrow().aborts, 1); - builder - .body(FullBody::new(Bytes::from(collected))) - .map_err(|err| EdgeError::internal(anyhow::anyhow!("failed to build response: {err}"))) + let finish_state = Rc::new(RefCell::new(MockState { + finish: Some(IoFailureKind::Transport), + ..MockState::default() + })); + let (finish_result, finish_reports) = transmit(Body::empty(), Rc::clone(&finish_state)); + assert_eq!(finish_result, Err(DeliveryError::Transport)); + assert_eq!( + finish_reports[0].outcome, + ResponseEgressOutcome::TransportError + ); + assert_eq!(finish_state.borrow().aborts, 1); + } + + #[test] + fn fallback_uses_its_own_budget_and_preserves_original_cause() { + let now = MonotonicInstant::now(); + let clock = stable_clock(now); + let observer = RecordingObserver::default(); + let state = Rc::new(RefCell::new(MockState::default())); + let mut committer = MockCommitter::new(Rc::clone(&state)); + + let fallback = start_fallback( + &Method::GET, + ResponseEgressOutcome::ConversionError, + new_attempt(&observer, now), + &clock, + &mut committer, + ) + .expect("fallback response"); + + assert_eq!(fallback.status, StatusCode::INTERNAL_SERVER_ERROR); + assert!(fallback.transmits_body); + committer.run_task(); + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::ConversionError); + assert_eq!(reports[0].body_kind, ResponseEgressBodyKind::Fallback); + assert_eq!( + reports[0].fallback_disposition, + Some(ResponseEgressFallbackDisposition::Completed) + ); + assert_eq!(state.borrow().aborts, 0); + assert_eq!(state.borrow().accepted, INTERNAL_FALLBACK_BODY); + assert_eq!( + reports[0].bytes_written, + u64::try_from(INTERNAL_FALLBACK_BODY.len()).expect("fallback length") + ); + } + + #[test] + fn fallback_failures_abort_once_and_preserve_original_cause() { + for (writes, finish, next_body, expected_bytes) in [ + ( + VecDeque::from([ + MockWrite::Accept(3), + MockWrite::Fail(IoFailureKind::Transport, 0), + ]), + None, + None, + 3, + ), + ( + VecDeque::new(), + Some(IoFailureKind::Transport), + None, + u64::try_from(INTERNAL_FALLBACK_BODY.len()).expect("fallback length"), + ), + (VecDeque::new(), None, Some(IoFailureKind::Deadline), 0), + ] { + let now = MonotonicInstant::now(); + let observer = RecordingObserver::default(); + let state = Rc::new(RefCell::new(MockState { + finish, + next_body, + writes, + ..MockState::default() + })); + let mut committer = MockCommitter::new(Rc::clone(&state)); + let clock = stable_clock(now); + + start_fallback( + &Method::GET, + ResponseEgressOutcome::ConversionError, + new_attempt(&observer, now), + &clock, + &mut committer, + ) + .expect("fallback response"); + committer.run_task(); + + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::ConversionError); + assert_eq!(reports[0].body_kind, ResponseEgressBodyKind::Fallback); + assert_eq!( + reports[0].fallback_disposition, + Some(ResponseEgressFallbackDisposition::Aborted) + ); + assert_eq!(reports[0].bytes_written, expected_bytes); + assert_eq!(state.borrow().aborts, 1); + } + } + + #[test] + fn head_fallback_suppresses_body_without_writing() { + let now = MonotonicInstant::now(); + let observer = RecordingObserver::default(); + let state = Rc::new(RefCell::new(MockState::default())); + let mut committer = MockCommitter::new(Rc::clone(&state)); + + let clock = stable_clock(now); + let fallback = start_fallback( + &Method::HEAD, + ResponseEgressOutcome::DeadlineExceeded, + new_attempt(&observer, now), + &clock, + &mut committer, + ) + .expect("HEAD fallback response"); + assert_eq!(fallback.status, StatusCode::GATEWAY_TIMEOUT); + assert!(!fallback.transmits_body); + committer.run_task(); + + assert!(state.borrow().accepted.is_empty()); + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + assert_eq!(reports[0].bytes_written, 0); + assert_eq!( + reports[0].fallback_disposition, + Some(ResponseEgressFallbackDisposition::Completed) + ); + } + + #[test] + fn dropping_coordinator_in_each_pending_state_reports_once_and_releases_io() { + for pending_at in [PendingAt::Source, PendingAt::Write, PendingAt::Finish] { + let now = MonotonicInstant::now(); + let observer = RecordingObserver::default(); + let state = Rc::new(RefCell::new(MockState { + pending: Some(pending_at), + ..MockState::default() + })); + let mut future = Box::pin(transmit_committed( + Body::from("body"), + future_policy(now), + attempt(&observer, now), + stable_clock(now), + EgressKind::Application, + MockIo(Rc::clone(&state)), + )); + let mut context = Context::from_waker(noop_waker_ref()); + + assert!(matches!(future.as_mut().poll(&mut context), Poll::Pending)); + drop(future); + + assert_eq!(state.borrow().drops, 1); + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::TransportError); + } + } + + #[test] + fn deadline_equality_aborts_before_polling_the_source() { + let now = MonotonicInstant::now(); + let observer = RecordingObserver::default(); + let state = Rc::new(RefCell::new(MockState::default())); + let result = block_on(transmit_committed( + Body::from("body"), + ResponseEgressPolicy { + write_deadline: Deadline::at_instant(now), + }, + attempt(&observer, now), + stable_clock(now), + EgressKind::Application, + MockIo(Rc::clone(&state)), + )); + + assert_eq!(result, Err(DeliveryError::Deadline)); + assert_eq!(state.borrow().events, ["abort"]); + assert_eq!(state.borrow().aborts, 1); + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::DeadlineExceeded); + } + + #[test] + fn suppressed_application_source_is_dropped_without_polling() { + struct DropSignal(Rc>); + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.set(self.0.get() + 1); + } + } + + let polls = Rc::new(Cell::new(0_usize)); + let drops = Rc::new(Cell::new(0_usize)); + let observed_polls = Rc::clone(&polls); + let signal = DropSignal(Rc::clone(&drops)); + let body = Body::from_stream(stream::poll_fn(move |_context| { + let _keep_alive = &signal; + observed_polls.set(observed_polls.get() + 1); + Poll::Pending + })); + let core_response = response_builder() + .status(StatusCode::OK) + .header("content-length", "99") + .body(body) + .expect("response"); + let prepared = prepare_response_egress(&Method::HEAD, core_response).expect("prepared"); + let now = MonotonicInstant::now(); + let observer = RecordingObserver::default(); + let state = Rc::new(RefCell::new(MockState::default())); + let mut committer = MockCommitter::new(Rc::clone(&state)); + + let platform_response = match start_prepared( + prepared, + future_policy(now), + new_attempt(&observer, now), + stable_clock(now), + EgressKind::Application, + &mut committer, + ) { + StartOutcome::Started(response) => response, + StartOutcome::Precommit { cause, .. } => { + panic!("unexpected start failure: {cause:?}") + } + }; + assert!(!platform_response.transmits_body); + committer.run_task(); + + assert_eq!(polls.get(), 0); + assert_eq!(drops.get(), 1); + assert!(state.borrow().accepted.is_empty()); + assert_eq!(observer.reports()[0].bytes_written, 0); + } } diff --git a/crates/edgezero-adapter-spin/src/secret_store.rs b/crates/edgezero-adapter-spin/src/secret_store.rs index f160f1786..a591d42dc 100644 --- a/crates/edgezero-adapter-spin/src/secret_store.rs +++ b/crates/edgezero-adapter-spin/src/secret_store.rs @@ -4,9 +4,19 @@ //! The `store_name` parameter is intentionally ignored; provision secrets as //! application variables in `spin.toml`. +use std::future::Future; +#[cfg(test)] +use std::future::{pending, ready}; +use std::time::Duration; + use async_trait::async_trait; use bytes::Bytes; -use edgezero_core::secret_store::{SecretError, SecretStore}; +use edgezero_core::config_store::BoundedStoreRead; +use edgezero_core::secret_store::{SecretError, SecretStore, finish_bounded_secret_read}; +use edgezero_core::time::{Deadline, MonotonicClock}; +use futures_util::future::{Either, select}; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use spin_sdk::time::sleep; /// Secret store backed by Spin component variables. /// @@ -58,7 +68,203 @@ impl SecretStore for SpinSecretStore { ))), } } + + #[inline] + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + bounded_secret_read( + self.get_bytes(store_name, key), + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + .await + } +} + +// Spin Variables returns a complete value, so the byte bounds apply after host +// materialization. The timer race bounds guest return but does not prove host cancellation. +pub(crate) async fn bounded_secret_read( + read: F, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, +) -> Result, SecretError> +where + F: Future, SecretError>>, +{ + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + { + return bounded_secret_read_with_timer( + read, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + sleep, + ) + .await; + } + #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] + { + bounded_secret_read_with_timer( + read, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + |_| pending(), + ) + .await + } } -// TODO: integration tests require the Spin runtime. -// Test SpinSecretStore as part of a Spin E2E test suite. +async fn bounded_secret_read_with_timer( + read: F, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + mut make_timer: MakeTimer, +) -> Result, SecretError> +where + F: Future, SecretError>>, + MakeTimer: FnMut(Duration) -> Timer, + Timer: Future, +{ + futures_util::pin_mut!(read); + loop { + let Some(remaining) = deadline.remaining_at(clock.now()) else { + return Err(SecretError::DeadlineExceeded); + }; + let timer = make_timer(remaining); + futures_util::pin_mut!(timer); + match select(timer, read.as_mut()).await { + Either::Left(((), _read)) => { + if deadline.is_expired_at(clock.now()) { + return Err(SecretError::DeadlineExceeded); + } + } + Either::Right((result, _timer)) => { + return finish_bounded_secret_read( + result, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + use edgezero_core::time::MonotonicClock; + use futures::executor::block_on; + + struct DropProbe(Arc); + + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + fn clock_reaching_deadline() -> (MonotonicClock, Deadline) { + let start = edgezero_core::MonotonicInstant::now(); + let terminal = start + .checked_add(Duration::from_secs(1)) + .expect("terminal instant"); + let samples = Arc::new(AtomicUsize::new(0)); + let observed_samples = Arc::clone(&samples); + let clock = MonotonicClock::new(move || { + if observed_samples.fetch_add(1, Ordering::SeqCst) == 0 { + start + } else { + terminal + } + }); + (clock, Deadline::at_instant(terminal)) + } + + #[test] + fn bounded_secret_timer_drops_never_ready_provider() { + let drops = Arc::new(AtomicUsize::new(0)); + let guard = DropProbe(Arc::clone(&drops)); + let read = async move { + let _guard = guard; + pending::, SecretError>>().await + }; + let (clock, deadline) = clock_reaching_deadline(); + + let error = block_on(bounded_secret_read_with_timer( + read, + &clock, + deadline, + 1, + 1, + |_| ready(()), + )) + .expect_err("timer must terminate a pending secret read"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + + #[test] + fn bounded_secret_read_rearms_after_an_early_timer_wake() { + let timer_calls = Arc::new(AtomicUsize::new(0)); + let observed_timer_calls = Arc::clone(&timer_calls); + let clock = MonotonicClock::default(); + let error = block_on(bounded_secret_read_with_timer( + ready(Err(SecretError::Unavailable)), + &clock, + Deadline::after(Duration::from_secs(1)), + 1, + 1, + move |_| { + let early_wake = observed_timer_calls.fetch_add(1, Ordering::SeqCst) == 0; + async move { + if !early_wake { + pending::<()>().await; + } + } + }, + )) + .expect_err("provider error must win after an early timer wake"); + + assert!(matches!(error, SecretError::Unavailable)); + assert_eq!(timer_calls.load(Ordering::SeqCst), 2); + } + + #[test] + fn bounded_secret_timer_wins_simultaneous_ready_error_at_deadline() { + let (clock, deadline) = clock_reaching_deadline(); + let error = block_on(bounded_secret_read_with_timer( + ready(Err(SecretError::Unavailable)), + &clock, + deadline, + 1, + 1, + |_| ready(()), + )) + .expect_err("timer readiness at the deadline must win equality"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + } +} diff --git a/crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs b/crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs index eb7e6e610..a22581d09 100644 --- a/crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs +++ b/crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs @@ -7,12 +7,12 @@ )] #[cfg(target_arch = "wasm32")] -use spin_sdk::http::{IntoResponse, Request}; +use spin_sdk::http::Request; #[cfg(target_arch = "wasm32")] use spin_sdk::http_service; #[cfg(target_arch = "wasm32")] #[http_service] -async fn handle(req: Request) -> anyhow::Result { +async fn handle(req: Request) -> anyhow::Result { edgezero_adapter_spin::run_app::<{{proj_core_mod}}::App>(req).await } diff --git a/crates/edgezero-adapter-spin/tests/contract.rs b/crates/edgezero-adapter-spin/tests/contract.rs index 7e4af0eee..fec81f90d 100644 --- a/crates/edgezero-adapter-spin/tests/contract.rs +++ b/crates/edgezero-adapter-spin/tests/contract.rs @@ -1,10 +1,6 @@ -// Adapter contract tests run on the Spin wasm32 target, matching the -// fastly and cloudflare contract suites. Gating the whole file keeps the -// host `cargo test`/`clippy` runs consistent across adapters. -#![cfg(all(feature = "spin", target_arch = "wasm32"))] - // Compile-time check: SpinKvStore and SpinSecretStore implement their // respective core store traits. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] mod store_trait_compile_checks { use edgezero_adapter_spin::key_value_store::SpinKvStore; use edgezero_adapter_spin::secret_store::SpinSecretStore; @@ -21,107 +17,75 @@ mod store_trait_compile_checks { } #[cfg(test)] +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[cfg_attr( + feature = "test-utils", + expect( + clippy::arbitrary_source_item_ordering, + reason = "ingress contracts are grouped after the provider fixture tests" + ) +)] mod tests { - // `from_core_response` tests live in a nested module so they're grouped - // together; the `tests_outside_test_module` lint is satisfied by the - // outer `#[cfg(test)] mod tests` wrapper. - mod from_core_response_tests { - use super::*; - use edgezero_adapter_spin::response::from_core_response; - use http_body_util::BodyExt as _; - - #[test] - fn from_core_response_translates_status_and_headers() { - block_on(async { - let response = response_builder() - .status(StatusCode::CREATED) - .header("x-edgezero-res", "1") - .body(Body::from(b"hello".to_vec())) - .expect("response"); - - let spin_response = from_core_response(response).await.expect("spin response"); - - assert_eq!( - spin_response.status(), - StatusCode::CREATED, - "status translated" - ); - assert!( - spin_response.headers().get("x-edgezero-res").is_some(), - "response header preserved" - ); - }); - } - - #[test] - fn from_core_response_collects_streaming_body() { - block_on(async { - let response = response_builder() - .status(StatusCode::OK) - .body(Body::stream(stream::iter(vec![ - Bytes::from_static(b"chunk-1"), - Bytes::from_static(b"chunk-2"), - ]))) - .expect("response"); - - let spin_response = from_core_response(response).await.expect("spin response"); - - assert_eq!(spin_response.status(), StatusCode::OK, "status translated"); - let body = spin_response - .into_body() - .collect() - .await - .expect("collect") - .to_bytes(); - assert_eq!(body.as_ref(), b"chunk-1chunk-2", "streaming body collected"); - }); - } - - #[test] - fn from_core_response_handles_empty_body() { - block_on(async { - let response = response_builder() - .status(StatusCode::NO_CONTENT) - .body(Body::from(Vec::new())) - .expect("response"); - - let spin_response = from_core_response(response).await.expect("spin response"); - - assert_eq!( - spin_response.status(), - StatusCode::NO_CONTENT, - "status translated" - ); - let body = spin_response - .into_body() - .collect() - .await - .expect("collect") - .to_bytes(); - assert!(body.is_empty(), "empty body preserved"); - }); - } - } - use bytes::Bytes; + #[cfg(feature = "test-utils")] + use edgezero_adapter_spin::build_app_for_test; use edgezero_adapter_spin::context::SpinRequestContext; + #[cfg(feature = "test-utils")] + use edgezero_adapter_spin::request::deadline_body_releases_source_for_test; use edgezero_core::app::App; + #[cfg(feature = "test-utils")] + use edgezero_core::app::Hooks; use edgezero_core::body::Body; - use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::config_store::{ + BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigStoreHandle, + finish_bounded_config_read, + }; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{Response, StatusCode, request_builder, response_builder}; use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; use edgezero_core::router::RouterService; - use edgezero_core::secret_store::{SecretError, SecretHandle, SecretStore}; + use edgezero_core::secret_store::{ + SecretError, SecretHandle, SecretStore, finish_bounded_secret_read, + }; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, }; + use edgezero_core::time::{Deadline, MonotonicClock}; use futures::executor::block_on; use futures::stream; use std::sync::Arc; use std::time::Duration; + /// Config store that returns a value only for the expected key. + #[cfg(feature = "test-utils")] + struct FailingConfiguration; + + #[cfg(feature = "test-utils")] + #[expect( + clippy::missing_trait_methods, + reason = "test hook exercises only adapter startup failure" + )] + impl Hooks for FailingConfiguration { + fn configure(_app: &mut App) -> Result<(), EdgeError> { + Err(EdgeError::service_unavailable("configuration unavailable")) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[cfg(feature = "test-utils")] + #[test] + fn failing_configuration() { + let result = build_app_for_test::(); + assert!(result.is_err()); + let error = result.err().expect("configuration must fail"); + assert_eq!(error.to_string(), "application configuration failed"); + assert!(format!("{error:#}").contains("configuration unavailable")); + } + /// Config store that returns a value only for the expected key. struct FixedConfigStore { key: &'static str, @@ -130,13 +94,36 @@ mod tests { #[async_trait::async_trait(?Send)] impl ConfigStore for FixedConfigStore { - async fn get(&self, key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + key: &str, + ) -> Result, ConfigStoreError> { if key == self.key { - Ok(Some(self.value.to_owned())) + Ok(Some(self.value.into())) } else { Ok(None) } } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + finish_bounded_config_read( + self.get(key).await, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + } } /// KV store that returns a fixed value for one key; everything else is absent. @@ -203,11 +190,32 @@ mod tests { Ok(None) } } + + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + if deadline.is_expired_at(clock.now()) { + return Err(SecretError::DeadlineExceeded); + } + finish_bounded_secret_read( + self.get_bytes(store_name, key).await, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + } } fn build_test_app() -> App { async fn capture_uri(ctx: RequestContext) -> Result { - let body = Body::text(ctx.request().uri().to_string()); + let body = Body::text(ctx.uri().to_string()); let response = response_builder() .status(StatusCode::OK) .body(body) @@ -216,12 +224,7 @@ mod tests { } async fn mirror_body(ctx: RequestContext) -> Result { - let bytes = ctx - .request() - .body() - .as_bytes() - .expect("buffered request body") - .to_vec(); + let bytes = ctx.body_bytes(1024 * 1024).await?.to_vec(); let response = response_builder() .status(StatusCode::OK) .body(Body::from(bytes)) @@ -252,7 +255,7 @@ mod tests { .await .ok() .flatten() - .unwrap_or_else(|| "missing".to_owned()), + .map_or_else(|| "missing".to_owned(), |value| value.to_string()), None => "missing".to_owned(), }; let response = response_builder() @@ -333,6 +336,12 @@ mod tests { let _app = build_test_app(); } + #[cfg(feature = "test-utils")] + #[test] + fn deadline_body_releases_source_when_timeout_is_emitted() { + assert!(deadline_body_releases_source_for_test()); + } + #[test] fn router_dispatches_get_and_returns_response() { let app = build_test_app(); @@ -536,4 +545,1266 @@ mod tests { "no secret handle yields the no-handle marker" ); } + + #[cfg(feature = "test-utils")] + mod ingress_contract { + use std::collections::VecDeque; + use std::future::{Future, poll_fn as poll_future, ready}; + use std::io; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::task::Poll; + + use edgezero_adapter_spin::outbound::{ + SpinOutboundClient, converted_response_content_type_provenance_holds_for_test, + converted_response_retains_injected_clock_for_test, deferred_clock_paths_hold_for_test, + }; + use edgezero_adapter_spin::request::{ + dispatch_ingress_source_error_for_test, dispatch_ingress_stream_for_test, + dispatch_ingress_stream_with_timer_for_test, dispatch_ingress_with_for_test, + dispatch_request_for_test, + }; + use edgezero_core::http::{HeaderMap, HeaderValue, Method}; + use edgezero_core::ingress::{ + AdmissionDecision, BufferedIngressResponse, IngressGrant, IngressHeadLimits, + }; + use edgezero_core::middleware::{Middleware, Next}; + use edgezero_core::outbound::{OutboundHttpClient as _, OutboundRequest}; + use edgezero_core::response_egress::{ + DEFAULT_RESPONSE_WRITE_BUDGET, DetachedResponseEgressDecision, + ResponseEgressCompletion, ResponseEgressEnvelope, ResponseEgressObserver, + ResponseEgressOutcome, ResponseEgressReport, + }; + use edgezero_core::router::{RouteMetadata, RouteResolution}; + use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; + use futures::FutureExt as _; + use futures::stream::{empty, poll_fn}; + use spin_sdk::http::{FromRequest as _, IntoRequest as _, Request as SpinRequest}; + + use super::*; + + #[test] + fn admission_abort() { + let source_calls = Arc::new(AtomicUsize::new(0)); + let observed_source_calls = Arc::clone(&source_calls); + let delivery_calls = Arc::new(AtomicUsize::new(0)); + let observed_delivery_calls = Arc::clone(&delivery_calls); + let mut app = App::new(RouterService::builder().build()); + app.set_ingress_admission_policy(|_| AdmissionDecision::Abort); + + let error = block_on(dispatch_ingress_with_for_test( + &app, + Method::POST, + "/missing".parse().expect("URI"), + move || { + observed_source_calls.fetch_add(1, Ordering::SeqCst); + Ok(stream::empty::>()) + }, + move |_response| { + observed_delivery_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + )) + .expect_err("abort must return a platform error"); + + assert!(error.to_string().contains("ingress admission aborted")); + assert_eq!(source_calls.load(Ordering::SeqCst), 0); + assert_eq!(delivery_calls.load(Ordering::SeqCst), 0); + } + + struct DropSignal(Arc); + + #[derive(Clone)] + struct RecordingEgressObserver(Arc>>); + + impl ResponseEgressObserver for RecordingEgressObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.0.lock().expect("reports lock").push(report.clone()); + } + } + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + fn scripted_clock(script: Vec) -> MonotonicClock { + let observations = Arc::new(Mutex::new(VecDeque::from(script))); + MonotonicClock::new(move || { + observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + }) + } + + #[test] + fn outbound_clock_controls_preflight_elapsed_and_backwards_failure() { + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(9)) + .expect("completed instant"); + let forward_client = + SpinOutboundClient::with_clock(scripted_clock(vec![start, completed])); + let forward_request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + let observation_cutoff = Deadline::at_instant( + start + .checked_add(Duration::from_secs(1)) + .expect("cutoff instant"), + ); + let forward_results = block_on( + forward_client + .start_batch_until(vec![forward_request], observation_cutoff) + .collect(), + ) + .expect("valid batch driver"); + assert_eq!( + forward_results.termination, + edgezero_core::OutboundBatchTermination::Completed + ); + assert_eq!(forward_results.slots.len(), 1); + let forward_result = forward_results.slots[0] + .as_ref() + .expect("single forward result"); + assert_eq!(forward_result.elapsed, Duration::from_millis(9)); + assert!(matches!( + forward_result.outcome, + Err(EdgeError::BadRequest { .. }) + )); + + let earlier = start + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let backwards_client = + SpinOutboundClient::with_clock(scripted_clock(vec![start, earlier])); + let backwards_request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + let backwards_results = block_on( + backwards_client + .start_batch_until(vec![backwards_request], observation_cutoff) + .collect(), + ) + .expect("valid batch driver"); + assert_eq!( + backwards_results.termination, + edgezero_core::OutboundBatchTermination::Completed + ); + assert_eq!(backwards_results.slots.len(), 1); + let backwards_result = backwards_results.slots[0] + .as_ref() + .expect("single backwards result"); + assert_eq!(backwards_result.elapsed, Duration::ZERO); + assert!(matches!( + backwards_result.outcome, + Err(EdgeError::Internal { .. }) + )); + } + + #[test] + fn expired_observation_cutoff_leaves_slot_unresolved() { + let now = MonotonicInstant::now(); + let client = SpinOutboundClient::with_clock(MonotonicClock::new(move || now)); + let request = OutboundRequest::get("https://example.com/").expect("request"); + + let results = block_on( + client + .start_batch_until(vec![request], Deadline::at_instant(now)) + .collect(), + ) + .expect("valid batch driver"); + + assert_eq!( + results.termination, + edgezero_core::OutboundBatchTermination::Cutoff + ); + assert!(matches!(results.slots.first(), Some(None))); + } + + #[test] + fn batch_preserves_three_preflight_slots_in_input_order() { + let now = MonotonicInstant::now(); + let client = SpinOutboundClient::with_clock(MonotonicClock::new(move || now)); + let streamed_upload = OutboundRequest::post("https://example.com/upload") + .expect("upload request") + .body(Body::stream(stream::iter([Bytes::from_static(b"body")]))); + let streamed_response = OutboundRequest::get("https://example.com/stream") + .expect("stream request") + .stream_response(); + let method_error = OutboundRequest::get("https://example.com/get") + .expect("GET request") + .body(Body::stream(stream::iter([Bytes::new()]))); + + let results = block_on( + client + .start_batch_until( + vec![streamed_upload, streamed_response, method_error], + Deadline::at_instant( + now.checked_add(Duration::from_secs(1)) + .expect("cutoff instant"), + ), + ) + .collect(), + ) + .expect("valid batch driver"); + assert_eq!( + results.termination, + edgezero_core::OutboundBatchTermination::Completed + ); + let messages: Vec<_> = results + .slots + .iter() + .map( + |slot| match &slot.as_ref().expect("resolved preflight slot").outcome { + Err(EdgeError::BadRequest { message }) => message.as_str(), + other => panic!("expected preflight rejection, got {other:?}"), + }, + ) + .collect(); + + assert_eq!( + messages, + [ + "outbound batches require buffered request bodies; use send for a streamed upload", + "outbound batches require buffered responses; use send for a streamed response", + "GET/HEAD request must not carry a streamed body; emptiness cannot be determined without consuming the stream", + ] + ); + assert!(results.slots.iter().all(|slot| { + slot.as_ref() + .is_some_and(|result| result.elapsed == Duration::ZERO) + })); + } + + #[test] + fn deferred_request_and_response_paths_retain_the_injected_clock() { + assert!(block_on(deferred_clock_paths_hold_for_test())); + } + + #[test] + fn converted_response_retains_injected_clock() { + assert!(block_on( + converted_response_retains_injected_clock_for_test() + )); + } + + #[test] + fn converted_response_preserves_original_content_type_provenance() { + assert!(block_on( + converted_response_content_type_provenance_holds_for_test() + )); + } + + #[test] + fn standard_dispatch_installs_the_application_outbound_clock() { + async fn elapsed(ctx: RequestContext) -> Result { + let client = ctx + .http_client() + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("missing HTTP client")))?; + let request = OutboundRequest::get("https://example.com/")?.stream_response(); + let results = client + .send_all_until(vec![request], Deadline::after(Duration::from_secs(1))) + .await?; + let result = results + .slots + .first() + .and_then(Option::as_ref) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!("missing outbound result")) + })?; + Ok(result.elapsed.as_millis().to_string()) + } + + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(7)) + .expect("completed instant"); + let observations = Arc::new(AtomicUsize::new(0)); + let clock_observations = Arc::clone(&observations); + let mut app = App::new(RouterService::builder().get("/clock", elapsed).build()); + app.set_monotonic_clock(MonotonicClock::new(move || { + if clock_observations.fetch_add(1, Ordering::SeqCst) < 2 { + start + } else { + completed + } + })); + let source = empty::>(); + + let response = block_on(dispatch_ingress_stream_for_test( + &app, + Method::GET, + "/clock".parse().expect("URI"), + source, + )) + .expect("Spin response"); + + let bytes = captured_body(response); + assert_eq!(bytes.as_ref(), b"7"); + assert!(observations.load(Ordering::SeqCst) >= 3); + } + + #[test] + fn standard_native_request_refuses_before_body_read() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + app.set_ingress_admission_policy(|head| { + assert!(matches!(head.route_resolution(), RouteResolution::NotFound)); + AdmissionDecision::Refuse { + completion: ResponseEgressCompletion::empty(), + response: response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("x-ingress-refusal", "saturated") + .body(Body::from("spin unavailable\n")) + .expect("refusal response"), + } + }); + let outgoing_request = SpinRequest::builder() + .method("POST") + .uri("http://example.test/missing") + .body(http_body_util::Full::new(Bytes::from_static(b"abcde"))) + .expect("outgoing-compatible request"); + let wasi_request = outgoing_request.into_request().expect("WASI request"); + let incoming_request = + SpinRequest::from_request(wasi_request).expect("incoming Spin request"); + + let response = + block_on(dispatch_request_for_test(&app, incoming_request)).expect("Spin response"); + + let mut expected_headers = HeaderMap::new(); + expected_headers.insert("x-ingress-refusal", HeaderValue::from_static("saturated")); + assert_terminal_response( + response, + StatusCode::SERVICE_UNAVAILABLE, + &expected_headers, + b"spin unavailable\n", + ); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + + struct CountingMiddleware(Arc); + + #[async_trait::async_trait(?Send)] + impl Middleware for CountingMiddleware { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } + } + + fn guarded_fallback_app() -> (App, Arc, Arc) { + let handler_calls = Arc::new(AtomicUsize::new(0)); + let handler_counter = Arc::clone(&handler_calls); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .get("/known", move |_ctx: RequestContext| { + let request_handler_calls = Arc::clone(&handler_counter); + async move { + request_handler_calls.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("handler must not run") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + (App::new(router), handler_calls, middleware_calls) + } + + fn fallback_app( + max_body_bytes: usize, + read_budget: Duration, + grant_drops: &Arc, + ) -> (App, Arc, Arc) { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let observed_grant_drops = Arc::clone(grant_drops); + app.set_ingress_admission_policy(move |head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound + )); + AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes, + read_deadline: head.read_deadline_after(read_budget), + on_exceeded: terminal_response( + StatusCode::UNPROCESSABLE_ENTITY, + "overflow", + b"spin overflow\0response", + ), + on_timeout: terminal_response( + StatusCode::GATEWAY_TIMEOUT, + "timeout", + b"spin timeout response\n", + ), + } + }); + (app, handler_calls, middleware_calls) + } + + fn matched_body_app( + read_budget: Duration, + grant_drops: &Arc, + ) -> (App, Arc, Arc) { + let handler_calls = Arc::new(AtomicUsize::new(0)); + let handler_counter = Arc::clone(&handler_calls); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .post("/body", move |ctx: RequestContext| { + let request_handler_calls = Arc::clone(&handler_counter); + async move { + request_handler_calls.fetch_add(1, Ordering::SeqCst); + let _bytes = ctx.body_bytes(4).await?; + Ok::<_, EdgeError>("body read completed unexpectedly") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + let mut app = App::new(router); + let observed_grant_drops = Arc::clone(grant_drops); + app.set_ingress_admission_policy(move |head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::Matched(metadata) if metadata.pattern() == "/body" + )); + AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + read_deadline: head.read_deadline_after(read_budget), + } + }); + (app, handler_calls, middleware_calls) + } + + fn deadline_timer() -> impl Future { + let mut first_poll = true; + poll_future(move |cx| { + if first_poll { + first_poll = false; + cx.waker().wake_by_ref(); + Poll::Pending + } else { + Poll::Ready(()) + } + }) + } + + fn terminal_headers(marker: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + HeaderValue::from_static("application/octet-stream"), + ); + headers.insert("x-ingress-terminal", HeaderValue::from_static(marker)); + headers + } + + fn terminal_response( + status: StatusCode, + marker: &'static str, + body: &'static [u8], + ) -> BufferedIngressResponse { + BufferedIngressResponse::new(status, terminal_headers(marker), Bytes::from_static(body)) + } + + fn internal_error_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("content-length", HeaderValue::from_static("76")); + headers.insert("content-type", HeaderValue::from_static("application/json")); + headers + } + + fn tracked_stream( + chunks: Vec, + grant_drops: &Arc, + source_drops: &Arc, + body_polls: &Arc, + expire_on_poll: Option<(Arc>, MonotonicInstant)>, + ) -> impl futures::Stream> + 'static { + let observed_grant_drops = Arc::clone(grant_drops); + let observed_body_polls = Arc::clone(body_polls); + let source_drop = DropSignal(Arc::clone(source_drops)); + let mut pending_chunks = chunks.into_iter(); + poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + observed_body_polls.fetch_add(1, Ordering::SeqCst); + if let Some((now, deadline)) = &expire_on_poll { + *now.lock().expect("clock lock") = *deadline; + } + Poll::Ready(pending_chunks.next().map(Ok)) + }) + } + + fn error_stream( + grant_drops: &Arc, + source_drops: &Arc, + body_polls: &Arc, + ) -> impl futures::Stream> + 'static { + let observed_grant_drops = Arc::clone(grant_drops); + let observed_body_polls = Arc::clone(body_polls); + let source_drop = DropSignal(Arc::clone(source_drops)); + let mut emitted = false; + poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + observed_body_polls.fetch_add(1, Ordering::SeqCst); + if emitted { + Poll::Ready(None) + } else { + emitted = true; + Poll::Ready(Some(Err(io::Error::other("spin ingress source failure")))) + } + }) + } + + fn pending_stream( + grant_drops: &Arc, + source_drops: &Arc, + body_polls: &Arc, + ) -> impl futures::Stream> + 'static { + let observed_grant_drops = Arc::clone(grant_drops); + let observed_body_polls = Arc::clone(body_polls); + let source_drop = DropSignal(Arc::clone(source_drops)); + poll_fn(move |_cx| { + let _keep_source_alive = &source_drop; + assert_eq!(observed_grant_drops.load(Ordering::SeqCst), 0); + observed_body_polls.fetch_add(1, Ordering::SeqCst); + Poll::Pending + }) + } + + fn assert_no_route_dispatch(handler_calls: &AtomicUsize, middleware_calls: &AtomicUsize) { + assert_eq!(handler_calls.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + } + + fn capture_response(envelope: ResponseEgressEnvelope) -> Response { + let Ok((prepared, _policy, mut attempt, clock)) = envelope.begin() else { + panic!("test egress envelope must prepare"); + }; + assert!(attempt.begin_writing()); + assert!(attempt.terminate(ResponseEgressOutcome::HostHandoff, clock.now())); + prepared.into_response() + } + + fn assert_terminal_response( + envelope: ResponseEgressEnvelope, + status: StatusCode, + expected_headers: &HeaderMap, + body: &[u8], + ) { + let response = capture_response(envelope); + assert_eq!(response.status(), status); + assert_eq!(response.headers(), expected_headers); + let bytes = captured_response_body(response); + assert_eq!(bytes.as_ref(), body); + } + + fn captured_body(envelope: ResponseEgressEnvelope) -> Bytes { + captured_response_body(capture_response(envelope)) + } + + #[test] + fn normalized_ingress_error_returns_typed_response_without_application_observation() { + let reports = Arc::new(Mutex::new(Vec::new())); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let mut app = App::new(RouterService::builder().build()); + app.set_ingress_head_limits( + IngressHeadLimits::default() + .with_max_request_target_bytes(4) + .expect("target limit"), + ); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let observed_factory_calls = Arc::clone(&factory_calls); + let observed_completion_calls = Arc::clone(&completion_calls); + app.set_detached_response_egress_decision_factory(move |head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + let terminal_calls = Arc::clone(&observed_completion_calls); + DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::new(move |_report| { + terminal_calls.fetch_add(1, Ordering::SeqCst); + }), + deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET), + } + }); + + let egress = block_on(dispatch_ingress_stream_for_test( + &app, + Method::GET, + "/too-long".parse().expect("URI"), + empty::>(), + )) + .expect("typed response"); + let response = capture_response(egress); + + assert_eq!(response.status(), StatusCode::URI_TOO_LONG); + assert!(reports.lock().expect("reports lock").is_empty()); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert_eq!(completion_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn normalized_ingress_error_decision_abort_uses_provider_error() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + app.set_ingress_head_limits( + IngressHeadLimits::default() + .with_max_request_target_bytes(4) + .expect("target limit"), + ); + let reports = Arc::new(Mutex::new(Vec::new())); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let observed_factory_calls = Arc::clone(&factory_calls); + app.set_detached_response_egress_decision_factory(move |_head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + DetachedResponseEgressDecision::Abort + }); + let source_calls = Arc::new(AtomicUsize::new(0)); + let observed_source_calls = Arc::clone(&source_calls); + let delivery_calls = Arc::new(AtomicUsize::new(0)); + let observed_delivery_calls = Arc::clone(&delivery_calls); + + let error = block_on(dispatch_ingress_with_for_test( + &app, + Method::GET, + "/too-long".parse().expect("URI"), + move || { + observed_source_calls.fetch_add(1, Ordering::SeqCst); + Ok(empty::>()) + }, + move |_egress| { + observed_delivery_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + )) + .expect_err("detached abort must return a platform error"); + + assert!(error.to_string().contains("ingress admission aborted")); + assert_eq!(source_calls.load(Ordering::SeqCst), 0); + assert_eq!(delivery_calls.load(Ordering::SeqCst), 0); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert!(reports.lock().expect("reports lock").is_empty()); + } + + #[test] + fn admission_policy_error_decision_send_uses_detached_completion() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let decision_completion_calls = Arc::new(AtomicUsize::new(0)); + let decision_resource_drops = Arc::new(AtomicUsize::new(0)); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_decision_completion_calls = Arc::clone(&decision_completion_calls); + let observed_decision_resource_drops = Arc::clone(&decision_resource_drops); + let observed_grant_drops = Arc::clone(&grant_drops); + app.set_ingress_admission_policy(move |head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::Matched(_) + )); + let decision_resource = DropSignal(Arc::clone(&observed_decision_resource_drops)); + let terminal_calls = Arc::clone(&observed_decision_completion_calls); + AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::new(move |_report| { + let _resource = decision_resource; + terminal_calls.fetch_add(1, Ordering::SeqCst); + }), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes: 1, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text( + StatusCode::PAYLOAD_TOO_LARGE, + "too large", + ), + on_timeout: BufferedIngressResponse::text( + StatusCode::REQUEST_TIMEOUT, + "timeout", + ), + } + }); + let reports = Arc::new(Mutex::new(Vec::new())); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let observed_factory_calls = Arc::clone(&factory_calls); + let observed_completion_calls = Arc::clone(&completion_calls); + app.set_detached_response_egress_decision_factory(move |head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + let terminal_calls = Arc::clone(&observed_completion_calls); + DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::new(move |_report| { + terminal_calls.fetch_add(1, Ordering::SeqCst); + }), + deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET), + } + }); + let source_calls = Arc::new(AtomicUsize::new(0)); + let observed_source_calls = Arc::clone(&source_calls); + + let response = block_on(dispatch_ingress_with_for_test( + &app, + Method::GET, + "/known".parse().expect("URI"), + move || { + observed_source_calls.fetch_add(1, Ordering::SeqCst); + Ok(empty::>()) + }, + |egress| Ok(capture_response(egress)), + )) + .expect("typed policy error response"); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(source_calls.load(Ordering::SeqCst), 0); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + assert_eq!(decision_completion_calls.load(Ordering::SeqCst), 0); + assert_eq!(decision_resource_drops.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert_eq!(completion_calls.load(Ordering::SeqCst), 1); + assert!(reports.lock().expect("reports lock").is_empty()); + } + + #[test] + fn admission_policy_error_decision_abort_uses_provider_error() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let decision_resource_drops = Arc::new(AtomicUsize::new(0)); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_decision_resource_drops = Arc::clone(&decision_resource_drops); + let observed_grant_drops = Arc::clone(&grant_drops); + app.set_ingress_admission_policy(move |head| { + AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::new({ + let resource = DropSignal(Arc::clone(&observed_decision_resource_drops)); + move |_report| drop(resource) + }), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + max_body_bytes: 1, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text( + StatusCode::PAYLOAD_TOO_LARGE, + "too large", + ), + on_timeout: BufferedIngressResponse::text( + StatusCode::REQUEST_TIMEOUT, + "timeout", + ), + } + }); + let reports = Arc::new(Mutex::new(Vec::new())); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let observed_factory_calls = Arc::clone(&factory_calls); + app.set_detached_response_egress_decision_factory(move |_head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + DetachedResponseEgressDecision::Abort + }); + let source_calls = Arc::new(AtomicUsize::new(0)); + let observed_source_calls = Arc::clone(&source_calls); + let delivery_calls = Arc::new(AtomicUsize::new(0)); + let observed_delivery_calls = Arc::clone(&delivery_calls); + + let error = block_on(dispatch_ingress_with_for_test( + &app, + Method::GET, + "/known".parse().expect("URI"), + move || { + observed_source_calls.fetch_add(1, Ordering::SeqCst); + Ok(empty::>()) + }, + move |_egress| { + observed_delivery_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + )) + .expect_err("detached abort must return a platform error"); + + assert!(error.to_string().contains("ingress admission aborted")); + assert_eq!(source_calls.load(Ordering::SeqCst), 0); + assert_eq!(delivery_calls.load(Ordering::SeqCst), 0); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + assert_eq!(decision_resource_drops.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert!(reports.lock().expect("reports lock").is_empty()); + } + + #[test] + fn post_admission_failure_uses_owned_error_egress() { + let reports = Arc::new(Mutex::new(Vec::new())); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_grant_drops = Arc::clone(&grant_drops); + let mut app = App::new( + RouterService::builder() + .get("/owned", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("handler must not run") + }) + .build(), + ); + app.set_ingress_admission_policy(move |head| AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::new(DropSignal(Arc::clone(&observed_grant_drops))), + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + }); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + + let egress = block_on(dispatch_ingress_source_error_for_test( + &app, + Method::GET, + "/owned".parse().expect("URI"), + )) + .expect("owned error egress"); + let response = capture_response(egress); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + let observed = reports.lock().expect("reports lock"); + assert_eq!(observed.len(), 1); + let report = observed.first().expect("one report"); + assert_eq!(report.outcome, ResponseEgressOutcome::HostHandoff); + assert_eq!( + report.route.as_ref().map(RouteMetadata::pattern), + Some("/owned") + ); + } + + fn captured_response_body(response: Response) -> Bytes { + block_on(response.into_body().into_bytes_bounded(usize::MAX)) + .expect("captured response body") + } + + #[test] + fn exact_cap_preserves_not_found_and_method_not_allowed() { + for (path, expected_status) in [ + ("/missing", StatusCode::NOT_FOUND), + ("/known", StatusCode::METHOD_NOT_ALLOWED), + ] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = tracked_stream( + vec![Bytes::from_static(b"ab"), Bytes::from_static(b"cd")], + &grant_drops, + &source_drops, + &body_polls, + None, + ); + + let envelope = block_on(dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + )) + .expect("response"); + let response = capture_response(envelope); + + assert_eq!(response.status(), expected_status); + if expected_status == StatusCode::METHOD_NOT_ALLOWED { + assert_eq!( + response.headers().get("allow").expect("Allow header"), + "GET" + ); + } + assert_eq!(body_polls.load(Ordering::SeqCst), 3); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn cap_plus_one_precedes_not_found_and_method_not_allowed() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = tracked_stream( + vec![Bytes::from_static(b"abcd"), Bytes::from_static(b"e")], + &grant_drops, + &source_drops, + &body_polls, + None, + ); + + let response = block_on(dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + )) + .expect("response"); + + assert_terminal_response( + response, + StatusCode::UNPROCESSABLE_ENTITY, + &terminal_headers("overflow"), + b"spin overflow\0response", + ); + assert_eq!(body_polls.load(Ordering::SeqCst), 2); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn best_effort_pre_read_expiry_preserves_application_response() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let start = MonotonicInstant::now(); + let (mut app, handler_calls, middleware_calls) = + fallback_app(4, Duration::ZERO, &grant_drops); + app.set_monotonic_clock(MonotonicClock::new(move || start)); + let source = tracked_stream( + vec![Bytes::from_static(b"body")], + &grant_drops, + &source_drops, + &body_polls, + None, + ); + + let response = block_on(dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + )) + .expect("response"); + + assert_terminal_response( + response, + StatusCode::GATEWAY_TIMEOUT, + &terminal_headers("timeout"), + b"spin timeout response\n", + ); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn best_effort_post_ready_expiry_preserves_application_response() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let start = MonotonicInstant::now(); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let now = Arc::new(Mutex::new(start)); + let observed_now = Arc::clone(&now); + let (mut app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(1), &grant_drops); + app.set_monotonic_clock(MonotonicClock::new(move || { + *observed_now.lock().expect("clock lock") + })); + let source = tracked_stream( + vec![Bytes::from_static(b"body")], + &grant_drops, + &source_drops, + &body_polls, + Some((now, deadline)), + ); + + let response = block_on(dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + )) + .expect("response"); + + assert_terminal_response( + response, + StatusCode::GATEWAY_TIMEOUT, + &terminal_headers("timeout"), + b"spin timeout response\n", + ); + assert_eq!(body_polls.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn pending_matched_body_read_returns_408_and_releases_lifecycle() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + matched_body_app(Duration::from_millis(100), &grant_drops); + let source = pending_stream(&grant_drops, &source_drops, &body_polls); + + let envelope = block_on(dispatch_ingress_stream_with_timer_for_test( + &app, + Method::POST, + "/body".parse().expect("URI"), + source, + |_remaining| deadline_timer(), + )) + .expect("Spin response"); + let response = capture_response(envelope); + + assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT); + assert_eq!( + response.headers().get("content-type"), + Some(&HeaderValue::from_static("application/json")) + ); + let bytes = captured_response_body(response); + assert_eq!( + bytes.as_ref(), + br#"{"error":{"kind":"request_timeout","message":"inbound body read deadline exceeded","status":408}}"# + ); + assert!(body_polls.load(Ordering::SeqCst) > 0); + assert_eq!(handler_calls.load(Ordering::SeqCst), 1); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + } + + #[test] + fn simultaneously_ready_timer_precedes_matched_body() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + matched_body_app(Duration::from_millis(100), &grant_drops); + let source = tracked_stream( + vec![Bytes::from_static(b"body")], + &grant_drops, + &source_drops, + &body_polls, + None, + ); + + let envelope = block_on(dispatch_ingress_stream_with_timer_for_test( + &app, + Method::POST, + "/body".parse().expect("URI"), + source, + |_remaining| ready(()), + )) + .expect("Spin response"); + let response = capture_response(envelope); + + assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + assert_eq!(handler_calls.load(Ordering::SeqCst), 1); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + } + + #[test] + fn pending_fallback_body_read_preserves_timeout_response_and_releases_lifecycle() { + for path in ["/missing", "/known"] { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_millis(100), &grant_drops); + let source = pending_stream(&grant_drops, &source_drops, &body_polls); + + let response = block_on(dispatch_ingress_stream_with_timer_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + |_remaining| deadline_timer(), + )) + .expect("Spin response"); + + assert_terminal_response( + response, + StatusCode::GATEWAY_TIMEOUT, + &terminal_headers("timeout"), + b"spin timeout response\n", + ); + assert!(body_polls.load(Ordering::SeqCst) > 0); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn saturated_fallback_refuses_without_polling_body() { + for path in ["/missing", "/known"] { + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let observed_completion_calls = Arc::clone(&completion_calls); + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + app.set_ingress_admission_policy(move |head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound + )); + AdmissionDecision::Refuse { + completion: { + let calls = Arc::clone(&observed_completion_calls); + ResponseEgressCompletion::new(move |_report| { + calls.fetch_add(1, Ordering::SeqCst); + }) + }, + response: response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("x-ingress-refusal", "saturated") + .body(Body::from("spin unavailable\n")) + .expect("refusal response"), + } + }); + let unobserved_grant = Arc::new(AtomicUsize::new(0)); + let source = tracked_stream( + vec![Bytes::from_static(b"body")], + &unobserved_grant, + &source_drops, + &body_polls, + None, + ); + + let envelope = block_on(dispatch_ingress_stream_for_test( + &app, + Method::POST, + path.parse().expect("URI"), + source, + )) + .expect("response"); + let response = capture_response(envelope); + + let mut expected_headers = HeaderMap::new(); + expected_headers.insert("x-ingress-refusal", HeaderValue::from_static("saturated")); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers(), &expected_headers); + let bytes = captured_response_body(response); + assert_eq!(bytes.as_ref(), b"spin unavailable\n"); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + assert_eq!(unobserved_grant.load(Ordering::SeqCst), 0); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_eq!(completion_calls.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } + + #[test] + fn source_error_uses_spin_response_boundary_and_releases_lifecycle() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = error_stream(&grant_drops, &source_drops, &body_polls); + + let response = block_on(dispatch_ingress_stream_for_test( + &app, + Method::POST, + "/missing".parse().expect("URI"), + source, + )) + .expect("standard error response"); + + assert_terminal_response( + response, + StatusCode::INTERNAL_SERVER_ERROR, + &internal_error_headers(), + br#"{"error":{"kind":"internal","message":"internal server error","status":500}}"#, + ); + assert_eq!(body_polls.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn poll_then_drop_releases_pending_source_and_grant() { + let grant_drops = Arc::new(AtomicUsize::new(0)); + let source_drops = Arc::new(AtomicUsize::new(0)); + let body_polls = Arc::new(AtomicUsize::new(0)); + let (app, handler_calls, middleware_calls) = + fallback_app(4, Duration::from_secs(30), &grant_drops); + let source = pending_stream(&grant_drops, &source_drops, &body_polls); + + let outcome = dispatch_ingress_stream_for_test( + &app, + Method::POST, + "/missing".parse().expect("URI"), + source, + ) + .now_or_never(); + + assert!(outcome.is_none()); + assert_eq!(body_polls.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!(source_drops.load(Ordering::SeqCst), 1); + assert_no_route_dispatch(&handler_calls, &middleware_calls); + } + } +} + +#[cfg(test)] +#[cfg(all(feature = "test-utils", not(target_arch = "wasm32")))] +mod outbound_contract_tests { + use bytes::Bytes; + use edgezero_adapter_spin::outbound::{ + validate_batch_request_for_test, validate_request_for_test, + }; + use edgezero_core::body::Body; + use edgezero_core::http::{Method, Uri}; + use edgezero_core::outbound::OutboundRequest; + use futures_util::stream; + + fn request() -> OutboundRequest { + OutboundRequest::new( + Method::POST, + "https://example.com/bid".parse::().expect("URI"), + ) + .expect("request") + } + + #[test] + fn batch_preflight_precedence_and_indices() { + let requests = [ + request().body(Bytes::from_static(b"buffered")), + request().body(Body::stream(stream::once(async { + Bytes::from_static(b"streamed") + }))), + request().stream_response(), + request().body(Bytes::from_static(b"last")), + ]; + + let outcomes: Vec<_> = requests + .iter() + .map(validate_batch_request_for_test) + .collect(); + + outcomes[0].as_ref().expect("buffered slot 0"); + assert!(outcomes[1].is_err(), "streamed upload keeps slot index 1"); + assert!(outcomes[2].is_err(), "streamed response keeps slot index 2"); + outcomes[3].as_ref().expect("buffered slot 3"); + } + + #[test] + fn authority_override_fails_before_spin_dispatch() { + let request = request() + .host_authority_override("virtual.example") + .expect("valid portable authority"); + + let error = validate_request_for_test(&request).expect_err("unsupported on Spin"); + assert!(matches!(error, edgezero_core::EdgeError::BadRequest { .. })); + } } diff --git a/crates/edgezero-adapter-spin/tests/sdk_resources.rs b/crates/edgezero-adapter-spin/tests/sdk_resources.rs new file mode 100644 index 000000000..bacd885f8 --- /dev/null +++ b/crates/edgezero-adapter-spin/tests/sdk_resources.rs @@ -0,0 +1,343 @@ +#![cfg(all(feature = "spin", feature = "test-utils", target_arch = "wasm32"))] + +#[cfg(test)] +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "SDK construction checks stay before the exhaustive classifier fixture" +)] +mod tests { + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use edgezero_adapter_spin::outbound::{SpinOutboundClient, map_spin_send_error_for_test}; + use edgezero_core::error::{BadGatewayReason, BudgetSource, EdgeError}; + use edgezero_core::outbound::{OutboundHttpClient, OutboundRequest}; + use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; + use futures::executor::block_on; + use spin_sdk::wasip3::http::types::{ + DnsErrorPayload, ErrorCode, FieldSizePayload, Fields, Request, RequestOptions, Response, + TlsAlertReceivedPayload, + }; + use spin_sdk::wasip3::http_compat::BodyWriter; + + fn assert_outbound_client() {} + + #[test] + fn spin_outbound_client_implements_portable_contract() { + assert_outbound_client::(); + } + + #[test] + fn injected_clock_controls_method_entry_and_preflight_elapsed() { + let start = MonotonicInstant::now(); + let completed = start + .checked_add(Duration::from_millis(7)) + .expect("completed instant"); + let observations = Arc::new(Mutex::new(VecDeque::from([start, completed]))); + let clock_observations = Arc::clone(&observations); + let client = SpinOutboundClient::with_clock(MonotonicClock::new(move || { + clock_observations + .lock() + .expect("clock observations") + .pop_front() + .expect("clock observation") + })); + let request = OutboundRequest::get("https://example.com/") + .expect("request") + .stream_response(); + + let results = block_on( + client + .start_batch_until(vec![request], Deadline::after(Duration::from_secs(1))) + .collect(), + ) + .expect("valid batch driver"); + + let slot = results.slots[0].as_ref().expect("resolved slot"); + assert_eq!(slot.elapsed, Duration::from_millis(7)); + assert!(matches!(slot.outcome, Err(EdgeError::BadRequest { .. }))); + } + + #[test] + fn sdk_fields_preserve_duplicate_values() { + let fields = Fields::new(); + fields + .append("set-cookie", b"first=1") + .expect("first field append"); + fields + .append("set-cookie", b"second=2") + .expect("second field append"); + + assert_eq!( + fields.get("set-cookie"), + vec![b"first=1".to_vec(), b"second=2".to_vec()] + ); + } + + #[test] + fn sdk_request_options_execute_all_setters() { + let options = RequestOptions::new(); + options + .set_connect_timeout(Some(1_000_000)) + .expect("connect timeout is supported"); + options + .set_first_byte_timeout(Some(2_000_000)) + .expect("first-byte timeout is supported"); + options + .set_between_bytes_timeout(Some(3_000_000)) + .expect("between-bytes timeout is supported"); + } + + #[test] + fn sdk_request_and_response_completion_resources_construct() { + let (request_writer, request_body, request_trailers) = BodyWriter::new(); + let (request, request_done) = Request::new( + Fields::new(), + Some(request_body), + request_trailers, + Some(RequestOptions::new()), + ); + + let (response_writer, response_body, response_trailers) = BodyWriter::new(); + let (response, response_done) = + Response::new(Fields::new(), Some(response_body), response_trailers); + + drop((request, request_done, request_writer)); + drop((response, response_done, response_writer)); + } + + #[derive(Clone, Copy, Debug)] + enum ExpectedError { + BadRequest, + Internal, + Protocol, + Timeout, + Transport, + Unknown, + Unreachable, + } + + #[expect( + clippy::too_many_lines, + reason = "the pinned SDK table intentionally constructs every known ErrorCode variant" + )] + fn known_error_codes() -> Vec<(ErrorCode, ExpectedError)> { + let field = FieldSizePayload { + field_name: Some("x-test".to_owned()), + field_size: Some(1), + }; + vec![ + (ErrorCode::DnsTimeout, ExpectedError::Timeout), + ( + ErrorCode::DnsError(DnsErrorPayload { + rcode: Some("NXDOMAIN".to_owned()), + info_code: Some(3), + }), + ExpectedError::Unreachable, + ), + (ErrorCode::DestinationNotFound, ExpectedError::Unreachable), + ( + ErrorCode::DestinationUnavailable, + ExpectedError::Unreachable, + ), + ( + ErrorCode::DestinationIpProhibited, + ExpectedError::Unreachable, + ), + ( + ErrorCode::DestinationIpUnroutable, + ExpectedError::Unreachable, + ), + (ErrorCode::ConnectionRefused, ExpectedError::Unreachable), + (ErrorCode::ConnectionTerminated, ExpectedError::Transport), + (ErrorCode::ConnectionTimeout, ExpectedError::Timeout), + (ErrorCode::ConnectionReadTimeout, ExpectedError::Timeout), + (ErrorCode::ConnectionWriteTimeout, ExpectedError::Timeout), + ( + ErrorCode::ConnectionLimitReached, + ExpectedError::Unreachable, + ), + (ErrorCode::TlsProtocolError, ExpectedError::Unreachable), + (ErrorCode::TlsCertificateError, ExpectedError::Unreachable), + ( + ErrorCode::TlsAlertReceived(TlsAlertReceivedPayload { + alert_id: Some(42), + alert_message: Some("fixture".to_owned()), + }), + ExpectedError::Unreachable, + ), + (ErrorCode::HttpRequestDenied, ExpectedError::BadRequest), + ( + ErrorCode::HttpRequestLengthRequired, + ExpectedError::Internal, + ), + ( + ErrorCode::HttpRequestBodySize(Some(1)), + ExpectedError::BadRequest, + ), + (ErrorCode::HttpRequestMethodInvalid, ExpectedError::Internal), + (ErrorCode::HttpRequestUriInvalid, ExpectedError::Internal), + (ErrorCode::HttpRequestUriTooLong, ExpectedError::BadRequest), + ( + ErrorCode::HttpRequestHeaderSectionSize(Some(1)), + ExpectedError::BadRequest, + ), + ( + ErrorCode::HttpRequestHeaderSize(Some(field.clone())), + ExpectedError::BadRequest, + ), + ( + ErrorCode::HttpRequestTrailerSectionSize(Some(1)), + ExpectedError::Internal, + ), + ( + ErrorCode::HttpRequestTrailerSize(field.clone()), + ExpectedError::Internal, + ), + (ErrorCode::HttpResponseIncomplete, ExpectedError::Protocol), + ( + ErrorCode::HttpResponseHeaderSectionSize(Some(1)), + ExpectedError::Protocol, + ), + ( + ErrorCode::HttpResponseHeaderSize(field.clone()), + ExpectedError::Protocol, + ), + ( + ErrorCode::HttpResponseBodySize(Some(1)), + ExpectedError::Protocol, + ), + ( + ErrorCode::HttpResponseTrailerSectionSize(Some(1)), + ExpectedError::Protocol, + ), + ( + ErrorCode::HttpResponseTrailerSize(field), + ExpectedError::Protocol, + ), + ( + ErrorCode::HttpResponseTransferCoding(Some("fixture".to_owned())), + ExpectedError::Protocol, + ), + ( + ErrorCode::HttpResponseContentCoding(Some("fixture".to_owned())), + ExpectedError::Protocol, + ), + (ErrorCode::HttpResponseTimeout, ExpectedError::Timeout), + (ErrorCode::HttpUpgradeFailed, ExpectedError::Protocol), + (ErrorCode::HttpProtocolError, ExpectedError::Protocol), + (ErrorCode::LoopDetected, ExpectedError::Protocol), + (ErrorCode::ConfigurationError, ExpectedError::Internal), + ( + ErrorCode::InternalError(Some("fixture".to_owned())), + ExpectedError::Unknown, + ), + ] + } + + fn assert_expected_error(error: &EdgeError, expected: ExpectedError) { + match expected { + ExpectedError::BadRequest => assert!(matches!(error, EdgeError::BadRequest { .. })), + ExpectedError::Internal => assert!(matches!(error, EdgeError::Internal { .. })), + ExpectedError::Protocol => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Protocol, + .. + } + )), + ExpectedError::Timeout => assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::Unspecified, + .. + } + )), + ExpectedError::Transport => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Transport, + .. + } + )), + ExpectedError::Unknown => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Unspecified, + .. + } + )), + ExpectedError::Unreachable => assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Unreachable, + .. + } + )), + } + } + + #[test] + fn spin_error_code_table_is_exhaustive() { + let now = MonotonicInstant::now(); + let live = Deadline::at_instant( + now.checked_add(Duration::from_secs(30)) + .expect("live deadline"), + ); + for (code, expected) in known_error_codes() { + let mapped = map_spin_send_error_for_test(&code, live, BudgetSource::Default, now); + assert_expected_error(&mapped, expected); + + let expired = map_spin_send_error_for_test( + &code, + Deadline::at_instant(now), + BudgetSource::RequestDeadline, + now, + ); + assert!(matches!( + expired, + EdgeError::GatewayTimeout { + cause: BudgetSource::RequestDeadline, + .. + } + )); + } + } + + #[test] + fn spin_timeout_provenance_before_and_after_deadline() { + let now = MonotonicInstant::now(); + let live = Deadline::at_instant( + now.checked_add(Duration::from_secs(30)) + .expect("live deadline"), + ); + let early = map_spin_send_error_for_test( + &ErrorCode::DnsTimeout, + live, + BudgetSource::PerCallTimeout, + now, + ); + assert!(matches!( + early, + EdgeError::GatewayTimeout { + cause: BudgetSource::Unspecified, + .. + } + )); + + let unreachable = map_spin_send_error_for_test( + &ErrorCode::ConnectionRefused, + live, + BudgetSource::Default, + now, + ); + assert!(matches!( + unreachable, + EdgeError::BadGateway { + reason: BadGatewayReason::Unreachable, + .. + } + )); + } +} diff --git a/crates/edgezero-adapter-spin/tests/secret_store_bounded.rs b/crates/edgezero-adapter-spin/tests/secret_store_bounded.rs new file mode 100644 index 000000000..908d9d667 --- /dev/null +++ b/crates/edgezero-adapter-spin/tests/secret_store_bounded.rs @@ -0,0 +1,193 @@ +#![cfg(not(target_arch = "wasm32"))] + +extern crate self as spin_sdk; + +mod variables { + use std::fmt; + + use futures::future; + + #[derive(Debug)] + pub(crate) enum Error { + InvalidName(String), + Other, + Undefined(String), + } + + impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidName(message) => write!(f, "invalid name: {message}"), + Self::Other => f.write_str("other"), + Self::Undefined(message) => write!(f, "undefined: {message}"), + } + } + } + + pub(crate) async fn get(_key: &str) -> Result { + future::pending().await + } +} + +#[path = "../src/secret_store.rs"] +mod secret_store; + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::mem; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use bytes::Bytes; + use edgezero_core::secret_store::SecretError; + use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; + use futures::executor::block_on; + + use super::{secret_store, variables}; + + #[test] + fn source_harness_constructs_spin_types() { + let store = secret_store::SpinSecretStore::new(); + assert_eq!(mem::size_of_val(&store), 0); + let errors = [ + variables::Error::Undefined(String::new()), + variables::Error::InvalidName(String::new()), + variables::Error::Other, + ]; + for error in errors { + match error { + variables::Error::Undefined(message) | variables::Error::InvalidName(message) => { + assert!(message.is_empty()); + } + variables::Error::Other => {} + } + } + } + + #[test] + fn bounded_secret_reports_exact_bytes_and_accepts_exact_caps() { + let clock = MonotonicClock::default(); + let result = block_on(secret_store::bounded_secret_read( + async { Ok(Some(Bytes::from_static(b"value"))) }, + &clock, + Deadline::after(Duration::from_secs(1)), + 5, + 5, + )) + .expect("exact caps must succeed"); + + assert_eq!(result.backend_bytes, 5); + assert_eq!(result.value, Some(Bytes::from_static(b"value"))); + } + + #[test] + fn bounded_secret_rejects_either_exceeded_cap() { + for (max_backend_bytes, max_value_bytes) in [(4, 5), (5, 4)] { + let clock = MonotonicClock::default(); + let error = block_on(secret_store::bounded_secret_read( + async { Ok(Some(Bytes::from_static(b"value"))) }, + &clock, + Deadline::after(Duration::from_secs(1)), + max_backend_bytes, + max_value_bytes, + )) + .expect_err("an exceeded cap must fail"); + + assert!(matches!(error, SecretError::ValueTooLarge)); + } + } + + #[test] + fn bounded_secret_checks_deadline_before_polling_host_call() { + let polled = Cell::new(false); + let clock = MonotonicClock::default(); + let error = block_on(secret_store::bounded_secret_read( + async { + polled.set(true); + Ok(None) + }, + &clock, + Deadline::after(Duration::ZERO), + 1, + 1, + )) + .expect_err("expired deadline must fail"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + assert!(!polled.get(), "expired reads must not poll the host call"); + } + + #[test] + fn bounded_secret_checks_deadline_after_host_call() { + let start = MonotonicInstant::now(); + let terminal = start + .checked_add(Duration::from_secs(1)) + .expect("terminal instant"); + let now = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&now); + let clock = MonotonicClock::new(move || *clock_now.lock().expect("clock lock")); + let error = block_on(secret_store::bounded_secret_read( + async { + *now.lock().expect("clock lock") = terminal; + Ok(None) + }, + &clock, + Deadline::at_instant(terminal), + 1, + 1, + )) + .expect_err("a host call completing after the deadline must fail"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + } + + #[test] + fn bounded_secret_deadline_wins_over_late_host_error() { + let start = MonotonicInstant::now(); + let terminal = start + .checked_add(Duration::from_secs(1)) + .expect("terminal instant"); + let now = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&now); + let clock = MonotonicClock::new(move || *clock_now.lock().expect("clock lock")); + let error = block_on(secret_store::bounded_secret_read( + async { + *now.lock().expect("clock lock") = terminal; + Err(SecretError::Unavailable) + }, + &clock, + Deadline::at_instant(terminal), + 1, + 1, + )) + .expect_err("the post-call deadline check must run after host errors"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + } + + #[test] + fn bounded_secret_read_uses_injected_clock() { + let process_now = MonotonicInstant::now(); + let injected_now = process_now + .checked_sub(Duration::from_mins(1)) + .expect("injected instant"); + let clock = MonotonicClock::new(move || injected_now); + let deadline = Deadline::at_instant( + injected_now + .checked_add(Duration::from_secs(1)) + .expect("deadline instant"), + ); + + let result = block_on(secret_store::bounded_secret_read( + async { Ok(Some(Bytes::from_static(b"value"))) }, + &clock, + deadline, + 5, + 5, + )) + .expect("the application clock is still before its deadline"); + + assert_eq!(result.value, Some(Bytes::from_static(b"value"))); + } +} diff --git a/crates/edgezero-adapter/Cargo.toml b/crates/edgezero-adapter/Cargo.toml index 34ad59464..e10f52013 100644 --- a/crates/edgezero-adapter/Cargo.toml +++ b/crates/edgezero-adapter/Cargo.toml @@ -15,6 +15,7 @@ default = [] cli = ["dep:serde", "dep:serde_json", "dep:sha2", "dep:toml", "dep:walkdir"] [dependencies] +edgezero-core = { workspace = true } toml = { workspace = true, optional = true } serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } diff --git a/crates/edgezero-adapter/src/cli_support.rs b/crates/edgezero-adapter/src/cli_support.rs index a5e664d9f..33d895a68 100644 --- a/crates/edgezero-adapter/src/cli_support.rs +++ b/crates/edgezero-adapter/src/cli_support.rs @@ -8,6 +8,10 @@ use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::process::Command; +#[cfg(test)] +use edgezero_core::manifest::ManifestLoader; +use edgezero_core::manifest::{Manifest, canonicalize_outbound_hosts}; + /// Walks up the directory tree looking for `manifest_name` alongside a `Cargo.toml`. #[inline] #[must_use] @@ -126,6 +130,112 @@ pub fn read_package_name(manifest: &Path) -> Result { )) } +/// Verifies the selected Spin component's outbound host set against the application contract. +/// +/// # Errors +/// Returns an operator-facing error when the Spin manifest/component is invalid or its canonical +/// `allowed_outbound_hosts` set differs from `[capabilities.outbound].hosts`. +#[inline] +pub fn validate_spin_outbound_host_contract( + manifest: &Manifest, + manifest_path: &Path, + spin_manifest_path: &Path, + component_selector: Option<&str>, +) -> Result<(), String> { + let raw = fs::read_to_string(spin_manifest_path).map_err(|error| { + format!( + "failed to read Spin manifest at {}: {error}", + spin_manifest_path.display() + ) + })?; + let parsed: toml::Value = toml::from_str(&raw).map_err(|error| { + format!( + "failed to parse {} as TOML: {error}", + spin_manifest_path.display() + ) + })?; + let components = parsed + .get("component") + .and_then(toml::Value::as_table) + .ok_or_else(|| { + format!( + "{}: no [component.*] declarations found", + spin_manifest_path.display() + ) + })?; + let component = match component_selector { + Some(selected) if components.contains_key(selected) => selected.to_owned(), + Some(selected) => { + return Err(format!( + "Spin component {selected:?} is not declared in {}", + spin_manifest_path.display() + )); + } + None if components.len() == 1 => components + .keys() + .next() + .cloned() + .ok_or_else(|| "Spin component selection failed".to_owned())?, + None => { + return Err(format!( + "{} declares {} components but no Spin component was selected", + spin_manifest_path.display(), + components.len() + )); + } + }; + let component_table = components + .get(&component) + .and_then(toml::Value::as_table) + .ok_or_else(|| { + format!( + "[component.{component}] in {} must be a table", + spin_manifest_path.display() + ) + })?; + let platform_values = component_table + .get("allowed_outbound_hosts") + .and_then(toml::Value::as_array) + .ok_or_else(|| { + format!( + "[component.{component}].allowed_outbound_hosts is missing or not an array in {}", + spin_manifest_path.display() + ) + })?; + let platform_hosts = platform_values + .iter() + .map(|value| { + value.as_str().map(str::to_owned).ok_or_else(|| { + format!( + "[component.{component}].allowed_outbound_hosts in {} must contain only strings", + spin_manifest_path.display() + ) + }) + }) + .collect::, _>>()?; + let expected = canonicalize_outbound_hosts(manifest.capabilities.outbound.hosts.as_deref()) + .map_err(|error| { + format!( + "invalid outbound host contract in {}: {error}", + manifest_path.display() + ) + })?; + let actual = canonicalize_outbound_hosts(Some(&platform_hosts)).map_err(|error| { + format!( + "invalid [component.{component}].allowed_outbound_hosts in {}: {error}", + spin_manifest_path.display() + ) + })?; + if actual == expected { + return Ok(()); + } + Err(format!( + "Spin outbound host drift: {} selects component `{component}` in {}; expected canonical allowed_outbound_hosts {expected:?}, found {actual:?}. Update that component field and rerun.", + manifest_path.display(), + spin_manifest_path.display() + )) +} + #[cfg(test)] mod tests { use super::*; @@ -242,4 +352,105 @@ mod tests { "expected the exit-status branch, got: {err}" ); } + + #[test] + fn spin_selected_component_outbound_hosts_are_canonical_and_order_independent() { + let dir = tempdir().expect("temp dir"); + let manifest_path = dir.path().join("edgezero.toml"); + let spin_path = dir.path().join("spin.toml"); + fs::write( + &manifest_path, + "[capabilities.outbound]\nhosts = [\"*\", \"api.example.com\"]\n", + ) + .expect("write app manifest"); + fs::write( + &spin_path, + "[component.app]\nallowed_outbound_hosts = [\"https://api.example.com\", \"https://*:*\", \"http://*:*\"]\n", + ) + .expect("write Spin manifest"); + let loader = ManifestLoader::from_path(&manifest_path).expect("load manifest"); + + validate_spin_outbound_host_contract(loader.manifest(), &manifest_path, &spin_path, None) + .expect("canonical host sets match"); + } + + #[test] + fn spin_selected_component_outbound_host_drift_fails_closed() { + let dir = tempdir().expect("temp dir"); + let manifest_path = dir.path().join("edgezero.toml"); + let spin_path = dir.path().join("spin.toml"); + fs::write(&manifest_path, "").expect("write app manifest"); + fs::write( + &spin_path, + "[component.first]\nallowed_outbound_hosts = [\"https://*:*\"]\n[component.second]\nallowed_outbound_hosts = [\"https://api.example.com\"]\n", + ) + .expect("write Spin manifest"); + let loader = ManifestLoader::from_path(&manifest_path).expect("load manifest"); + + let error = validate_spin_outbound_host_contract( + loader.manifest(), + &manifest_path, + &spin_path, + Some("second"), + ) + .expect_err("selected component drift"); + + assert!(error.contains("component `second`"), "{error}"); + assert!(error.contains("https://*:*"), "{error}"); + assert!(error.contains("https://api.example.com"), "{error}"); + } + + #[test] + fn spin_selected_component_applies_default_and_rejects_malformed_platform_hosts() { + let dir = tempdir().expect("temp dir"); + let manifest_path = dir.path().join("edgezero.toml"); + let spin_path = dir.path().join("spin.toml"); + fs::write(&manifest_path, "").expect("write app manifest"); + fs::write( + &spin_path, + "[component.app]\nallowed_outbound_hosts = [\"https://*:*\"]\n", + ) + .expect("write Spin manifest"); + let loader = ManifestLoader::from_path(&manifest_path).expect("load manifest"); + validate_spin_outbound_host_contract(loader.manifest(), &manifest_path, &spin_path, None) + .expect("absent app hosts retain the HTTPS-only default"); + + fs::write( + &spin_path, + "[component.app]\nallowed_outbound_hosts = [\"ftp://example.com\"]\n", + ) + .expect("replace Spin manifest"); + let error = validate_spin_outbound_host_contract( + loader.manifest(), + &manifest_path, + &spin_path, + None, + ) + .expect_err("malformed platform host"); + assert!(error.contains("invalid [component.app]"), "{error}"); + } + + #[test] + fn spin_selected_component_requires_unambiguous_component() { + let dir = tempdir().expect("temp dir"); + let manifest_path = dir.path().join("edgezero.toml"); + let spin_path = dir.path().join("spin.toml"); + fs::write(&manifest_path, "").expect("write app manifest"); + fs::write( + &spin_path, + "[component.first]\nallowed_outbound_hosts = [\"https://*:*\"]\n[component.second]\nallowed_outbound_hosts = [\"https://*:*\"]\n", + ) + .expect("write Spin manifest"); + let loader = ManifestLoader::from_path(&manifest_path).expect("load manifest"); + + let error = validate_spin_outbound_host_contract( + loader.manifest(), + &manifest_path, + &spin_path, + None, + ) + .expect_err("ambiguous component"); + + assert!(error.contains("2 components"), "{error}"); + } } diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 1aafaa44b..79640ae11 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -2,6 +2,8 @@ use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::sync::{LazyLock, PoisonError, RwLock}; +use edgezero_core::{Capability, CapabilitySupport, PlatformMetadata}; + static REGISTRY: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); @@ -41,6 +43,71 @@ pub enum AdapterAction { Serve, } +/// Canonical application target selected by the CLI before adapter dispatch. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct AdapterExecutionTarget { + app_root: PathBuf, + component: Option, + environment_defaults: Vec<(String, String)>, + platform_manifest: Option, +} + +impl AdapterExecutionTarget { + /// Canonical application root used for this action. + #[must_use] + #[inline] + pub fn app_root(&self) -> &Path { + &self.app_root + } + + /// Optional platform component selected by the manifest. + #[must_use] + #[inline] + pub fn component(&self) -> Option<&str> { + self.component.as_deref() + } + + /// Selected-manifest defaults not already supplied by the parent environment. + /// Declaration order is retained; child commands apply the last applicable value. + #[must_use] + #[inline] + pub fn environment_defaults(&self) -> &[(String, String)] { + &self.environment_defaults + } + + /// Construct a target after the caller has canonicalized and validated it. + #[must_use] + #[inline] + pub fn new( + app_root: PathBuf, + component: Option, + platform_manifest: Option, + ) -> Self { + Self { + app_root, + component, + environment_defaults: Vec::new(), + platform_manifest, + } + } + + /// Canonical platform manifest selected for this action, when applicable. + #[must_use] + #[inline] + pub fn platform_manifest(&self) -> Option<&Path> { + self.platform_manifest.as_deref() + } + + /// Carry filtered runtime defaults to the adapter without mutating process state. + #[must_use] + #[inline] + pub fn with_environment_defaults(mut self, defaults: Vec<(String, String)>) -> Self { + self.environment_defaults = defaults; + self + } +} + /// Logical store ids declared by the application manifest for a deploy. /// /// This stays platform-neutral: each adapter decides whether and how its @@ -79,6 +146,8 @@ pub struct AdapterDeployContext { pub application_manifest_path: Option, /// Canonical root of an extracted immutable application release. pub application_release_root: Option, + /// Pinned runtime target; deployment must not rediscover source from cwd. + pub execution_target: Option, pub service_id: Option, pub staging: bool, pub stores: DeployStoreIds, @@ -294,7 +363,7 @@ pub enum ReadConfigEntry { /// or pushed under a different key). MissingKey, /// The store itself is absent — wrangler.toml has no matching binding, - /// fastly.toml has no setup table, axum's local-config-.json file + /// fastly.toml has no setup table, axum's `local-config-.json` file /// doesn't exist yet. MissingStore, /// The remote held the key; the body is the serialised envelope JSON. @@ -308,33 +377,23 @@ pub enum ReadConfigEntry { /// Interface implemented by adapter crates to integrate with the `EdgeZero` CLI. /// /// The non-`execute` methods carry the adapter's `config validate` -/// rules. They take primitive parameters (no `Manifest` / -/// `SecretField` from `edgezero-core`) so this crate stays dep-free -/// of `edgezero-core`. Defaults are no-ops; adapters override what -/// they actually need. -#[expect( - clippy::arbitrary_source_item_ordering, - reason = "deploy lifecycle hooks read in invocation order: preflight before deploy" -)] +/// rules. Defaults are no-ops; adapters override what they actually need. pub trait Adapter: Sync + Send { - /// Decide whether the manifest command or adapter owns this deployment. + /// Report this adapter's support for an application capability. /// - /// # Errors - /// Returns an error string when the adapter cannot safely select a deploy path. + /// The fail-closed default prevents an adapter from accidentally claiming + /// support merely because a new capability was added to core. + #[must_use] #[inline] - fn preflight_deploy( - &self, - _context: &AdapterDeployContext, - _args: &[String], - ) -> Result { - Ok(DeployOwnership::ManifestCommand) + fn capability(&self, _capability: Capability) -> CapabilitySupport { + CapabilitySupport::Unsupported } /// Deploy with EdgeZero-owned inputs carried as typed context and only /// provider-native passthrough in `args`. /// /// Adapters that do not need structured deploy data can use the default - /// dispatch to their existing `execute` implementation. + /// dispatch to their pinned `execute_target` implementation. /// /// # Errors /// Returns an error string when the adapter deploy fails. @@ -345,7 +404,11 @@ pub trait Adapter: Sync + Send { } else { AdapterAction::Deploy }; - self.execute(action, args) + let target = context + .execution_target + .as_ref() + .ok_or_else(|| "adapter deployment requires a pinned execution target".to_owned())?; + self.execute_target(action, target, args) } /// Execute the requested action with optional adapter-specific args. @@ -364,6 +427,27 @@ pub trait Adapter: Sync + Send { /// Returns an error string if the requested adapter action fails. fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String>; + /// Execute against a target already selected and validated by the CLI. + /// + /// The default refuses rather than falling back to [`Self::execute`], which + /// could rediscover a different project from the process working directory. + /// + /// # Errors + /// Returns an explicit unsupported error unless the adapter implements + /// pinned-target dispatch. + #[inline] + fn execute_target( + &self, + _action: AdapterAction, + _target: &AdapterExecutionTarget, + _args: &[String], + ) -> Result<(), String> { + Err(format!( + "adapter `{}` does not support pinned execution targets", + self.name() + )) + } + /// Finish a successful deploy. /// /// `command_output` is present when a manifest-defined deploy command ran @@ -446,6 +530,15 @@ pub trait Adapter: Sync + Send { /// Name used to reference the adapter (case-insensitive). fn name(&self) -> &'static str; + /// Report the target's resource metadata. + /// + /// The default leaves every fact unknown. + #[must_use] + #[inline] + fn platform_metadata(&self) -> PlatformMetadata { + PlatformMetadata::default() + } + /// Reject a config `(key, body)` that this adapter cannot store, BEFORE any /// provider I/O. Called by `config push` ahead of the remote read, so an /// infeasible push fails offline instead of after a `list`/`describe` @@ -466,27 +559,17 @@ pub trait Adapter: Sync + Send { Ok(()) } - /// Validate the final config key selected by CLI and environment precedence - /// before config push or diff performs provider I/O. - /// - /// `logical_store_id` is the portable manifest ID. `key` is the final key - /// the operation would read or write. `staging` identifies the requested - /// deployment target, and `local` identifies an emulator operation. - /// Adapters whose runtimes use fixed keys can reject a divergent selection; - /// the default preserves configurable keys. + /// Decide whether the manifest command or adapter owns this deployment. /// /// # Errors - /// Returns a human-readable error when the selected key cannot be read by - /// this adapter's runtime for the requested target. + /// Returns an error string when the adapter cannot safely select a deploy path. #[inline] - fn validate_config_key_for_target( + fn preflight_deploy( &self, - _logical_store_id: &str, - _key: &str, - _staging: bool, - _local: bool, - ) -> Result<(), String> { - Ok(()) + _context: &AdapterDeployContext, + _args: &[String], + ) -> Result { + Ok(DeployOwnership::ManifestCommand) } /// Provision the platform resources backing each store id the @@ -704,6 +787,29 @@ pub trait Adapter: Sync + Send { Ok(()) } + /// Validate the final config key selected by CLI and environment precedence + /// before config push or diff performs provider I/O. + /// + /// `logical_store_id` is the portable manifest ID. `key` is the final key + /// the operation would read or write. `staging` identifies the requested + /// deployment target, and `local` identifies an emulator operation. + /// Adapters whose runtimes use fixed keys can reject a divergent selection; + /// the default preserves configurable keys. + /// + /// # Errors + /// Returns a human-readable error when the selected key cannot be read by + /// this adapter's runtime for the requested target. + #[inline] + fn validate_config_key_for_target( + &self, + _logical_store_id: &str, + _key: &str, + _staging: bool, + _local: bool, + ) -> Result<(), String> { + Ok(()) + } + /// Typed-only check that needs `#[secret]` field values — the /// CLI calls this only from the typed validation flow. /// `entries` carries both `KeyInDefault` and `KeyInNamedStore` @@ -780,6 +886,35 @@ mod tests { name: &'static str, } + struct CapabilityAdapter; + + #[expect( + clippy::missing_trait_methods, + reason = "fixture overrides only the behavior under test" + )] + impl Adapter for CapabilityAdapter { + #[expect( + clippy::wildcard_enum_match_arm, + reason = "Capability is non-exhaustive and this fixture defaults all untested capabilities to unsupported" + )] + fn capability(&self, capability: Capability) -> CapabilitySupport { + match capability { + Capability::LazyStreamedResponsePassthrough => CapabilitySupport::BestEffort, + Capability::OutboundDeadlines => CapabilitySupport::BoundedCooperative, + Capability::OutboundHttp => CapabilitySupport::Native, + _ => CapabilitySupport::Unsupported, + } + } + + fn execute(&self, _action: AdapterAction, _args: &[String]) -> Result<(), String> { + Ok(()) + } + + fn name(&self) -> &'static str { + "capability-fixture" + } + } + #[expect( clippy::missing_trait_methods, reason = "TestAdapter only exercises register / get / execute; the validation methods inherit the trait defaults (no-ops)" @@ -801,6 +936,66 @@ mod tests { HIT.store(0, Ordering::SeqCst); } + #[test] + fn adapter_capability_default_is_unsupported() { + assert_eq!( + FIRST.capability(Capability::OutboundHttp), + CapabilitySupport::Unsupported + ); + } + + #[test] + fn adapter_platform_metadata_default_is_unknown() { + let metadata = FIRST.platform_metadata(); + + assert_eq!(metadata, edgezero_core::PlatformMetadata::default()); + assert_eq!( + metadata.memory_ceiling(), + edgezero_core::PlatformFact::unknown(edgezero_core::PlatformUnknownReason::Unspecified) + ); + assert_eq!( + metadata.inbound_request_population_bound(), + edgezero_core::PlatformFact::unknown(edgezero_core::PlatformUnknownReason::Unspecified) + ); + assert_eq!( + metadata.host_ingress_memory_accounting(), + edgezero_core::PlatformFact::unknown(edgezero_core::PlatformUnknownReason::Unspecified) + ); + } + + #[test] + fn adapter_capability_reports_each_support_level() { + let adapter = CapabilityAdapter; + assert_eq!( + adapter.capability(Capability::OutboundHttp), + CapabilitySupport::Native + ); + assert_eq!( + adapter.capability(Capability::OutboundDeadlines), + CapabilitySupport::BoundedCooperative + ); + assert_eq!( + adapter.capability(Capability::LazyStreamedResponsePassthrough), + CapabilitySupport::BestEffort + ); + assert_eq!( + adapter.capability(Capability::OutboundCompleteResourceAccounting), + CapabilitySupport::Unsupported + ); + } + + #[test] + fn adapter_execute_target_default_refuses_rediscovery() { + let _guard = TEST_LOCK.lock().expect("lock"); + reset(); + let target = AdapterExecutionTarget::new(PathBuf::from("/tmp"), None, None); + let error = FIRST + .execute_target(AdapterAction::Build, &target, &[]) + .expect_err("default target execution must fail closed"); + assert!(error.contains("pinned execution targets")); + assert_eq!(HIT.load(Ordering::SeqCst), 0); + } + #[test] fn default_deploy_preflight_keeps_manifest_command() { let context = AdapterDeployContext::default(); @@ -813,6 +1008,29 @@ mod tests { assert!(context.variable_defaults.is_empty()); } + #[test] + fn default_deploy_never_rediscovers_an_unpinned_target() { + let _guard = TEST_LOCK.lock().expect("lock"); + reset(); + let missing = FIRST + .deploy(&AdapterDeployContext::default(), &[]) + .expect_err("unpinned deploy must fail before execute"); + assert!(missing.contains("pinned execution target")); + let context = AdapterDeployContext { + execution_target: Some(AdapterExecutionTarget::new( + PathBuf::from("/selected"), + None, + None, + )), + ..AdapterDeployContext::default() + }; + let unsupported = FIRST + .deploy(&context, &[]) + .expect_err("default pinned dispatch must not fall back to execute"); + assert!(unsupported.contains("pinned execution targets")); + assert_eq!(HIT.load(Ordering::SeqCst), 0); + } + #[test] fn registers_and_fetches_adapter() { let _guard = TEST_LOCK.lock().expect("lock"); @@ -935,4 +1153,19 @@ mod tests { ); assert!(local_err.contains("--local"), "msg: {local_err}"); } + + #[test] + fn execution_target_retains_ordered_defaults_without_changing_paths() { + let empty = AdapterExecutionTarget::new(PathBuf::from("/tmp"), None, None); + assert!(empty.environment_defaults().is_empty()); + let defaults = vec![ + ("key".to_owned(), "first".to_owned()), + ("KEY".to_owned(), "last".to_owned()), + ]; + let selected = empty.with_environment_defaults(defaults.clone()); + let cloned = selected.clone(); + assert_eq!(cloned.environment_defaults(), defaults); + assert_eq!(cloned.app_root(), Path::new("/tmp")); + assert_eq!(cloned, selected); + } } diff --git a/crates/edgezero-cli/Cargo.toml b/crates/edgezero-cli/Cargo.toml index ce988d6a5..4fe54f60d 100644 --- a/crates/edgezero-cli/Cargo.toml +++ b/crates/edgezero-cli/Cargo.toml @@ -30,7 +30,7 @@ app-demo-core = { path = "../../examples/app-demo/crates/app-demo-core", package chrono = { workspace = true } clap = { version = "4", features = ["derive"], optional = true } futures = { workspace = true } -handlebars = { workspace = true } +handlebars = { workspace = true, optional = true } log = { workspace = true } serde = { workspace = true } similar = { workspace = true } @@ -58,6 +58,6 @@ default = [ "edgezero-adapter-cloudflare", "edgezero-adapter-spin", ] -cli = ["dep:clap"] +cli = ["dep:clap", "dep:handlebars"] demo-example = ["dep:app-demo-core", "edgezero-adapter-axum"] nested-app-config-check = ["dep:proc-macro2", "dep:syn", "dep:walkdir"] diff --git a/crates/edgezero-cli/README.md b/crates/edgezero-cli/README.md index d04dec2b7..77157550e 100644 --- a/crates/edgezero-cli/README.md +++ b/crates/edgezero-cli/README.md @@ -46,6 +46,13 @@ Contributor build with the bundled example: cargo run -p edgezero-cli --features "cli,demo-example" -- demo ``` +The bundled demo uses the same Axum logger as the standalone app-demo adapter, not the CLI's +fixed-Info backend. It honors `EDGEZERO__LOGGING__LEVEL`; runtime `Off` suppresses ordinary +startup messages, while managed boot warnings remain at `Warn`. Capability checks run after +logger setup and before application configuration or listener binding. Terminal failures print +to stderr even with logging disabled. Generated adapter entrypoints likewise delegate to reusable +runners; no logging implementation is copied into their templates. + ## Built-in commands `edgezero` ships with these subcommands (see `edgezero --help` for full diff --git a/crates/edgezero-cli/src/adapter.rs b/crates/edgezero-cli/src/adapter.rs index db6575961..7d16ae8bf 100644 --- a/crates/edgezero-cli/src/adapter.rs +++ b/crates/edgezero-cli/src/adapter.rs @@ -1,7 +1,12 @@ +use edgezero_adapter::cli_support::validate_spin_outbound_host_contract; use edgezero_adapter::registry::{ self as adapter_registry, AdapterAction, AdapterDeployContext, DeployOwnership, }; -use edgezero_core::manifest::{Manifest, ManifestLoader, ResolvedEnvironment}; +use edgezero_core::manifest::{ + CapabilitySupport, Manifest, ManifestContract, ManifestLoader, ResolvedEnvironment, +}; + +use crate::manifest_source::{ResolvedAdapterTarget, ResolvedRuntime}; use std::env; use std::fmt; @@ -82,14 +87,7 @@ fn apply_environment( // `env::var_os` first and skip the explicit set when the parent // already has one. This mirrors the precedence the plan + the // typed-config env-overlay docs both promise. - for binding in &environment.variables { - if let Some(value) = &binding.value { - if env::var_os(&binding.env).is_some() { - continue; - } - command.env(&binding.env, value); - } - } + command.envs(resolve_environment_defaults(environment)); let mut missing = Vec::new(); for binding in &environment.secrets { @@ -162,7 +160,7 @@ fn require_adapter( } /// Select deployment ownership through the registered adapter's preflight. -/// Manifest-owned and fallback deployments retain the legacy finalizer, while +/// Manifest-owned and fallback deployments invoke the finalizer, while /// adapter-managed deployments own the complete lifecycle in `deploy`. pub fn deploy( adapter_name: &str, @@ -236,6 +234,234 @@ pub fn deploy( fallback_adapter.finalize_deploy(context, None) } +/// Check the selected runtime contract before deployment preflight or provider work. +pub(crate) fn deploy_runtime( + runtime: &ResolvedRuntime, + context: &AdapterDeployContext, + adapter_manifest_path_error: Option<&str>, + adapter_args: &[String], +) -> Result<(), String> { + ensure_action_capabilities(runtime)?; + deploy( + runtime.adapter_name(), + context, + adapter_manifest_path_error, + runtime.manifest_loader(), + adapter_args, + ) +} + +/// Dispatch a runtime-producing action against one pre-resolved target/contract pair. +pub(crate) fn execute_runtime( + runtime: &ResolvedRuntime, + adapter_args: &[String], +) -> Result<(), String> { + ensure_action_capabilities(runtime)?; + execute_after_gate(runtime, adapter_args) +} + +fn ensure_action_capabilities(runtime: &ResolvedRuntime) -> Result<(), String> { + if !produces_current_runtime(runtime.action()) { + return Err("operational action entered outbound runtime dispatcher".to_owned()); + } + ensure_spin_outbound_hosts(runtime)?; + ensure_capabilities( + runtime.adapter_name(), + ManifestContract::from_opt(runtime.manifest()), + ) +} + +fn ensure_spin_outbound_hosts(runtime: &ResolvedRuntime) -> Result<(), String> { + if !runtime.adapter_name().eq_ignore_ascii_case("spin") { + return Ok(()); + } + let Some(manifest) = runtime.manifest() else { + return Ok(()); + }; + let manifest_path = runtime + .manifest_path() + .ok_or_else(|| "resolved Spin runtime lost its application manifest path".to_owned())?; + let (platform_manifest, component) = match runtime.target() { + ResolvedAdapterTarget::Registered(target) => { + (target.platform_manifest(), target.component()) + } + ResolvedAdapterTarget::Shell(shell) => (shell.platform_manifest(), shell.component()), + }; + let platform_manifest_path = platform_manifest.ok_or_else(|| { + format!( + "Spin runtime in {} has no selected spin.toml; set [adapters.spin.adapter].manifest", + manifest_path.display() + ) + })?; + validate_spin_outbound_host_contract(manifest, manifest_path, platform_manifest_path, component) +} + +fn execute_after_gate(runtime: &ResolvedRuntime, adapter_args: &[String]) -> Result<(), String> { + match runtime.target() { + ResolvedAdapterTarget::Registered(_target) => { + execute_registered_after_gate(runtime, adapter_args) + } + ResolvedAdapterTarget::Shell(shell) => run_shell( + shell.command(), + shell.root(), + runtime.adapter_name(), + runtime.action(), + Some(shell.environment().clone()), + (shell.bind_host().map(str::to_owned), shell.bind_port()), + adapter_args, + ), + } +} + +fn execute_registered_after_gate( + runtime: &ResolvedRuntime, + adapter_args: &[String], +) -> Result<(), String> { + let ResolvedAdapterTarget::Registered(target) = runtime.target() else { + return Err("registered runtime dispatcher received a shell target".to_owned()); + }; + let adapter = adapter_registry::get_adapter(runtime.adapter_name()).ok_or_else(|| { + let available = adapter_registry::registered_adapters(); + if available.is_empty() { + format!( + "adapter `{}` is not registered (no adapters available)", + runtime.adapter_name() + ) + } else { + format!( + "adapter `{}` is not registered (available: {})", + runtime.adapter_name(), + available.join(", ") + ) + } + })?; + let defaults = runtime.manifest().map_or_else(Vec::new, |manifest| { + resolve_environment_defaults(&manifest.environment_for(runtime.adapter_name())) + }); + let selected = target.clone().with_environment_defaults(defaults); + adapter.execute_target( + AdapterAction::from(runtime.action()), + &selected, + adapter_args, + ) +} + +pub(crate) fn resolve_environment_defaults( + environment: &ResolvedEnvironment, +) -> Vec<(String, String)> { + let mut defaults = Vec::new(); + for binding in &environment.variables { + if let Some(value) = &binding.value + && env::var_os(&binding.env).is_none() + { + // Keep declaration order; Command owns platform-specific key identity. + defaults.push((binding.env.clone(), value.clone())); + } + } + defaults +} + +pub(crate) fn ensure_capabilities( + adapter_name: &str, + contract: ManifestContract<'_>, +) -> Result<(), String> { + let verified_manifest = match contract { + ManifestContract::Malformed(reason) => { + return Err(format!( + "capability check aborted: {reason}. This is an EdgeZero/app! contract bug; the baked manifest is unreadable, so required capabilities cannot be verified. Refusing to proceed rather than silently skipping enforcement." + )); + } + ManifestContract::None => return Ok(()), + ManifestContract::Present(present_manifest) => present_manifest, + _ => { + return Err( + "capability check aborted: unrecognized manifest-contract state. Refusing to proceed rather than skipping enforcement." + .to_owned(), + ); + } + }; + let capabilities = &verified_manifest.capabilities; + let Some(adapter) = adapter_registry::get_adapter(adapter_name) else { + if capabilities.required.is_empty() { + if capabilities.optional.is_empty() { + log::warn!( + target: edgezero_core::BOOT_LOG_TARGET, + "adapter '{adapter_name}' not in registry; capability check skipped (no capabilities declared)" + ); + } else { + log::warn!( + target: edgezero_core::BOOT_LOG_TARGET, + "adapter '{adapter_name}' not in registry; cannot verify its OPTIONAL capabilities; proceeding, since optional capabilities never hard-fail" + ); + } + return Ok(()); + } + return Err(format!( + "adapter '{adapter_name}' is not in the registry; cannot verify REQUIRED capabilities. Register an adapter stub that returns capability metadata, or move those entries to `optional`." + )); + }; + + let mut best_effort = Vec::new(); + let mut unsupported = Vec::new(); + for capability in capabilities.required.iter().copied() { + match adapter.capability(capability) { + CapabilitySupport::BestEffort => best_effort.push(capability.as_str()), + CapabilitySupport::BoundedCooperative => log::info!( + "adapter '{adapter_name}': required capability '{}' is bounded-cooperative; see capability docs for the bound", + capability.as_str() + ), + CapabilitySupport::Native => {} + CapabilitySupport::Unsupported | _ => unsupported.push(capability.as_str()), + } + } + if !unsupported.is_empty() { + return Err(format!( + "adapter '{adapter_name}' does not support required capabilities: {}", + unsupported.join(", ") + )); + } + if !best_effort.is_empty() { + return Err(format!( + "adapter '{adapter_name}': required capabilities are only best-effort: {}. See https://edgezero.dev/guide/capabilities and declare them `optional` only when the documented limitation is acceptable.", + best_effort.join(", ") + )); + } + + for capability in capabilities.optional.iter().copied() { + match adapter.capability(capability) { + CapabilitySupport::BestEffort => log::warn!( + target: edgezero_core::BOOT_LOG_TARGET, + "adapter '{adapter_name}': optional capability '{}' is best-effort; see https://edgezero.dev/guide/capabilities", + capability.as_str() + ), + CapabilitySupport::BoundedCooperative | CapabilitySupport::Native => {} + CapabilitySupport::Unsupported => log::warn!( + target: edgezero_core::BOOT_LOG_TARGET, + "adapter '{adapter_name}': optional capability '{}' unavailable", + capability.as_str() + ), + _ => log::warn!( + target: edgezero_core::BOOT_LOG_TARGET, + "adapter '{adapter_name}': optional capability '{}' reports an unrecognized support level; treating as degraded", + capability.as_str() + ), + } + } + Ok(()) +} + +fn produces_current_runtime(action: Action) -> bool { + match action { + Action::Build | Action::Deploy | Action::DeployStaging | Action::Serve => true, + Action::AuthLogin + | Action::AuthLogout + | Action::AuthStatus + | Action::EmitVersion + | Action::Healthcheck + | Action::Rollback => false, + } +} + fn manifest_command<'manifest>( manifest: &'manifest Manifest, adapter_name: &str, @@ -473,12 +699,178 @@ fn shell_join(args: &[String]) -> String { #[cfg(test)] mod tests { - use super::{AdapterDeployContext, ResolvedEnvironment, apply_environment, deploy}; + use super::{ + AdapterDeployContext, ResolvedEnvironment, apply_environment, deploy, ensure_capabilities, + execute_runtime, + }; + use crate::adapter::Action; + use crate::manifest_source::resolve_runtime_from; use crate::test_support::manifest_guard; - use edgezero_core::manifest::ResolvedEnvironmentBinding; + use edgezero_adapter::registry::{ + Adapter, AdapterAction, AdapterExecutionTarget, register_adapter, + }; + use edgezero_core::manifest::{ + Capability, CapabilitySupport, ManifestContract, ManifestLoader, ResolvedEnvironmentBinding, + }; use edgezero_core::test_env::EnvOverride; + use std::env; + use std::ffi::{OsStr, OsString}; + use std::fs; use std::path::PathBuf; use std::process::Command; + use std::sync::Mutex; + use tempfile::tempdir; + + static BEST_EFFORT_ADAPTER: GateAdapter = GateAdapter { + name: "gate-best-effort", + support: CapabilitySupport::BestEffort, + }; + static ENVIRONMENT_ADAPTER: EnvironmentAdapter = EnvironmentAdapter; + static ENVIRONMENT_TARGETS: Mutex> = Mutex::new(Vec::new()); + static BOUNDED_ADAPTER: GateAdapter = GateAdapter { + name: "gate-bounded", + support: CapabilitySupport::BoundedCooperative, + }; + static NATIVE_ADAPTER: GateAdapter = GateAdapter { + name: "gate-native", + support: CapabilitySupport::Native, + }; + static UNSUPPORTED_ADAPTER: GateAdapter = GateAdapter { + name: "gate-unsupported", + support: CapabilitySupport::Unsupported, + }; + + struct GateAdapter { + name: &'static str, + support: CapabilitySupport, + } + + struct EnvironmentAdapter; + + #[expect( + clippy::missing_trait_methods, + reason = "records pinned target dispatch without launching a child" + )] + impl Adapter for EnvironmentAdapter { + fn execute(&self, _action: AdapterAction, _args: &[String]) -> Result<(), String> { + Err("pinned dispatch must not rediscover the invocation project".to_owned()) + } + + fn execute_target( + &self, + _action: AdapterAction, + target: &AdapterExecutionTarget, + _args: &[String], + ) -> Result<(), String> { + ENVIRONMENT_TARGETS + .lock() + .map_err(|_poison| "fixture target lock poisoned")? + .push(target.clone()); + Ok(()) + } + + fn name(&self) -> &'static str { + "gate-environment" + } + } + + #[expect( + clippy::missing_trait_methods, + reason = "capability-gate fixture overrides only the relevant trait methods" + )] + impl Adapter for GateAdapter { + fn capability(&self, _capability: Capability) -> CapabilitySupport { + self.support + } + + fn execute(&self, _action: AdapterAction, _args: &[String]) -> Result<(), String> { + Ok(()) + } + + fn name(&self) -> &'static str { + self.name + } + } + + fn capability_manifest(section: &str) -> ManifestLoader { + ManifestLoader::load_from_str(&format!("[capabilities]\n{section}\n[adapters.gate]\n")) + } + + #[test] + fn registered_dispatch_carries_only_selected_manifest_defaults_in_order() { + const KEY: &str = "EDGEZERO_TEST_REGISTERED_INGRESS"; + let _lock = manifest_guard().lock().unwrap(); + let _unset = EnvOverride::remove(KEY); + register_adapter(&ENVIRONMENT_ADAPTER); + let invocation = tempdir().unwrap(); + let selected = tempdir().unwrap(); + fs::write(invocation.path().join("edgezero.toml"), + format!("[adapters.gate-environment]\n[[environment.variables]]\nname = '{KEY}'\nvalue = 'wrong-project'\n")).unwrap(); + let manifest_path = selected.path().join("edgezero.toml"); + fs::write(&manifest_path, format!( + "[adapters.gate-environment]\n\ + [[environment.variables]]\nname = 'first'\nenv = '{KEY}'\nvalue = 'global'\n\ + [[environment.variables]]\nname = 'second'\nenv = '{KEY}'\nvalue = 'selected'\nadapters = ['gate-environment']\n\ + [[environment.variables]]\nname = 'excluded'\nenv = '{KEY}'\nvalue = 'wrong-adapter'\nadapters = ['spin']\n\ + [[environment.variables]]\nname = 'last'\nenv = '{KEY}'\nvalue = ''\n" + )).unwrap(); + for action in [Action::Build, Action::Serve] { + let runtime = resolve_runtime_from( + "gate-environment", + action, + invocation.path(), + Some(OsString::from(&manifest_path)), + ) + .unwrap(); + execute_runtime(&runtime, &[]).unwrap(); + } + let mut targets = ENVIRONMENT_TARGETS.lock().unwrap(); + assert_eq!(targets.len(), 2); + for target in targets.drain(..) { + assert_eq!(target.app_root(), selected.path().canonicalize().unwrap()); + assert_eq!( + target.environment_defaults(), + &[ + (KEY.to_owned(), "global".to_owned()), + (KEY.to_owned(), "selected".to_owned()), + (KEY.to_owned(), String::new()) + ] + ); + let mut child = Command::new("echo"); + child.envs(target.environment_defaults().iter().cloned()); + assert!( + child + .get_envs() + .any(|(key, value)| key == KEY && value.is_some_and(OsStr::is_empty)) + ); + } + assert!(env::var_os(KEY).is_none()); + } + + #[test] + fn environment_defaults_filter_before_collapsing_and_preserve_parent_values() { + const KEY: &str = "EDGEZERO_TEST_INGRESS_PROPAGATION"; + let _lock = manifest_guard().lock().unwrap(); + let _unset = EnvOverride::remove(KEY); + let loader = ManifestLoader::load_from_str(&format!( + "[[environment.variables]]\nname = 'first'\nenv = '{KEY}'\nvalue = 'global'\n\ + [[environment.variables]]\nname = 'selected'\nenv = '{KEY}'\nvalue = 'selected'\nadapters = ['AxUm']\n\ + [[environment.variables]]\nname = 'excluded'\nenv = '{KEY}'\nvalue = 'wrong'\nadapters = ['spin']\n\ + [[environment.variables]]\nname = 'valueless'\nenv = '{KEY}'\n" + )); + let resolved = loader.manifest().environment_for("axum"); + let defaults = super::resolve_environment_defaults(&resolved); + let mut child = Command::new("echo"); + child.envs(defaults); + assert!( + child.get_envs().any(|(key, value)| key == KEY + && value.is_some_and(|configured| configured == "selected")) + ); + for value in ["operator", ""] { + let _parent = EnvOverride::set(KEY, value); + assert!(super::resolve_environment_defaults(&resolved).is_empty()); + } + } #[test] fn deploy_rejects_application_release_for_non_managed_adapter() { @@ -601,6 +993,138 @@ mod tests { ); } + #[test] + fn capability_gate_accepts_native_and_bounded_required_support() { + register_adapter(&NATIVE_ADAPTER); + register_adapter(&BOUNDED_ADAPTER); + let loader = capability_manifest("required = [\"outbound-http\"]"); + for name in [NATIVE_ADAPTER.name, BOUNDED_ADAPTER.name] { + assert_eq!( + ensure_capabilities(name, ManifestContract::from_opt(Some(loader.manifest()))), + Ok(()) + ); + } + } + + #[test] + fn capability_gate_fails_closed_for_malformed_contract() { + let result = ensure_capabilities( + "gate-missing-malformed", + ManifestContract::Malformed("fixture corruption"), + ); + assert!(result.is_err_and(|error| error.contains("fixture corruption"))); + } + + #[test] + fn capability_gate_rejects_best_effort_and_unsupported_required_support() { + register_adapter(&BEST_EFFORT_ADAPTER); + register_adapter(&UNSUPPORTED_ADAPTER); + let loader = capability_manifest("required = [\"outbound-http\"]"); + for name in [BEST_EFFORT_ADAPTER.name, UNSUPPORTED_ADAPTER.name] { + assert!( + ensure_capabilities(name, ManifestContract::from_opt(Some(loader.manifest()))) + .is_err() + ); + } + } + + #[test] + fn capability_gate_treats_missing_registry_by_requirement_level() { + let required = capability_manifest("required = [\"outbound-http\"]"); + let optional = capability_manifest("optional = [\"outbound-http\"]"); + assert!( + ensure_capabilities( + "gate-missing-required", + ManifestContract::from_opt(Some(required.manifest())) + ) + .is_err() + ); + assert_eq!( + ensure_capabilities( + "gate-missing-optional", + ManifestContract::from_opt(Some(optional.manifest())) + ), + Ok(()) + ); + assert_eq!( + ensure_capabilities("gate-missing-none", ManifestContract::None), + Ok(()) + ); + } + + #[test] + fn optional_capability_degradation_never_hard_fails() { + register_adapter(&BEST_EFFORT_ADAPTER); + register_adapter(&UNSUPPORTED_ADAPTER); + let loader = capability_manifest("optional = [\"outbound-http\"]"); + for name in [BEST_EFFORT_ADAPTER.name, UNSUPPORTED_ADAPTER.name] { + assert_eq!( + ensure_capabilities(name, ManifestContract::from_opt(Some(loader.manifest()))), + Ok(()) + ); + } + } + + #[test] + fn spin_host_drift_blocks_runtime_actions_before_dispatch() { + let dir = tempdir().expect("temp dir"); + let marker = dir.path().join("runtime-started"); + let manifest_path = dir.path().join("edgezero.toml"); + fs::write( + dir.path().join("spin.toml"), + "[component.app]\nallowed_outbound_hosts = [\"https://actual.example\"]\n", + ) + .expect("write Spin manifest"); + fs::write( + &manifest_path, + format!( + "[capabilities.outbound]\nhosts = [\"https://expected.example\"]\n\ + [adapters.spin.adapter]\nmanifest = \"spin.toml\"\ncomponent = \"app\"\n\ + [adapters.spin.commands]\nbuild = \"touch {}\"\nserve = \"touch {}\"\ndeploy = \"touch {}\"\n", + marker.display(), + marker.display(), + marker.display() + ), + ) + .expect("write application manifest"); + + for action in [ + Action::Build, + Action::Serve, + Action::Deploy, + Action::DeployStaging, + ] { + let runtime = resolve_runtime_from( + "spin", + action, + dir.path(), + Some(OsString::from(&manifest_path)), + ) + .expect("resolve runtime"); + let error = execute_runtime(&runtime, &[]).expect_err("host drift must fail closed"); + assert!(error.contains("Spin outbound host drift"), "{error}"); + assert!(!marker.exists(), "runtime side effect for {action}"); + } + + fs::write( + &manifest_path, + "[capabilities.outbound]\nhosts = [\"https://expected.example\"]\n\ + [adapters.spin.adapter]\nmanifest = \"spin.toml\"\ncomponent = \"app\"\n", + ) + .expect("replace application manifest with registered target"); + let registered = resolve_runtime_from( + "spin", + Action::Build, + dir.path(), + Some(OsString::from(&manifest_path)), + ) + .expect("resolve registered runtime"); + let error = execute_runtime(®istered, &[]) + .expect_err("registered target host drift must fail before adapter dispatch"); + assert!(error.contains("Spin outbound host drift"), "{error}"); + assert!(!marker.exists(), "registered adapter must not run on drift"); + } + #[test] fn shell_escape_quotes_and_spaces() { assert_eq!(super::shell_escape("plain"), "plain"); diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 60a86799a..bab412e76 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -720,16 +720,12 @@ where } }, ReadConfigEntry::MissingKey => { - let leaf_count = collect_changes( - &serde_json::Value::Object(serde_json::Map::default()), - &local_envelope.data, - ) - .len(); + let leaf_count = collect_changes(&serde_json::json!({}), &local_envelope.data).len(); diff_info(&format!( "# no remote at key `{key}`; all {leaf_count} leaves added" )); dispatch_diff_format( - &serde_json::Value::Object(serde_json::Map::default()), + &serde_json::json!({}), &local_envelope.data, "(none)", &local_sha, @@ -738,18 +734,14 @@ where DiffOutcome::RemoteAbsent } ReadConfigEntry::MissingStore => { - let leaf_count = collect_changes( - &serde_json::Value::Object(serde_json::Map::default()), - &local_envelope.data, - ) - .len(); + let leaf_count = collect_changes(&serde_json::json!({}), &local_envelope.data).len(); diff_info(&format!( "# store has no matching backend yet \u{2014} run `edgezero provision \ --adapter {}` first if this is the live remote; all {leaf_count} leaves added", args.adapter )); dispatch_diff_format( - &serde_json::Value::Object(serde_json::Map::default()), + &serde_json::json!({}), &local_envelope.data, "(none)", &local_sha, @@ -1074,7 +1066,7 @@ fn render_first_read_diff( ReadConfigEntry::MissingKey if !no_diff => { push_info(&format!("# no remote at key `{key}`; all leaves added")); print_unified_diff_inline( - &serde_json::Value::Object(serde_json::Map::default()), + &serde_json::json!({}), &local_envelope.data, "(none)", local_sha, @@ -1088,7 +1080,7 @@ fn render_first_read_diff( ); push_info("# no remote store; all leaves added"); print_unified_diff_inline( - &serde_json::Value::Object(serde_json::Map::default()), + &serde_json::json!({}), &local_envelope.data, "(none)", local_sha, @@ -2301,7 +2293,11 @@ ids = ["app_config"] ]) .to_string(); let oplog = dir.path().join("fastly-ops.log"); - let fake = fake_fastly_gc_list(r#"[{"name":"app_config","id":"store-1"}]"#, &list, &oplog); + let fake = fake_fastly_gc_list( + &serde_json::json!([{"name": "app_config", "id": "store-1"}]).to_string(), + &list, + &oplog, + ); let _prepend = PathPrepend::new(fake.path()); let args = ConfigGcArgs { @@ -2336,13 +2332,17 @@ ids = ["app_config"] let list = json!([ { "item_key": "app_config", - "item_value": r#"{"edgezero_kind":"fastly_config_chunks_v2","data":{}}"#, + "item_value": &serde_json::json!({"edgezero_kind": "fastly_config_chunks_v2", "data": {}}).to_string(), "created_at": "2026-07-01T00:00:00Z" }, ]) .to_string(); let oplog = dir.path().join("fastly-ops.log"); - let fake = fake_fastly_gc_list(r#"[{"name":"app_config","id":"store-1"}]"#, &list, &oplog); + let fake = fake_fastly_gc_list( + &serde_json::json!([{"name": "app_config", "id": "store-1"}]).to_string(), + &list, + &oplog, + ); let _prepend = PathPrepend::new(fake.path()); let args = ConfigGcArgs { @@ -2396,7 +2396,7 @@ ids = ["app_config"] .to_string(); // The store list is keyed ONLY on the ENV name -> a specific id. If the // overlay were ignored (logical "app_config" looked up), resolution fails. - let stores = r#"[{"name":"shared_config","id":"store-42"}]"#; + let stores = &serde_json::json!([{"name": "shared_config", "id": "store-42"}]).to_string(); let oplog = dir.path().join("fastly-ops.log"); let fake = fake_fastly_gc_list(stores, &entries, &oplog); let _prepend = PathPrepend::new(fake.path()); @@ -3914,12 +3914,17 @@ ids = ["default"] // Every kind of unreadable prior STATE must let a local dry-run reach the // writer's degrading count rather than hitting the Spin-Cloud rejection: // a corrupt-but-parsing pointer, malformed TOML, and a non-string root. + let sha = "a".repeat(64); let corrupt_pointer = format!( "app_config = {}", - toml_string_literal(&format!( - "{{\"edgezero_kind\":\"fastly_config_chunks\",\"version\":1,\"chunks\":[{{\"key\":\"app_config.__edgezero_chunks.{sha}.0\",\"len\":10,\"sha256\":\"x\"}}],\"data_sha256\":\"\",\"envelope_len\":10,\"envelope_sha256\":\"{sha}\"}}", - sha = "a".repeat(64), - )), + toml_string_literal(&serde_json::json!({ + "edgezero_kind": "fastly_config_chunks", + "version": 1_u32, + "chunks": [{"key": format!("app_config.__edgezero_chunks.{sha}.0"), "len": 10_u32, "sha256": "x"}], + "data_sha256": "", + "envelope_len": 10_u32, + "envelope_sha256": sha, + }).to_string()), ); let cases = [ ( diff --git a/crates/edgezero-cli/src/demo_server.rs b/crates/edgezero-cli/src/demo_server.rs index 3efe5a4cd..54d9e5123 100644 --- a/crates/edgezero-cli/src/demo_server.rs +++ b/crates/edgezero-cli/src/demo_server.rs @@ -4,7 +4,7 @@ //! //! `demo` runs the bundled `app-demo` example locally — the **same way** //! `app-demo`'s own axum adapter runs it: via -//! [`edgezero_adapter_axum::dev_server::run_app`], which reads the store +//! [`edgezero_adapter_axum::dev_server::run_app_with_preflight`], which reads the store //! config baked into `App` plus `EDGEZERO__*` environment variables and //! wires the full setup (routing, KV / config / secret stores, logging, //! host/port). @@ -15,15 +15,20 @@ /// Run the bundled `app-demo` example on the local axum server. /// -/// Delegates to `run_app`, so `edgezero demo` behaves identically to -/// `cargo run -p app-demo-adapter-axum`. +/// Delegates logger and server setup to Axum. Capability validation runs after +/// logging is ready and before application configuration or listener binding. /// /// # Errors /// /// Returns an error if the demo server fails to start. pub fn run_demo() -> Result<(), String> { use app_demo_core::App; - use edgezero_adapter_axum::dev_server::run_app; + use edgezero_adapter_axum::dev_server::run_app_with_preflight; + use std::io; - run_app::().map_err(|err| format!("demo server error: {err}")) + run_app_with_preflight::(|| { + crate::demo_capability_gate::().map_err(io::Error::other)?; + Ok(()) + }) + .map_err(|err| format!("demo server error: {err}")) } diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index c40f6c15f..222eb7a37 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -6,7 +6,7 @@ use crate::scaffold::{ use edgezero_adapter::scaffold; use edgezero_adapter::scaffold::AdapterBlueprint; use handlebars::Handlebars; -use serde_json::{Map, Value}; +use serde_json::{Value, json}; use std::collections::BTreeMap; use std::env; use std::fmt::{self, Write as _}; @@ -217,7 +217,7 @@ pub fn generate_new(args: &NewArgs) -> Result<(), GeneratorError> { let adapter_artifacts = collect_adapter_data(&layout, &repo_root, &mut workspace_dependencies)?; - let mut data_map = build_base_data( + let mut data_value = build_base_data( &layout, &core_crate_line, &cli_crate_line, @@ -225,14 +225,14 @@ pub fn generate_new(args: &NewArgs) -> Result<(), GeneratorError> { &workspace_dependencies, ); - for context in &adapter_artifacts.contexts { - for (key, value) in &context.data_entries { - data_map.insert(key.clone(), Value::String(value.clone())); + if let Some(data_map) = data_value.as_object_mut() { + for context in &adapter_artifacts.contexts { + for (key, value) in &context.data_entries { + data_map.insert(key.clone(), json!(value)); + } } } - let data_value = Value::Object(data_map); - render_templates(&layout, &adapter_artifacts.contexts, &data_value)?; initialize_git_repo(&layout.out_dir); @@ -269,10 +269,10 @@ fn seed_workspace_dependencies() -> BTreeMap { ); deps.insert( "worker".to_owned(), - "worker = { version = \"0.8\", default-features = false, features = [\"http\"] }" + "worker = { version = \"0.8.5\", default-features = false, features = [\"http\"] }" .to_owned(), ); - deps.insert("fastly".to_owned(), "fastly = \"0.12\"".to_owned()); + deps.insert("fastly".to_owned(), "fastly = \"0.13\"".to_owned()); deps.insert("once_cell".to_owned(), "once_cell = \"1\"".to_owned()); deps.insert( "tokio".to_owned(), @@ -281,7 +281,7 @@ fn seed_workspace_dependencies() -> BTreeMap { deps.insert("tracing".to_owned(), "tracing = \"0.1\"".to_owned()); deps.insert( "spin-sdk".to_owned(), - "spin-sdk = { version = \"6\", default-features = false }".to_owned(), + "spin-sdk = { version = \"7\", default-features = false }".to_owned(), ); // Core depends on `validator` for `#[derive(Validate)]` on the // generated `Config` struct. Pinned to the same @@ -564,70 +564,36 @@ fn build_base_data( cli_crate_line: &str, artifacts: &AdapterArtifacts, workspace_dependencies: &BTreeMap, -) -> Map { - let mut data = Map::new(); - data.insert("name".into(), Value::String(layout.name.clone())); - data.insert("proj_core".into(), Value::String(layout.core_name.clone())); - data.insert("proj_cli".into(), Value::String(layout.cli_name.clone())); - data.insert( - "proj_core_mod".into(), - Value::String(layout.core_mod.clone()), - ); - data.insert("proj_mod".into(), Value::String(layout.project_mod.clone())); - data.insert( - "NameUpperCamel".into(), - Value::String(layout.upper_camel.clone()), - ); - data.insert("EnvPrefix".into(), Value::String(layout.env_prefix.clone())); - data.insert( - "dep_edgezero_core".into(), - Value::String(core_crate_line.to_owned()), - ); - data.insert( - "dep_edgezero_cli".into(), - Value::String(cli_crate_line.to_owned()), - ); - +) -> Value { let adapter_list_str = artifacts .adapter_ids .iter() .map(|id| format!("\"{id}\"")) .collect::>() .join(", "); - data.insert("adapter_list".into(), Value::String(adapter_list_str)); - data.insert( - "workspace_members".into(), - Value::String(artifacts.workspace_members.join("\n")), - ); - data.insert( - "adapter_manifest_sections".into(), - Value::String(artifacts.manifest_sections.clone()), - ); - data.insert( - "readme_adapter_crates".into(), - Value::String(artifacts.readme_adapter_crates.clone()), - ); - data.insert( - "readme_adapter_dev".into(), - Value::String(artifacts.readme_adapter_dev.clone()), - ); - let workspace_dep_lines = workspace_dependencies .values() .cloned() .collect::>() .join("\n"); - data.insert( - "workspace_dependencies".into(), - Value::String(workspace_dep_lines), - ); - - data.insert( - "tool_versions_contents".into(), - Value::String(build_tool_versions(&artifacts.adapter_ids)), - ); - - data + json!({ + "name": layout.name, + "proj_core": layout.core_name, + "proj_cli": layout.cli_name, + "proj_core_mod": layout.core_mod, + "proj_mod": layout.project_mod, + "NameUpperCamel": layout.upper_camel, + "EnvPrefix": layout.env_prefix, + "dep_edgezero_core": core_crate_line, + "dep_edgezero_cli": cli_crate_line, + "adapter_list": adapter_list_str, + "workspace_members": artifacts.workspace_members.join("\n"), + "adapter_manifest_sections": artifacts.manifest_sections, + "readme_adapter_crates": artifacts.readme_adapter_crates, + "readme_adapter_dev": artifacts.readme_adapter_dev, + "workspace_dependencies": workspace_dep_lines, + "tool_versions_contents": build_tool_versions(&artifacts.adapter_ids), + }) } /// Render the `.tool-versions` body for a scaffolded project, @@ -642,10 +608,7 @@ fn build_base_data( /// - `fastly` → `fastly` (the Fastly CLI we shell out to for /// provision + config push) plus `viceroy` (what /// `fastly compute serve` uses for local emulation). -/// - `spin` → no asdf pin; the Spin CLI is install-flow-managed -/// (). A header comment points -/// the operator at the URL when `spin` is in the adapter set so -/// they don't wonder why everything else is pinned but spin. +/// - `spin` → `spin` (the runtime used by `spin build` and `spin up`). /// - `axum` → no extra pin (uses the host Rust toolchain only). /// /// Versions are pulled from this repo's own `.tool-versions` (see @@ -658,7 +621,10 @@ fn build_tool_versions(adapter_ids: &[String]) -> String { } if has("fastly") { lines.push("fastly 15.1.0".to_owned()); - lines.push("viceroy 0.17.0".to_owned()); + lines.push("viceroy 0.21.0".to_owned()); + } + if has("spin") { + lines.push("spin 4.1.0".to_owned()); } lines.push("rust 1.95.0".to_owned()); // Sort + dedup so the file is stable regardless of adapter @@ -666,13 +632,7 @@ fn build_tool_versions(adapter_ids: &[String]) -> String { lines.sort(); lines.dedup(); let mut body = lines.join("\n"); - if has("spin") { - body.push_str( - "\n\n# Spin is not asdf-managed in this scaffold; install via\n# https://spinframework.dev/install\n", - ); - } else { - body.push('\n'); - } + body.push('\n'); body } @@ -806,8 +766,11 @@ fn initialize_git_repo(out_dir: &Path) { mod tests { use super::*; use crate::test_support::path_mutation_guard; + use edgezero_adapter::cli_support::validate_spin_outbound_host_contract; + use edgezero_core::Capability; use edgezero_core::app_config::app_name_prefix; - use edgezero_core::test_env::PathPrepend as PathOverride; + use edgezero_core::manifest::ManifestLoader; + use edgezero_core::test_env::{PathPrepend as PathOverride, env_lock}; use std::path::Path; use tempfile::TempDir; @@ -903,7 +866,7 @@ mod tests { // requirement. let out = build_tool_versions(&["fastly".to_owned()]); assert!(out.contains("fastly 15.1.0"), "must pin fastly: {out}"); - assert!(out.contains("viceroy 0.17.0"), "must pin viceroy: {out}"); + assert!(out.contains("viceroy 0.21.0"), "must pin viceroy: {out}"); assert!(out.contains("rust 1.95.0")); assert!( !out.contains("nodejs"), @@ -921,20 +884,12 @@ mod tests { } #[test] - fn build_tool_versions_spin_adds_install_hint_comment_not_asdf_pin() { - // Spin is install-flow-managed (not consistently asdf- - // managed in our toolchain), so don't write a brittle pin - // we can't honour — explain why with an inline hint so the - // operator isn't left guessing. + fn build_tool_versions_pins_spin_runtime() { let out = build_tool_versions(&["spin".to_owned()]); assert!(out.contains("rust 1.95.0")); assert!( - !out.contains("spin "), - "must NOT pin spin via asdf shape: {out}" - ); - assert!( - out.contains("spinframework.dev/install"), - "must point operators at the spin install URL: {out}" + out.contains("spin 4.1.0"), + "must pin the verified Spin runtime: {out}" ); } @@ -956,7 +911,8 @@ mod tests { for pin in [ "nodejs 24.12.0", "fastly 15.1.0", - "viceroy 0.17.0", + "spin 4.1.0", + "viceroy 0.21.0", "rust 1.95.0", ] { assert_eq!( @@ -965,8 +921,6 @@ mod tests { "`{pin}` must appear exactly once in: {out}" ); } - // Spin install hint present. - assert!(out.contains("spinframework.dev/install")); // Stable order (alphabetical). let pin_block = out.split("\n\n").next().expect("pin block").to_owned(); let lines: Vec<&str> = pin_block.lines().collect(); @@ -1013,6 +967,87 @@ mod tests { } } + fn with_generated_demo_app(assertions: impl FnOnce(&Path)) { + let _process_lock = path_mutation_guard().lock().expect("process env lock"); + let _lock = env_lock().lock().expect("env lock"); + let temp = TempDir::new().expect("temp dir"); + let bin_dir = temp.path().join("bin"); + write_git_stub(&bin_dir); + let _path_guard = PathOverride::new(&bin_dir); + + let args = NewArgs { + name: "demo-app".into(), + dir: Some(temp.path().to_string_lossy().into_owned()), + }; + generate_new(&args).expect("scaffold succeeds"); + assertions(&temp.path().join("demo-app")); + } + + #[test] + fn generated_manifest_declares_outbound_http_optional() { + with_generated_demo_app(|project_dir| { + let source = + fs::read_to_string(project_dir.join("edgezero.toml")).expect("read manifest"); + let manifest = ManifestLoader::load_from_str(&source); + assert_eq!( + manifest.manifest().capabilities.optional, + [Capability::OutboundHttp] + ); + let hosts = manifest + .manifest() + .capabilities + .outbound + .hosts + .as_deref() + .expect("generated outbound hosts"); + assert_eq!(hosts, ["https://*:*"]); + + let readme = fs::read_to_string(project_dir.join("README.md")).expect("read README.md"); + assert!( + readme.contains("may fail at runtime"), + "generated README must explain optional capability behavior" + ); + assert!( + readme.contains("promote `outbound-http` to `required`"), + "generated README must explain how to require outbound success" + ); + }); + } + + #[test] + fn generated_spin_hosts_default_to_https_only() { + with_generated_demo_app(|project_dir| { + let app_manifest_path = project_dir.join("edgezero.toml"); + let spin_manifest_path = project_dir.join("crates/demo-app-adapter-spin/spin.toml"); + let source = fs::read_to_string(&spin_manifest_path).expect("read spin.toml"); + let manifest: toml::Value = toml::from_str(&source).expect("parse spin.toml"); + let hosts = manifest + .get("component") + .and_then(|components| components.get("demo-app-adapter-spin")) + .and_then(|component| component.get("allowed_outbound_hosts")) + .and_then(toml::Value::as_array) + .expect("allowed outbound hosts"); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].as_str(), Some("https://*:*")); + assert!( + hosts.iter().all(|host| { + host.as_str() + .is_some_and(|value| !value.starts_with("http://")) + }), + "generated Spin manifest must not grant cleartext implicitly" + ); + let app_manifest = ManifestLoader::from_path(&app_manifest_path) + .expect("parse generated edgezero.toml"); + validate_spin_outbound_host_contract( + app_manifest.manifest(), + &app_manifest_path, + &spin_manifest_path, + None, + ) + .expect("generated application and selected Spin component must remain aligned"); + }); + } + fn assert_scaffold_files(project_dir: &Path) { assert!(project_dir.is_dir(), "project directory created"); assert!(project_dir.join("Cargo.toml").exists()); @@ -1135,6 +1170,10 @@ mod tests { ); } + #[expect( + clippy::too_many_lines, + reason = "the scaffold contract is clearest as one exhaustive generated-workspace assertion" + )] fn assert_scaffold_workspace(project_dir: &Path) { let cargo_toml = fs::read_to_string(project_dir.join("Cargo.toml")).expect("read Cargo.toml"); @@ -1152,6 +1191,7 @@ mod tests { } assert!(cargo_toml.contains("[workspace.lints.clippy]")); assert!(cargo_toml.contains("blanket_clippy_restriction_lints = \"allow\"")); + assert_scaffold_sdk_dependencies(&cargo_toml); // Generated from a checkout: edgezero crates must resolve to local // path dependencies, not the Git fallback (whose `edgezero-cli` has @@ -1167,6 +1207,27 @@ mod tests { let manifest = fs::read_to_string(project_dir.join("edgezero.toml")).expect("read edgezero.toml"); + let manifest_value: toml::Value = toml::from_str(&manifest).expect("parse edgezero.toml"); + assert_eq!( + manifest_value + .get("capabilities") + .and_then(|capabilities| capabilities.get("optional")) + .and_then(toml::Value::as_array) + .and_then(|values| values.first()) + .and_then(toml::Value::as_str), + Some("outbound-http"), + "generated manifest must explicitly opt into portable outbound HTTP" + ); + assert_eq!( + manifest_value + .get("capabilities") + .and_then(|capabilities| capabilities.get("outbound")) + .and_then(|outbound| outbound.get("hosts")) + .and_then(toml::Value::as_array) + .and_then(|values| values.first()) + .and_then(toml::Value::as_str), + Some("https://*:*") + ); assert!(manifest.contains("[adapters.cloudflare.adapter]")); assert!(manifest.contains("[adapters.fastly.adapter]")); assert!( @@ -1251,6 +1312,24 @@ mod tests { } } + fn assert_scaffold_sdk_dependencies(cargo_toml: &str) { + assert!( + cargo_toml.contains("fastly = \"0.13\""), + "generated Fastly adapter must use the current SDK release line" + ); + assert!( + cargo_toml.contains(concat!( + "worker = { version = \"0.8.5\", default-features = false, ", + "features = [\"http\"] }", + )), + "generated Cloudflare adapter must use the current SDK release" + ); + assert!( + cargo_toml.contains("spin-sdk = { version = \"7\", default-features = false }"), + "generated Spin adapter must use the current SDK major" + ); + } + fn assert_scaffold_crate_lints(project_dir: &Path) { for crate_dir in [ "crates/demo-app-core", @@ -1277,9 +1356,237 @@ mod tests { /// templates shipped a production `.expect(...)` in the `stream` handler, /// infallible `IntoResponse` test usage, and adapter host stubs that /// tripped `print_stderr` / `exit`. - fn assert_generated_sources_are_lint_clean(project_dir: &Path) { + fn fallback_initializer_block(core_lib: &str) -> &str { + const VARIANT: &str = "AdmissionDecision::ReadBodyBeforeFallback"; + let variant_start = core_lib + .find(VARIANT) + .expect("fallback decision initializer"); + let after_variant = core_lib + .get(variant_start..) + .expect("fallback decision start boundary"); + let open_brace = after_variant + .find('{') + .and_then(|offset| variant_start.checked_add(offset)) + .expect("fallback decision opening brace"); + let mut depth = 0_usize; + + let after_open_brace = core_lib + .get(open_brace..) + .expect("fallback opening brace boundary"); + for (offset, character) in after_open_brace.char_indices() { + match character { + '{' => depth = depth.checked_add(1).expect("fallback brace depth"), + '}' => { + depth = depth.checked_sub(1).expect("balanced fallback braces"); + if depth == 0 { + let block_end = open_brace + .checked_add(offset) + .and_then(|end| end.checked_add(character.len_utf8())) + .expect("fallback initializer end"); + return core_lib + .get(variant_start..block_end) + .expect("fallback initializer boundaries"); + } + } + _ => {} + } + } + + panic!("fallback decision closing brace"); + } + + fn normalize_source_whitespace(source: &str) -> String { + source.split_whitespace().collect::>().join(" ") + } + + fn assert_generated_fallback_policy(core_lib: &str) { + let fallback = normalize_source_whitespace(fallback_initializer_block(core_lib)); + assert!( + core_lib.contains("FALLBACK_INGRESS_BODY_BYTES: usize = 4 * 1024") + && fallback.contains("completion: ResponseEgressCompletion::empty()") + && fallback.contains("grant: IngressGrant::empty()") + && fallback.contains("max_body_bytes: FALLBACK_INGRESS_BODY_BYTES") + && fallback.contains(concat!( + "on_exceeded: BufferedIngressResponse::text( ", + "StatusCode::BAD_REQUEST, ", + "\"request body too large\\n\", ),", + )) + && fallback.contains(concat!( + "on_timeout: BufferedIngressResponse::text( ", + "StatusCode::REQUEST_TIMEOUT, ", + "\"request timeout\\n\", ),", + )), + "generated admission policy must bound fallback reads with exact terminal responses", + ); + } + + #[test] + #[should_panic( + expected = "generated admission policy must bound fallback reads with exact terminal responses" + )] + fn generated_fallback_policy_rejects_swapped_terminal_mappings() { + assert_generated_fallback_policy( + r#"AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + max_body_bytes: FALLBACK_INGRESS_BODY_BYTES, + on_exceeded: BufferedIngressResponse::text( + StatusCode::REQUEST_TIMEOUT, + "request timeout\n", + ), + on_timeout: BufferedIngressResponse::text( + StatusCode::BAD_REQUEST, + "request body too large\n", + ), + } + const FALLBACK_INGRESS_BODY_BYTES: usize = 4 * 1024; + on_exceeded: BufferedIngressResponse::text( + StatusCode::BAD_REQUEST, + "request body too large\n", + ), + on_timeout: BufferedIngressResponse::text( + StatusCode::REQUEST_TIMEOUT, + "request timeout\n", + ),"#, + ); + } + + fn assert_generated_axum_entrypoint(project_dir: &Path) { + let axum_main = + fs::read_to_string(project_dir.join("crates/demo-app-adapter-axum/src/main.rs")) + .expect("read axum main.rs"); + assert!( + !axum_main.contains("process::exit") + && !axum_main.contains("EdgeZeroAxumService") + && !axum_main.contains("into_axum_response") + && axum_main.contains("run_app::()"), + "generated Axum entrypoint must use the connection-owning runner", + ); + } + + fn assert_generated_cloudflare_entrypoint(project_dir: &Path) { + let entrypoint = + fs::read_to_string(project_dir.join("crates/demo-app-adapter-cloudflare/src/lib.rs")) + .expect("read Cloudflare lib.rs"); + assert!( + entrypoint.contains("run_app::(req, env, ctx).await") + && !entrypoint.contains("from_core_response"), + "generated Cloudflare entrypoint must use the context-owning runner", + ); + let manifest = fs::read_to_string( + project_dir.join("crates/demo-app-adapter-cloudflare/wrangler.toml"), + ) + .expect("read Cloudflare wrangler.toml"); + let parsed: toml::Value = toml::from_str(&manifest).expect("parse Wrangler manifest"); + let flags = parsed + .get("compatibility_flags") + .and_then(toml::Value::as_array) + .expect("Cloudflare compatibility flags"); + for required in ["enable_request_signal", "enable_weak_ref"] { + assert!( + flags.iter().any(|flag| flag.as_str() == Some(required)), + "generated Cloudflare manifest must enable {required}" + ); + } + } + + fn assert_generated_fastly_entrypoint(project_dir: &Path) { + let entrypoint = + fs::read_to_string(project_dir.join("crates/demo-app-adapter-fastly/src/main.rs")) + .expect("read fastly main.rs"); + assert!( + entrypoint.contains("reason ="), + "adapter attributes must carry a reason for allow_attributes_without_reason", + ); + assert!( + !entrypoint.contains("#[fastly::main]") + && !entrypoint.contains("Request") + && !entrypoint.contains("Response") + && entrypoint.contains("pub fn main() -> Result<(), fastly::Error>") + && entrypoint.contains("run_app::()"), + "generated Fastly entrypoint must let EdgeZero own request receipt and response delivery", + ); + } + + fn assert_generated_spin_entrypoint(project_dir: &Path) { + let entrypoint = + fs::read_to_string(project_dir.join("crates/demo-app-adapter-spin/src/lib.rs")) + .expect("read Spin lib.rs"); + assert!( + !entrypoint.contains("IntoResponse") + && !entrypoint.contains("SpinFullResponse") + && !entrypoint.contains("FullBody") + && entrypoint.contains("anyhow::Result") + && entrypoint.contains("run_app::(req)"), + "generated Spin entrypoint must return EdgeZero's raw WASIp3 response type", + ); + } + + fn assert_generated_core_lifecycle(project_dir: &Path) { + let core_lib = fs::read_to_string(project_dir.join("crates/demo-app-core/src/lib.rs")) + .expect("read core lib.rs"); + assert!( + core_lib.contains("configure = crate::configure_app"), + "generated app macro must install the lifecycle configuration callback", + ); + assert!( + core_lib.contains("fn configure_app(app: &mut EdgeZeroApp) -> Result<(), EdgeError>") + && core_lib.contains("Ok(())"), + "generated lifecycle configuration must propagate initialization failures", + ); + assert!( + core_lib.contains("set_ingress_admission_policy"), + "generated app must configure ingress admission", + ); + let memory_behavior_tests = [ + "platform_memory_startup_accepts_fit", + "platform_memory_startup_preserves_primary_excess", + "platform_memory_startup_preserves_stack_excess", + "platform_memory_startup_keeps_indeterminate_distinct_from_fit", + "platform_memory_startup_preserves_validation_error", + "platform_memory_validation_accounts_for_both_stack_shapes", + ]; + assert!( + core_lib.contains("platform_memory_startup(app.platform())") + && core_lib.contains("platform.validate_memory_envelope(envelope)") + && core_lib.contains("MemoryEnvelopeValidation::Fits") + && core_lib.contains("MemoryEnvelopeValidation::Exceeds") + && core_lib.contains("MemoryEnvelopeValidation::Indeterminate") + && core_lib.contains("PlatformMemoryStartupError::UnsupportedValidationOutcome") + && memory_behavior_tests + .into_iter() + .all(|test_name| core_lib.contains(test_name)) + && !core_lib.contains("Semaphore") + && !core_lib.contains("set_ingress_concurrency"), + "generated app must carry typed memory startup behavior without adding a concurrency gate", + ); + assert!( + core_lib.contains("completion: ResponseEgressCompletion::empty()") + && core_lib.contains("grant: IngressGrant::empty()") + && !core_lib.contains("ResponsePermit") + && !core_lib.contains("AdmissionLease") + && !core_lib.contains("ResponseEgressCompletion::late_bound") + && !core_lib.contains("set_detached_response_egress_decision_factory"), + "generated apps must use the minimal core-owned lifecycle defaults", + ); + assert!( + core_lib.contains( + "EdgeZeroApp::build::(edgezero_core::PlatformMetadata::default())" + ), + "generated tests must handle fallible application assembly", + ); + assert_generated_fallback_policy(&core_lib); + } + + fn assert_generated_outbound_handler(project_dir: &Path) { let handlers = fs::read_to_string(project_dir.join("crates/demo-app-core/src/handlers.rs")) .expect("read handlers.rs"); + assert!( + handlers.contains("ctx.take_ingress_grant().is_some()") + && !handlers.contains("AdmissionLease") + && !handlers.contains("install_response_permit"), + "generated admission handler must demonstrate one-time grant consumption without a fake permit", + ); assert!( handlers.contains("pub async fn stream() -> Result"), "stream handler must be fallible, not panic via expect()", @@ -1292,45 +1599,110 @@ mod tests { handlers.contains(".into_response()"), "handler tests must use the fallible IntoResponse pattern", ); + assert!( + handlers.contains("HttpClient::with_client(TestOutboundClient::default())"), + "generated tests must execute the portable outbound client", + ); + assert!( + handlers.contains("fn generated_outbound_http_smoke()"), + "generated core must contain the outbound smoke sentinel", + ); + for required in [ + ".cache_policy(", + ".max_request_body_bytes(", + ".max_encoded_response_bytes(", + ".max_decoded_response_bytes(", + ".max_response_bytes(", + ".max_response_header_bytes(", + ".max_response_header_count(", + ".max_chunk_bytes(", + ".max_brotli_window_bits(", + ".max_decoder_bytes(", + ".timeout(", + "send_all_until(", + "map_err(|failure| failure.error)", + "OutboundBatchTermination::Completed", + "OutboundBatchTermination::Cutoff", + "OutboundBatch::from_driver", + "OutboundBatchDriverEvent::Item", + "BudgetSource, Deadline, MonotonicClock, MonotonicInstant, OutboundBatchDriverEvent,", + "clock: MonotonicClock", + "self.clock.clone()", + "generated_outbound_client_retains_its_clock_in_responses", + "FanoutSlotOutcome::Unresolved", + "OutboundCachePolicy::Bypass", + "slot.elapsed", + ] { + assert!( + handlers.contains(required), + "generated outbound example must contain `{required}`", + ); + } + assert!( + !handlers.contains(".send_all("), + "generated outbound example must not retain the removed send_all API", + ); + assert!( + !handlers.contains("max_brotli_decoder_bytes") + && !handlers.contains("MAX_BROTLI_DECODER_BYTES"), + "generated outbound example must use the generalized decoder budget", + ); + assert!( + handlers.contains("ResponseEgressDeadline::after(RESPONSE_EGRESS_BUDGET)") + && !handlers.contains(concat!("ResponseEgressDeadline::", "new(")), + "generated responses must use an egress-start-relative deadline", + ); + assert!( + handlers.contains(".variable(\"API_BASE_URL\")"), + "generated handlers must read runtime variables through the request context", + ); + } - let axum_main = - fs::read_to_string(project_dir.join("crates/demo-app-adapter-axum/src/main.rs")) - .expect("read axum main.rs"); + fn assert_generated_route_and_provider_manifests(project_dir: &Path) { + let manifest = + fs::read_to_string(project_dir.join("edgezero.toml")).expect("read edgezero.toml"); + assert!( + manifest.contains("[[environment.variables]]\nname = \"API_BASE_URL\""), + "generated manifest must declare the upstream runtime variable", + ); + assert!( + manifest.contains("path = \"/admission\"") + && manifest.contains("class = \"diagnostic\""), + "generated manifest must wire the admission diagnostic class", + ); assert!( - !axum_main.contains("process::exit"), - "axum host entrypoint must return Result, not call process::exit", + manifest.contains("path = \"/fanout\"") && manifest.contains("class = \"outbound\""), + "generated manifest must wire the outbound fanout class", ); - let fastly_main = - fs::read_to_string(project_dir.join("crates/demo-app-adapter-fastly/src/main.rs")) - .expect("read fastly main.rs"); + let spin_manifest = + fs::read_to_string(project_dir.join("crates/demo-app-adapter-spin/spin.toml")) + .expect("read spin.toml"); assert!( - fastly_main.contains("reason ="), - "adapter attributes must carry a reason for allow_attributes_without_reason", + spin_manifest.contains("allowed_outbound_hosts = [\"https://*:*\"]"), + "generated Spin hosts must default to HTTPS only", ); } + fn assert_generated_sources_are_lint_clean(project_dir: &Path) { + assert_generated_core_lifecycle(project_dir); + assert_generated_outbound_handler(project_dir); + assert_generated_route_and_provider_manifests(project_dir); + assert_generated_axum_entrypoint(project_dir); + assert_generated_cloudflare_entrypoint(project_dir); + assert_generated_fastly_entrypoint(project_dir); + assert_generated_spin_entrypoint(project_dir); + } + #[test] fn generate_new_scaffolds_workspace_layout() { - let _path_lock = path_mutation_guard().lock().expect("path guard"); - let temp = TempDir::new().expect("temp dir"); - let bin_dir = temp.path().join("bin"); - write_git_stub(&bin_dir); - let _path_guard = PathOverride::new(&bin_dir); - - let args = NewArgs { - name: "demo-app".into(), - dir: Some(temp.path().to_string_lossy().into_owned()), - }; - - generate_new(&args).expect("scaffold succeeds"); - - let project_dir = temp.path().join("demo-app"); - assert_scaffold_files(&project_dir); - assert_scaffold_workspace(&project_dir); - assert_scaffold_app_config(&project_dir); - assert_scaffold_crate_lints(&project_dir); - assert_scaffold_cli_full_command_set(&project_dir); + with_generated_demo_app(|project_dir| { + assert_scaffold_files(project_dir); + assert_scaffold_workspace(project_dir); + assert_scaffold_app_config(project_dir); + assert_scaffold_crate_lints(project_dir); + assert_scaffold_cli_full_command_set(project_dir); + }); } /// The scaffolded `-cli` must diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index 37232db75..1bc53fa36 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -32,6 +32,8 @@ mod diff; #[cfg(feature = "cli")] mod generator; #[cfg(feature = "cli")] +mod manifest_source; +#[cfg(feature = "cli")] mod provision; #[cfg(feature = "cli")] mod scaffold; @@ -59,7 +61,9 @@ use args::{ ActiveVersionArgs, BuildArgs, DeployArgs, HealthcheckArgs, NewArgs, RollbackArgs, ServeArgs, }; #[cfg(feature = "cli")] -use edgezero_adapter::registry::{AdapterDeployContext, DeployStoreIds}; +use edgezero_adapter::registry::{AdapterDeployContext, AdapterExecutionTarget, DeployStoreIds}; +#[cfg(any(test, feature = "demo-example"))] +use edgezero_core::app::Hooks; #[cfg(feature = "cli")] use edgezero_core::manifest::{Manifest, ManifestLoader, StoreDeclaration}; #[cfg(feature = "cli")] @@ -124,7 +128,8 @@ impl log::Log for CliLogger { /// Initialize a CLI logger that prints messages without timestamps /// or level prefixes — the CLI's output IS the user-facing UX, not -/// a debug log. See [`CliLogger`] for the routing rules. +/// a debug log. Informational messages go to stdout, warnings and errors go to +/// stderr, and debug and trace messages are filtered out. #[cfg(feature = "cli")] #[inline] pub fn init_cli_logger() { @@ -142,17 +147,11 @@ pub fn init_cli_logger() { #[cfg(feature = "cli")] #[inline] pub fn run_build(args: &BuildArgs) -> Result<(), String> { - let manifest = load_manifest_optional()?; - ensure_adapter_defined(&args.adapter, manifest.as_ref())?; - if let Some(loader) = &manifest { - log_store_bindings(&args.adapter, loader); + let runtime = manifest_source::resolve_runtime(&args.adapter, adapter::Action::Build)?; + if let Some(manifest) = runtime.manifest() { + log_store_bindings(runtime.adapter_name(), manifest); } - adapter::execute( - &args.adapter, - adapter::Action::Build, - manifest.as_ref(), - &args.adapter_args, - ) + adapter::execute_runtime(&runtime, &args.adapter_args) } /// Deploy the project to a target edge adapter. @@ -185,48 +184,69 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { )); } - let manifest = load_manifest_optional()?; - ensure_adapter_defined(&args.adapter, manifest.as_ref())?; - let manifest_stores = manifest.as_ref().map(|loader| &loader.manifest().stores); + let action = if args.staging { + adapter::Action::DeployStaging + } else { + adapter::Action::Deploy + }; + let runtime = manifest_source::resolve_runtime(&args.adapter, action)?; + let manifest_stores = runtime.manifest().map(|manifest| &manifest.stores); let declared_ids = |declaration: Option<&StoreDeclaration>| { declaration .map(|store| store.ids.clone()) .unwrap_or_default() }; - let deploy_stores = DeployStoreIds { + let stores = DeployStoreIds { config: declared_ids(manifest_stores.and_then(|declared| declared.config.as_ref())), kv: declared_ids(manifest_stores.and_then(|declared| declared.kv.as_ref())), secrets: declared_ids(manifest_stores.and_then(|declared| declared.secrets.as_ref())), }; - let variable_defaults = manifest - .as_ref() - .map(|loader| manifest_variable_defaults(loader.manifest(), &args.adapter)) + let variable_defaults = runtime + .manifest() + .map(|manifest| manifest_variable_defaults(manifest, runtime.adapter_name())) .unwrap_or_default(); - let (adapter_manifest_path, adapter_manifest_path_error) = - match resolve_adapter_manifest_path(manifest.as_ref(), &args.adapter) { - Ok(path) => (path.map(PathBuf::from), None), - Err(err) => (None, Some(err)), - }; - let application_manifest_path = loaded_application_manifest_path(manifest.as_ref())?; + let (adapter_manifest_path, adapter_manifest_path_error) = match runtime.target() { + manifest_source::ResolvedAdapterTarget::Registered(target) => { + (target.platform_manifest().map(Path::to_path_buf), None) + } + manifest_source::ResolvedAdapterTarget::Shell(_) => { + match resolve_adapter_manifest_path(runtime.manifest_loader(), runtime.adapter_name()) { + Ok(path) => (path.map(PathBuf::from), None), + Err(error) => (None, Some(error)), + } + } + }; + let application_manifest_path = runtime.manifest_path().map(Path::to_path_buf); let application_release_root = resolve_application_release_root( args.application_release.as_deref(), application_manifest_path.as_deref(), )?; + let target = match runtime.target() { + manifest_source::ResolvedAdapterTarget::Registered(target) => target.clone(), + manifest_source::ResolvedAdapterTarget::Shell(shell) => AdapterExecutionTarget::new( + shell.root().to_path_buf(), + shell.component().map(str::to_owned), + adapter_manifest_path.clone(), + ), + }; + let defaults = runtime.manifest().map_or_else(Vec::new, |manifest| { + adapter::resolve_environment_defaults(&manifest.environment_for(runtime.adapter_name())) + }); let context = AdapterDeployContext { adapter_manifest_path, application_manifest_path, application_release_root, + execution_target: Some(target.with_environment_defaults(defaults)), service_id: args.service_id.clone(), - stores: deploy_stores, + stores, staging: args.staging, variable_defaults, }; - adapter::deploy( - &args.adapter, + adapter::deploy_runtime( + &runtime, &context, adapter_manifest_path_error.as_deref(), - manifest.as_ref(), &args.adapter_args, ) } @@ -241,30 +261,6 @@ fn manifest_variable_defaults(manifest: &Manifest, adapter: &str) -> BTreeMap, -) -> Result, String> { - if loader.is_none() { - return Ok(None); - } - let path = env::var("EDGEZERO_MANIFEST") - .map_or_else(|_| PathBuf::from("edgezero.toml"), PathBuf::from); - let canonical = path.canonicalize().map_err(|error| { - format!( - "could not resolve loaded application manifest {}: {error}", - path.display() - ) - })?; - if !canonical.is_file() { - return Err(format!( - "loaded application manifest {} is not a regular file", - canonical.display() - )); - } - Ok(Some(canonical)) -} - #[cfg(feature = "cli")] fn resolve_application_release_root( requested_root: Option<&Path>, @@ -486,14 +482,8 @@ pub fn run_active_version(args: &ActiveVersionArgs) -> Result<(), String> { #[cfg(feature = "cli")] #[inline] pub fn run_serve(args: &ServeArgs) -> Result<(), String> { - let manifest = load_manifest_optional()?; - ensure_adapter_defined(&args.adapter, manifest.as_ref())?; - adapter::execute( - &args.adapter, - adapter::Action::Serve, - manifest.as_ref(), - &[], - ) + let runtime = manifest_source::resolve_runtime(&args.adapter, adapter::Action::Serve)?; + adapter::execute_runtime(&runtime, &[]) } /// Create a new `EdgeZero` app skeleton. @@ -521,10 +511,14 @@ pub fn run_demo() -> Result<(), String> { demo_server::run_demo() } +#[cfg(any(test, feature = "demo-example"))] +fn demo_capability_gate() -> Result<(), String> { + adapter::ensure_capabilities("axum", Application::manifest().as_contract()) +} + #[cfg(feature = "cli")] -fn store_bindings_message(adapter_name: &str, manifest: &ManifestLoader) -> Option { - let manifest_data = manifest.manifest(); - if !manifest_data.secret_store_enabled(adapter_name) { +fn store_bindings_message(adapter_name: &str, manifest: &Manifest) -> Option { + if !manifest.secret_store_enabled(adapter_name) { return None; } @@ -549,7 +543,7 @@ fn store_bindings_message(adapter_name: &str, manifest: &ManifestLoader) -> Opti } #[cfg(feature = "cli")] -fn log_store_bindings(adapter_name: &str, manifest: &ManifestLoader) { +fn log_store_bindings(adapter_name: &str, manifest: &Manifest) { if let Some(message) = store_bindings_message(adapter_name, manifest) { log::info!("{message}"); } @@ -607,7 +601,9 @@ mod tests { use edgezero_adapter::registry::{ self as adapter_registry, Adapter, AdapterAction, DeployOwnership, }; - use edgezero_core::manifest::ManifestLoader; + use edgezero_core::app::Hooks; + use edgezero_core::manifest::{BakedManifest, Manifest, ManifestLoader}; + use edgezero_core::router::RouterService; #[cfg(unix)] use edgezero_core::test_env::PathPrepend; use std::collections::BTreeMap; @@ -632,8 +628,25 @@ mod tests { LazyLock::new(|| Mutex::new(None)); static RECORDING_DEPLOY_ADAPTER: RecordingDeployAdapter = RecordingDeployAdapter; + struct CapabilityGateApp; struct RecordingDeployAdapter; + #[expect( + clippy::missing_trait_methods, + reason = "test hook overrides only routes and the baked manifest under test" + )] + impl Hooks for CapabilityGateApp { + fn manifest() -> BakedManifest { + Manifest::from_baked_json( + r#"{"capabilities":{"required":["outbound-complete-resource-accounting"]}}"#, + ) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + #[expect( clippy::missing_trait_methods, reason = "the recording adapter exercises only deploy preflight, deploy dispatch, and finalization" @@ -696,6 +709,41 @@ mod tests { .expect("deploy preflight context lock") = None; } + #[test] + fn demo_capability_gate_runs_before_server_start() { + assert!(demo_capability_gate::().is_err()); + } + + #[test] + fn deploy_capability_gate_precedes_managed_preflight() { + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_recording_deploy_adapter(PREFLIGHT_ADAPTER_MANAGED); + let temp = TempDir::new().expect("temp dir"); + let manifest = temp.path().join("edgezero.toml"); + fs::write(&manifest, "[app]\nname = \"demo\"\n[capabilities]\nrequired = [\"outbound-complete-resource-accounting\"]\n[adapters.recording_deploy_test.commands]\ndeploy = \"exit 99\"\n").expect("write manifest"); + let _manifest = EnvOverride::set("EDGEZERO_MANIFEST", &manifest); + let error = run_deploy(&DeployArgs { + adapter: "recording_deploy_test".to_owned(), + application_release: None, + adapter_args: Vec::new(), + service_id: None, + staging: false, + }) + .expect_err("unsupported required capability must block deployment"); + assert!( + error.contains("outbound-complete-resource-accounting"), + "{error}" + ); + assert!( + DEPLOY_PREFLIGHT_CONTEXT + .lock() + .expect("preflight lock") + .is_none() + ); + assert_eq!(DEPLOY_CALLS.load(Ordering::SeqCst), 0); + assert_eq!(DEPLOY_FINALIZE_CALLS.load(Ordering::SeqCst), 0); + } + #[test] fn load_manifest_optional_hard_errors_when_explicit_env_path_missing() { // An explicit `EDGEZERO_MANIFEST` pointing at a missing file must @@ -834,7 +882,7 @@ mod tests { .expect_err("staging must resolve the explicitly selected fastly.toml"); assert!( - err.contains("missing/fastly.toml") && err.contains("could not resolve"), + err.contains("missing/fastly.toml") && err.contains("failed to resolve"), "staging reports the selected missing manifest before discovery: {err}" ); } @@ -1476,19 +1524,20 @@ ids = ["MY_SECRETS"] "#, ); - let axum = store_bindings_message("axum", &loader).expect("axum message"); + let axum = store_bindings_message("axum", loader.manifest()).expect("axum message"); assert!(axum.contains("environment variables")); - let cloudflare = store_bindings_message("cloudflare", &loader).expect("cloudflare message"); + let cloudflare = + store_bindings_message("cloudflare", loader.manifest()).expect("cloudflare message"); assert!(cloudflare.contains("wrangler")); - let fastly = store_bindings_message("fastly", &loader).expect("fastly message"); + let fastly = store_bindings_message("fastly", loader.manifest()).expect("fastly message"); assert!(fastly.contains("secrets enabled")); } #[test] fn store_bindings_message_is_absent_without_secret_store() { let loader = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); - assert!(store_bindings_message("fastly", &loader).is_none()); + assert!(store_bindings_message("fastly", loader.manifest()).is_none()); } } diff --git a/crates/edgezero-cli/src/main.rs b/crates/edgezero-cli/src/main.rs index bdcf076c8..491c44929 100644 --- a/crates/edgezero-cli/src/main.rs +++ b/crates/edgezero-cli/src/main.rs @@ -6,8 +6,14 @@ fn main() { use edgezero_cli::args::{self, Args, Command, ConfigCmd}; use std::process; + let cmd = Args::parse().cmd; + #[cfg(feature = "demo-example")] + if !matches!(&cmd, Command::Demo) { + edgezero_cli::init_cli_logger(); + } + #[cfg(not(feature = "demo-example"))] edgezero_cli::init_cli_logger(); - let result = match Args::parse().cmd { + let result = match cmd { Command::Auth(cmd_args) => edgezero_cli::run_auth(&cmd_args), Command::Build(cmd_args) => edgezero_cli::run_build(&cmd_args), // `config push` and `config diff` require a typed app-config struct @@ -18,7 +24,7 @@ fn main() { #[expect( clippy::print_stderr, reason = "intentional: pointer text must reach the user even when \ - stdout is piped; this is the only stderr write in main" + stdout is piped" )] { eprintln!("{}", args::STUB_POINTER_AFTER_HELP); @@ -42,7 +48,13 @@ fn main() { Command::Serve(cmd_args) => edgezero_cli::run_serve(&cmd_args), }; if let Err(err) = result { - log::error!("[edgezero] {err}"); + #[expect( + clippy::print_stderr, + reason = "terminal command errors must remain visible when runtime logging is Off" + )] + { + eprintln!("[edgezero] {err}"); + }; process::exit(1); } } diff --git a/crates/edgezero-cli/src/manifest_source.rs b/crates/edgezero-cli/src/manifest_source.rs new file mode 100644 index 000000000..cd4f4fb1a --- /dev/null +++ b/crates/edgezero-cli/src/manifest_source.rs @@ -0,0 +1,511 @@ +use std::env; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; + +use edgezero_adapter::cli_support::{find_workspace_root, path_distance}; +use edgezero_adapter::registry::AdapterExecutionTarget; +use edgezero_core::manifest::{Manifest, ManifestLoader, ResolvedEnvironment}; + +use crate::adapter::Action; + +pub(crate) enum ResolvedAdapterTarget { + Registered(AdapterExecutionTarget), + Shell(ResolvedShellTarget), +} + +pub(crate) struct ResolvedManifest { + loader: ManifestLoader, + path: PathBuf, +} + +pub(crate) struct ResolvedRuntime { + action: Action, + adapter: String, + contract: Option, + target: ResolvedAdapterTarget, +} + +pub(crate) struct ResolvedShellTarget { + bind_host: Option, + bind_port: Option, + command: String, + component: Option, + environment: ResolvedEnvironment, + platform_manifest: Option, + root: PathBuf, +} + +impl ResolvedManifest { + #[inline] + pub(crate) fn path(&self) -> &Path { + &self.path + } +} + +impl ResolvedRuntime { + #[inline] + pub(crate) fn action(&self) -> Action { + self.action + } + + #[inline] + pub(crate) fn adapter_name(&self) -> &str { + &self.adapter + } + + #[inline] + pub(crate) fn manifest(&self) -> Option<&Manifest> { + self.contract + .as_ref() + .map(|contract| contract.loader.manifest()) + } + + #[inline] + pub(crate) fn manifest_loader(&self) -> Option<&ManifestLoader> { + self.contract.as_ref().map(|contract| &contract.loader) + } + + #[inline] + pub(crate) fn manifest_path(&self) -> Option<&Path> { + self.contract.as_ref().map(ResolvedManifest::path) + } + + #[inline] + pub(crate) fn target(&self) -> &ResolvedAdapterTarget { + &self.target + } +} + +impl ResolvedShellTarget { + #[inline] + pub(crate) fn bind_host(&self) -> Option<&str> { + self.bind_host.as_deref() + } + + #[inline] + pub(crate) fn bind_port(&self) -> Option { + self.bind_port + } + + #[inline] + pub(crate) fn command(&self) -> &str { + &self.command + } + + #[inline] + pub(crate) fn component(&self) -> Option<&str> { + self.component.as_deref() + } + + #[inline] + pub(crate) fn environment(&self) -> &ResolvedEnvironment { + &self.environment + } + + #[inline] + pub(crate) fn platform_manifest(&self) -> Option<&Path> { + self.platform_manifest.as_deref() + } + + #[inline] + pub(crate) fn root(&self) -> &Path { + &self.root + } +} + +pub(crate) fn resolve_runtime(adapter: &str, action: Action) -> Result { + let invocation_dir = env::current_dir() + .map_err(|error| format!("failed to read invocation directory: {error}"))?; + resolve_runtime_from( + adapter, + action, + &invocation_dir, + env::var_os("EDGEZERO_MANIFEST"), + ) +} + +fn canonical_directory(path: &Path, label: &str) -> Result { + let canonical = fs::canonicalize(path) + .map_err(|error| format!("failed to resolve {label} {}: {error}", path.display()))?; + if !canonical.is_dir() { + return Err(format!( + "{label} {} is not a directory", + canonical.display() + )); + } + Ok(canonical) +} + +fn canonical_regular_file(path: &Path, label: &str) -> Result { + let canonical = fs::canonicalize(path) + .map_err(|error| format!("failed to resolve {label} {}: {error}", path.display()))?; + if !canonical.is_file() { + return Err(format!( + "{label} {} is not a regular file", + canonical.display() + )); + } + Ok(canonical) +} + +#[expect( + clippy::filetype_is_file, + reason = "manifest discovery intentionally excludes symbolic links before canonicalization" +)] +fn collect_named_files(root: &Path, name: &str, depth: usize, output: &mut Vec) { + if depth == 0 { + return; + } + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_file() && path.file_name().is_some_and(|file_name| file_name == name) { + output.push(path); + } else if file_type.is_dir() + && !matches!( + path.file_name().and_then(|file_name| file_name.to_str()), + Some(".git" | "target") + ) + { + collect_named_files(&path, name, depth.saturating_sub(1), output); + } else { + // Other files and excluded directories are not manifest candidates. + } + } +} + +fn command_for(manifest: &Manifest, adapter: &str, action: Action) -> Option { + let (_canonical, config) = manifest.adapter_entry(adapter)?; + match action { + Action::Build => config.commands.build.clone(), + Action::Deploy => config.commands.deploy.clone(), + Action::Serve => config.commands.serve.clone(), + Action::DeployStaging + | Action::AuthLogin + | Action::AuthLogout + | Action::AuthStatus + | Action::EmitVersion + | Action::Healthcheck + | Action::Rollback => None, + } +} + +fn discover_default_manifest(invocation_dir: &Path) -> Result, String> { + for ancestor in invocation_dir.ancestors() { + let candidate = ancestor.join("edgezero.toml"); + if candidate.is_file() { + return canonical_regular_file(&candidate, "application manifest").map(Some); + } + } + + let workspace = canonical_directory(&find_workspace_root(invocation_dir), "workspace root")?; + let mut candidates = Vec::new(); + collect_named_files(&workspace, "edgezero.toml", 9, &mut candidates); + choose_nearest(invocation_dir, candidates, "application manifest") +} + +fn discover_platform_manifest( + adapter: &str, + invocation_dir: &Path, +) -> Result, String> { + let name = platform_manifest_name(adapter); + for ancestor in invocation_dir.ancestors() { + let candidate = ancestor.join(name); + if candidate.is_file() && ancestor.join("Cargo.toml").is_file() { + return canonical_regular_file(&candidate, "platform manifest").map(Some); + } + } + let workspace = canonical_directory(&find_workspace_root(invocation_dir), "workspace root")?; + let mut candidates = Vec::new(); + collect_named_files(&workspace, name, 9, &mut candidates); + candidates.retain(|path| { + path.parent() + .is_some_and(|parent| parent.join("Cargo.toml").is_file()) + }); + choose_nearest(invocation_dir, candidates, "platform manifest") +} + +fn choose_nearest( + invocation_dir: &Path, + candidates: Vec, + label: &str, +) -> Result, String> { + let mut ranked = candidates + .into_iter() + .map(|path| { + let parent = path.parent().unwrap_or(Path::new("")); + (path_distance(invocation_dir, parent), path) + }) + .collect::>(); + ranked.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); + let Some((distance, selected)) = ranked.first() else { + return Ok(None); + }; + if let Some((_next_distance, next)) = ranked.get(1).filter(|next| next.0 == *distance) { + return Err(format!( + "ambiguous {label} selection: {} and {} are equally near the invocation directory", + selected.display(), + next.display() + )); + } + canonical_regular_file(selected, label).map(Some) +} + +fn load_resolved_manifest(path: PathBuf) -> Result { + let loader = ManifestLoader::from_path(&path) + .map_err(|error| format!("failed to load {}: {error}", path.display()))?; + Ok(ResolvedManifest { loader, path }) +} + +fn platform_manifest_from_contract( + manifest: &Manifest, + adapter: &str, + app_root: &Path, +) -> Result, String> { + let Some((_canonical, config)) = manifest.adapter_entry(adapter) else { + return Err(format!( + "adapter `{adapter}` is not configured in {}", + app_root.join("edgezero.toml").display() + )); + }; + let Some(relative) = config.adapter.manifest.as_deref() else { + return Ok(None); + }; + let relative_path = Path::new(relative); + if relative_path.is_absolute() { + return Err(format!( + "adapter `{adapter}` platform manifest must be relative to the application root" + )); + } + let platform = canonical_regular_file( + &app_root.join(relative_path), + &format!("adapter `{adapter}` platform manifest"), + )?; + if !platform.starts_with(app_root) { + return Err(format!( + "adapter `{adapter}` platform manifest {} resolves outside application root {}", + platform.display(), + app_root.display() + )); + } + Ok(Some(platform)) +} + +fn platform_manifest_name(adapter: &str) -> &'static str { + if adapter.eq_ignore_ascii_case("axum") { + "axum.toml" + } else if adapter.eq_ignore_ascii_case("cloudflare") { + "wrangler.toml" + } else if adapter.eq_ignore_ascii_case("fastly") { + "fastly.toml" + } else if adapter.eq_ignore_ascii_case("spin") { + "spin.toml" + } else { + "edgezero-platform.toml" + } +} + +fn produces_current_runtime(action: Action) -> bool { + match action { + Action::Build | Action::Deploy | Action::DeployStaging | Action::Serve => true, + Action::AuthLogin + | Action::AuthLogout + | Action::AuthStatus + | Action::EmitVersion + | Action::Healthcheck + | Action::Rollback => false, + } +} + +fn resolve_contract_path( + invocation_dir: &Path, + explicit: Option, +) -> Result, String> { + if let Some(raw) = explicit { + let configured = PathBuf::from(raw); + let candidate = if configured.is_absolute() { + configured + } else { + invocation_dir.join(configured) + }; + return canonical_regular_file(&candidate, "explicit application manifest").map(Some); + } + discover_default_manifest(invocation_dir) +} + +pub(crate) fn resolve_runtime_from( + adapter: &str, + action: Action, + invocation_dir: &Path, + explicit: Option, +) -> Result { + if !produces_current_runtime(action) { + return Err("operational action entered outbound runtime resolver".to_owned()); + } + let canonical_invocation_dir = canonical_directory(invocation_dir, "invocation directory")?; + let contract_path = resolve_contract_path(&canonical_invocation_dir, explicit)?; + let contract = contract_path.map(load_resolved_manifest).transpose()?; + + if let Some(resolved_manifest) = contract { + let manifest = resolved_manifest.loader.manifest(); + let (canonical_adapter, config) = manifest.adapter_entry(adapter).ok_or_else(|| { + format!( + "adapter `{adapter}` is not configured in {}", + resolved_manifest.path.display() + ) + })?; + let app_root = canonical_directory( + manifest + .root() + .ok_or_else(|| "resolved manifest has no application root".to_owned())?, + "application root", + )?; + let adapter_name = canonical_adapter.to_ascii_lowercase(); + let component = config.adapter.component.clone(); + let target = if let Some(command) = command_for(manifest, &adapter_name, action) { + let platform_manifest = if adapter_name == "spin" { + platform_manifest_from_contract(manifest, &adapter_name, &app_root)? + } else { + None + }; + ResolvedAdapterTarget::Shell(ResolvedShellTarget { + bind_host: config.adapter.host.clone(), + bind_port: config.adapter.port, + command, + component, + environment: manifest.environment_for(&adapter_name), + platform_manifest, + root: app_root, + }) + } else { + let platform_manifest = + platform_manifest_from_contract(manifest, &adapter_name, &app_root)?; + ResolvedAdapterTarget::Registered(AdapterExecutionTarget::new( + app_root, + component, + platform_manifest, + )) + }; + return Ok(ResolvedRuntime { + action, + adapter: adapter_name, + contract: Some(resolved_manifest), + target, + }); + } + + let platform_manifest = discover_platform_manifest(adapter, &canonical_invocation_dir)?; + let app_root = platform_manifest + .as_deref() + .and_then(Path::parent) + .map(Path::to_path_buf) + .unwrap_or(canonical_invocation_dir); + Ok(ResolvedRuntime { + action, + adapter: adapter.to_ascii_lowercase(), + contract: None, + target: ResolvedAdapterTarget::Registered(AdapterExecutionTarget::new( + app_root, + None, + platform_manifest, + )), + }) +} + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + + use tempfile::TempDir; + + use super::*; + + fn write_manifest(root: &Path, adapter: &str, platform: &str, command: Option<&str>) { + fs::create_dir_all(root).expect("create app root"); + fs::write(root.join(platform), "# platform fixture\n").expect("write platform manifest"); + let command_section = command.map_or_else(String::new, |configured_command| { + format!("\n[adapters.{adapter}.commands]\nbuild = {configured_command:?}\n") + }); + fs::write( + root.join("edgezero.toml"), + format!( + "[app]\nname = \"fixture\"\n[adapters.{adapter}.adapter]\nmanifest = {platform:?}\n{command_section}" + ), + ) + .expect("write app manifest"); + fs::write(root.join("Cargo.toml"), "[workspace]\n").expect("write workspace manifest"); + } + + #[test] + fn explicit_manifest_is_authoritative() { + let temp = TempDir::new().expect("temp dir"); + let invocation = temp.path().join("invocation"); + let selected = temp.path().join("selected"); + write_manifest(&invocation, "axum", "axum.toml", None); + write_manifest(&selected, "fastly", "fastly.toml", Some("printf selected")); + + let runtime = resolve_runtime_from( + "fastly", + Action::Build, + &invocation, + Some(OsString::from("../selected/edgezero.toml")), + ) + .expect("resolve explicit manifest"); + let expected_path = + fs::canonicalize(selected.join("edgezero.toml")).expect("canonical selected"); + assert_eq!(runtime.adapter_name(), "fastly"); + assert_eq!( + runtime.contract.as_ref().map(ResolvedManifest::path), + Some(expected_path.as_path()) + ); + assert!(matches!(runtime.target(), ResolvedAdapterTarget::Shell(_))); + } + + #[test] + fn runtime_resolver_rejects_cross_app_pair() { + let temp = TempDir::new().expect("temp dir"); + let selected = temp.path().join("selected"); + let other = temp.path().join("other"); + fs::create_dir_all(&selected).expect("selected root"); + fs::create_dir_all(&other).expect("other root"); + fs::write(other.join("fastly.toml"), "# other\n").expect("platform"); + fs::write(selected.join("Cargo.toml"), "[workspace]\n").expect("cargo"); + fs::write( + selected.join("edgezero.toml"), + "[app]\nname = \"fixture\"\n[adapters.fastly.adapter]\nmanifest = \"../other/fastly.toml\"\n", + ) + .expect("manifest"); + + let result = resolve_runtime_from( + "fastly", + Action::Build, + &selected, + Some(OsString::from("edgezero.toml")), + ); + assert!(result.is_err_and(|error| error.contains("outside application root"))); + } + + #[test] + fn runtime_resolver_rejects_equal_distance_ambiguity() { + let temp = TempDir::new().expect("temp dir"); + fs::write(temp.path().join("Cargo.toml"), "[workspace]\n").expect("workspace"); + write_manifest(&temp.path().join("left"), "axum", "axum.toml", None); + write_manifest(&temp.path().join("right"), "axum", "axum.toml", None); + + let result = resolve_runtime_from("axum", Action::Build, temp.path(), None); + assert!(result.is_err_and(|error| error.contains("ambiguous application manifest"))); + } + + #[test] + fn runtime_resolver_rejects_operational_action() { + let temp = TempDir::new().expect("temp dir"); + let result = resolve_runtime_from("axum", Action::AuthStatus, temp.path(), None); + assert!(result.is_err_and(|error| error.contains("operational action"))); + } +} diff --git a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs index 5efa10fce..4f74d48ff 100644 --- a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs +++ b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs @@ -20,7 +20,11 @@ use edgezero_cli::args::{ use {{proj_core_mod}}::config::{{NameUpperCamel}}Config; #[derive(Parser, Debug)] -#[command(name = "{{proj_cli}}", version, about = "{{name}} edge CLI")] +#[command( + name = "{{proj_cli}}", + version, + about = "{{name}} edge CLI" +)] struct Args { #[command(subcommand)] cmd: Cmd, @@ -69,6 +73,12 @@ enum {{NameUpperCamel}}ConfigCmd { /// store and report changes. Exits 0 (no changes), 1 (changes with /// `--exit-code`), or 2 (unsupported / error). Diff(ConfigDiffArgs), + /// Reclaim orphaned chunk entries the config store leaked from prior + /// oversized pushes. Store-derived and untyped (no `{{NameUpperCamel}}Config`). + /// A dry-run by default; deletes only with `--yes` + an explicit + /// `--older-than` (YOUR assertion that nothing superseded within that + /// window is still being served, and no push is running). + Gc(ConfigGcArgs), /// Push `{{name}}.toml` as a single blob envelope to the /// adapter's config store. The blob carries every field verbatim /// (Model A -- `#[secret]` fields store the key NAME, @@ -78,12 +88,6 @@ enum {{NameUpperCamel}}ConfigCmd { /// Validate `edgezero.toml` and `{{name}}.toml` against the /// typed `{{NameUpperCamel}}Config` contract. Validate(ConfigValidateArgs), - /// Reclaim orphaned chunk entries the config store leaked from prior - /// oversized pushes. Store-derived and untyped (no `{{NameUpperCamel}}Config`). - /// A dry-run by default; deletes only with `--yes` + an explicit - /// `--older-than` (YOUR assertion that nothing superseded within that - /// window is still being served, and no push is running). - Gc(ConfigGcArgs), } fn main() { @@ -105,15 +109,15 @@ fn main() { Err(err) => Err(err), } } + // `gc` inspects the STORE, not the typed config, so it is not + // parameterised over `{{NameUpperCamel}}Config`. + Cmd::Config({{NameUpperCamel}}ConfigCmd::Gc(args)) => edgezero_cli::run_config_gc(&args), Cmd::Config({{NameUpperCamel}}ConfigCmd::Push(args)) => { edgezero_cli::run_config_push_typed::<{{NameUpperCamel}}Config>(&args) } Cmd::Config({{NameUpperCamel}}ConfigCmd::Validate(args)) => { edgezero_cli::run_config_validate_typed::<{{NameUpperCamel}}Config>(&args) } - // `gc` inspects the STORE, not the typed config, so it is not - // parameterised over `{{NameUpperCamel}}Config`. - Cmd::Config({{NameUpperCamel}}ConfigCmd::Gc(args)) => edgezero_cli::run_config_gc(&args), Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), Cmd::Healthcheck(args) => edgezero_cli::run_healthcheck(&args), Cmd::New(args) => edgezero_cli::run_new(&args), diff --git a/crates/edgezero-cli/src/templates/core/Cargo.toml.hbs b/crates/edgezero-cli/src/templates/core/Cargo.toml.hbs index 28b8b0c2c..e17a942e3 100644 --- a/crates/edgezero-cli/src/templates/core/Cargo.toml.hbs +++ b/crates/edgezero-cli/src/templates/core/Cargo.toml.hbs @@ -22,7 +22,5 @@ validator = { workspace = true } [dev-dependencies] async-trait = "0.1" -# `test-utils` exposes `edgezero_core::test_env`, the shared RAII env guards the -# handler tests below use to override `API_BASE_URL` without racing each other. -edgezero-core = { workspace = true, features = ["test-utils"] } +edgezero-core = { workspace = true } serde_json = "1" diff --git a/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs b/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs index d9a154920..bee6dca49 100644 --- a/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs +++ b/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs @@ -1,16 +1,41 @@ use bytes::Bytes; +use edgezero_core::ResponseEgressDeadline; use edgezero_core::action; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::extractor::{Headers, Json, Path}; -use edgezero_core::http::{self, Response, StatusCode, Uri}; -use edgezero_core::proxy::ProxyRequest; +use edgezero_core::http::{self, Method, Response, StatusCode, Uri}; +use edgezero_core::outbound::{ + OutboundBatchTermination, OutboundCachePolicy, OutboundRequest, OutboundSlotResult, +}; use edgezero_core::response::Text; use futures::{StreamExt as _, stream}; -use std::env; +use std::io::Error as IoError; +use std::num::NonZeroU64; +use std::time::Duration; const DEFAULT_PROXY_BASE: &str = "https://httpbin.org"; +const MAX_DECODER_BYTES: u64 = 0x0200_0000; +const MAX_BROTLI_WINDOW_BITS: u8 = 24; +const MAX_DECODED_RESPONSE_BYTES: u64 = 0x0010_0000; +const MAX_ENCODED_RESPONSE_BYTES: u64 = 0x0020_0000; +const MAX_FANOUT_INPUT_BYTES: usize = 0x4000; +const MAX_FANOUT_REQUESTS: usize = 8; +const MAX_FINAL_RESPONSE_BYTES: u64 = 0x0020_0000; +const MAX_OUTBOUND_REQUEST_BODY_BYTES: u64 = 0x0010_0000; +const MAX_RESPONSE_CHUNK_BYTES: u64 = 0x0001_0000; +const MAX_RESPONSE_HEADER_BYTES: u64 = 0x0001_0000; +const MAX_RESPONSE_HEADER_COUNT: u64 = 100; +const OUTBOUND_BATCH_BUDGET: Duration = Duration::from_secs(5); +const OUTBOUND_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const RESPONSE_EGRESS_BUDGET: Duration = Duration::from_secs(2); + +#[derive(serde::Serialize)] +struct AdmissionView { + grant_consumed_once: bool, + route_class: Option, +} #[derive(serde::Deserialize)] pub struct EchoBody { @@ -22,6 +47,26 @@ pub struct EchoParams { pub name: String, } +#[derive(serde::Deserialize)] +struct FanoutRequest { + paths: Vec, +} + +#[derive(serde::Serialize)] +struct FanoutSlot { + elapsed_ms: Option, + index: usize, + outcome: FanoutSlotOutcome, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "snake_case", tag = "kind")] +enum FanoutSlotOutcome { + Error { category: &'static str, status: u16 }, + Response { status: u16 }, + Unresolved, +} + #[derive(serde::Deserialize)] struct ProxyPath { #[serde(default)] @@ -65,23 +110,84 @@ pub async fn echo_json(Json(body): Json) -> Text { Text::new(format!("Hello, {}!", body.name)) } +#[action] +pub async fn admission(RequestContext(ctx): RequestContext) -> Result { + let route_class = ctx + .route_metadata() + .and_then(|metadata| metadata.class()) + .map(str::to_owned); + let grant_consumed_once = + ctx.take_ingress_grant().is_some() && ctx.take_ingress_grant().is_none(); + + json_response(&AdmissionView { + grant_consumed_once, + route_class, + }) +} + +#[action] +pub async fn fanout(RequestContext(ctx): RequestContext) -> Result { + let Some(client) = ctx.http_client() else { + return proxy_not_available_response(); + }; + let input: FanoutRequest = ctx.json_within(MAX_FANOUT_INPUT_BYTES).await?; + if input.paths.len() > MAX_FANOUT_REQUESTS { + return Err(EdgeError::validation(format!( + "fanout accepts at most {MAX_FANOUT_REQUESTS} paths" + ))); + } + + let base = ctx + .variable("API_BASE_URL") + .unwrap_or_else(|| DEFAULT_PROXY_BASE.to_owned()); + let deadline = ctx.monotonic_clock().deadline_after(OUTBOUND_BATCH_BUDGET); + let source_uri = Uri::from_static("/fanout"); + let requests = input + .paths + .into_iter() + .map(|path| { + let target = build_proxy_target(&base, &path, &source_uri)?; + Ok(outbound_policy(OutboundRequest::new(Method::GET, target)?).deadline(deadline)) + }) + .collect::, EdgeError>>()?; + let slots = client + .send_all_until(requests, deadline) + .await + .map_err(|failure| failure.error)?; + let termination = slots.termination; + let output = slots + .slots + .into_iter() + .enumerate() + .map(|(index, slot)| fanout_slot(index, slot, termination)) + .collect::, EdgeError>>()?; + + let mut response = json_response(&output)?; + response + .extensions_mut() + .insert(ResponseEgressDeadline::after(RESPONSE_EGRESS_BUDGET)); + Ok(response) +} + #[action] pub async fn proxy_demo(RequestContext(ctx): RequestContext) -> Result { let params: ProxyPath = ctx.path()?; - let proxy_handle = ctx.proxy_handle(); - let request = ctx.into_request(); - let target = build_proxy_target(¶ms.rest, request.uri())?; - let proxy_request = ProxyRequest::from_request(request, target); - - if let Some(handle) = proxy_handle { - handle.forward(proxy_request).await + let http_client = ctx.http_client(); + let base = ctx + .variable("API_BASE_URL") + .unwrap_or_else(|| DEFAULT_PROXY_BASE.to_owned()); + let request = ctx.into_request()?; + let target = build_proxy_target(&base, ¶ms.rest, request.uri())?; + let outbound_request = outbound_policy(OutboundRequest::from_request(request, target)?); + + if let Some(client) = http_client { + client.send(outbound_request).await?.into_response() } else { proxy_not_available_response() } } -fn build_proxy_target(rest: &str, original_uri: &Uri) -> Result { - let base = env::var("API_BASE_URL").unwrap_or_else(|_| DEFAULT_PROXY_BASE.to_owned()); +fn build_proxy_target(base: &str, rest: &str, original_uri: &Uri) -> Result { let mut target = base.trim_end_matches('/').to_owned(); let trimmed_rest = rest.trim_start_matches('/'); if !trimmed_rest.is_empty() { @@ -101,10 +207,70 @@ fn build_proxy_target(rest: &str, original_uri: &Uri) -> Result .map_err(|err| EdgeError::bad_request(format!("invalid proxy target URI: {err}"))) } +fn error_category(status: StatusCode) -> &'static str { + match status { + StatusCode::BAD_REQUEST => "bad_request", + StatusCode::BAD_GATEWAY => "bad_gateway", + StatusCode::GATEWAY_TIMEOUT => "gateway_timeout", + StatusCode::REQUEST_TIMEOUT => "request_timeout", + StatusCode::UNPROCESSABLE_ENTITY => "validation", + _ => "error", + } +} + +fn fanout_slot( + index: usize, + slot_result: Option, + termination: OutboundBatchTermination, +) -> Result { + match slot_result { + Some(slot) => { + let outcome = match slot.outcome { + Ok(response) => FanoutSlotOutcome::Response { + status: response.status().as_u16(), + }, + Err(error) => FanoutSlotOutcome::Error { + category: error_category(error.status()), + status: error.status().as_u16(), + }, + }; + Ok(FanoutSlot { + elapsed_ms: Some(slot.elapsed.as_millis()), + index, + outcome, + }) + } + None if termination == OutboundBatchTermination::Cutoff => Ok(FanoutSlot { + elapsed_ms: None, + index, + outcome: FanoutSlotOutcome::Unresolved, + }), + None if termination == OutboundBatchTermination::Completed => Err(EdgeError::internal( + IoError::other("completed outbound batch contained an unresolved slot"), + )), + None => Err(EdgeError::internal(IoError::other( + "outbound batch contained an unresolved slot for an unsupported termination reason", + ))), + } +} + +fn outbound_policy(request: OutboundRequest) -> OutboundRequest { + request + .cache_policy(OutboundCachePolicy::Bypass) + .max_decoder_bytes(MAX_DECODER_BYTES) + .max_brotli_window_bits(MAX_BROTLI_WINDOW_BITS) + .max_decoded_response_bytes(MAX_DECODED_RESPONSE_BYTES) + .max_encoded_response_bytes(MAX_ENCODED_RESPONSE_BYTES) + .max_request_body_bytes(MAX_OUTBOUND_REQUEST_BODY_BYTES) + .max_response_bytes(MAX_FINAL_RESPONSE_BYTES) + .max_chunk_bytes(NonZeroU64::new(MAX_RESPONSE_CHUNK_BYTES).unwrap_or(NonZeroU64::MIN)) + .max_response_header_bytes(MAX_RESPONSE_HEADER_BYTES) + .max_response_header_count(MAX_RESPONSE_HEADER_COUNT) + .timeout(OUTBOUND_REQUEST_TIMEOUT) +} + fn proxy_not_available_response() -> Result { - let body = Body::text( - "proxy example is not enabled for this adapter build; enable a proxy-capable adapter", - ); + let body = Body::text("outbound HTTP is not enabled for this adapter build"); http::response_builder() .status(StatusCode::NOT_IMPLEMENTED) .header("content-type", "text/plain; charset=utf-8") @@ -112,6 +278,18 @@ fn proxy_not_available_response() -> Result { .map_err(EdgeError::internal) } +fn json_response(value: &T) -> Result +where + T: serde::Serialize, +{ + let body = Body::json(value).map_err(EdgeError::internal)?; + http::response_builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(body) + .map_err(EdgeError::internal) +} + // `cfg.api_token` is the RESOLVED secret value (not the // key name). The framework's secret walk populated it at // extract time; the handler uses it directly. No @@ -136,23 +314,133 @@ mod tests { use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::http::header::{HeaderName, HeaderValue}; - use edgezero_core::http::{Method, StatusCode, Uri, request_builder}; + use edgezero_core::http::{HeaderMap, Method, StatusCode, Uri, request_builder}; + use edgezero_core::outbound::{ + HttpClient, OutboundBatch, OutboundBatchItem, OutboundHttpClient, OutboundRequestParts, + OutboundResponse, OutboundSlotResult, ResponseMode, + }; use edgezero_core::params::PathParams; - use edgezero_core::proxy::{ProxyClient, ProxyHandle, ProxyResponse}; use edgezero_core::response::IntoResponse as _; - use edgezero_core::test_env::{EnvOverride, env_lock}; + use edgezero_core::{ + BudgetSource, Deadline, MonotonicClock, MonotonicInstant, OutboundBatchDriverEvent, + }; use futures::executor::block_on; use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + use std::time::Duration; - struct TestProxyClient; + #[derive(Clone, Default)] + struct TestOutboundClient { + clock: MonotonicClock, + } #[async_trait(?Send)] - impl ProxyClient for TestProxyClient { - async fn send(&self, request: ProxyRequest) -> Result { - let (_method, uri, _headers, _body, _) = request.into_parts(); - assert!(uri.to_string().contains("status/201")); - Ok(ProxyResponse::new(StatusCode::CREATED, Body::empty())) + impl OutboundHttpClient for TestOutboundClient { + async fn send(&self, request: OutboundRequest) -> Result { + let parts = request.into_parts(); + assert_eq!(parts.method, Method::GET); + assert!(parts.uri.to_string().contains("status/201")); + assert!(parts.deadline.is_none()); + assert_outbound_policy(&parts); + response_for(parts, self.clock.clone()) + } + + fn start_batch_until( + &self, + requests: Vec, + observation_cutoff: Deadline, + ) -> OutboundBatch { + let mut observed_deadline = None; + let slot_count = requests.len(); + let mut results = Vec::with_capacity(slot_count); + for (index, request) in requests.into_iter().enumerate() { + let parts = request.into_parts(); + assert_outbound_policy(&parts); + let slot_deadline = parts.deadline.expect("batch deadline"); + if let Some(expected) = observed_deadline { + assert_eq!(slot_deadline.instant(), expected); + } else { + observed_deadline = Some(slot_deadline.instant()); + } + let elapsed = Duration::from_millis( + u64::try_from(index).expect("slot index").saturating_add(1), + ); + results.push(OutboundBatchItem::new( + index, + OutboundSlotResult::new(elapsed, response_for(parts, self.clock.clone())), + )); + } + if let Some(batch_deadline) = observed_deadline { + assert_eq!(batch_deadline, observation_cutoff.instant()); + } + OutboundBatch::from_driver( + slot_count, + stream::iter(results.into_iter().map(OutboundBatchDriverEvent::Item)), + ) + } + } + + fn assert_outbound_policy(parts: &OutboundRequestParts) { + assert_eq!(parts.cache_policy, OutboundCachePolicy::Bypass); + assert_eq!(parts.timeout, Some(OUTBOUND_REQUEST_TIMEOUT)); + assert_eq!( + parts.max_request_body_bytes, + MAX_OUTBOUND_REQUEST_BODY_BYTES + ); + assert_eq!( + parts.max_encoded_response_bytes, + Some(MAX_ENCODED_RESPONSE_BYTES) + ); + assert_eq!( + parts.max_decoded_response_bytes, + Some(MAX_DECODED_RESPONSE_BYTES) + ); + assert_eq!( + parts.response_mode, + ResponseMode::Buffered { + max_bytes: MAX_FINAL_RESPONSE_BYTES, + } + ); + assert_eq!( + parts.max_response_header_bytes, + Some(MAX_RESPONSE_HEADER_BYTES) + ); + assert_eq!( + parts.max_response_header_count, + Some(MAX_RESPONSE_HEADER_COUNT) + ); + assert_eq!( + parts.max_chunk_bytes, + Some(NonZeroU64::new(MAX_RESPONSE_CHUNK_BYTES).unwrap_or(NonZeroU64::MIN)) + ); + assert_eq!(parts.max_brotli_window_bits, MAX_BROTLI_WINDOW_BITS); + assert_eq!(parts.max_decoder_bytes, MAX_DECODER_BYTES); + } + + fn response_for( + parts: OutboundRequestParts, + clock: MonotonicClock, + ) -> Result { + if parts.uri.path() == "/fail" { + return Err(EdgeError::gateway_timeout_caused( + "provider URL https://user:token@example.invalid", + BudgetSource::RequestDeadline, + )); } + + let status = match parts.uri.path() { + "/status/201" => StatusCode::CREATED, + "/status/204" => StatusCode::NO_CONTENT, + _ => StatusCode::OK, + }; + Ok(OutboundResponse::new( + parts.method, + status, + HeaderMap::new(), + Body::empty(), + clock, + edgezero_core::ContentTypeProvenance::Unknown, + )) } #[test] @@ -216,7 +504,7 @@ mod tests { #[test] fn echo_json_formats_payload() { - let ctx = context_with_json("/echo", r#"{"name":"Edge"}"#); + let ctx = context_with_json("/echo", &serde_json::json!({"name": "Edge"}).to_string()); let response = block_on(echo_json(ctx)) .expect("handler ok") .into_response() @@ -227,28 +515,76 @@ mod tests { #[test] fn build_proxy_target_merges_segments_and_query() { - let _lock = env_lock().lock().expect("env lock"); - let _env = EnvOverride::set("API_BASE_URL", "https://example.com/api"); let original = Uri::from_static("/proxy/status?foo=bar"); - let target = build_proxy_target("status/200", &original).expect("target uri"); + let target = build_proxy_target("https://example.com/api", "status/200", &original) + .expect("target uri"); assert_eq!( target.to_string(), "https://example.com/api/status/200?foo=bar" ); } + #[test] + fn fanout_reports_positional_elapsed_and_typed_outcomes() { + let ctx = fanout_context( + &serde_json::json!({"paths": ["status/200", "status/204", "fail"]}).to_string(), + ); + let response = block_on(fanout(ctx)).expect("fanout response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], "application/json"); + assert!( + response + .extensions() + .get::() + .is_some() + ); + assert!( + !String::from_utf8_lossy(response.body().as_bytes().expect("buffered")) + .contains("token@example.invalid") + ); + + let payload: serde_json::Value = response.body().to_json().expect("json"); + assert_eq!(payload[0]["index"], 0_i64); + assert_eq!(payload[0]["elapsed_ms"], 1_i64); + assert_eq!(payload[0]["outcome"]["kind"], "response"); + assert_eq!(payload[0]["outcome"]["status"], 200_i64); + assert_eq!(payload[1]["index"], 1_i64); + assert_eq!(payload[1]["elapsed_ms"], 2_i64); + assert_eq!(payload[1]["outcome"]["status"], 204_i64); + assert_eq!(payload[2]["index"], 2_i64); + assert_eq!(payload[2]["elapsed_ms"], 3_i64); + assert_eq!(payload[2]["outcome"]["kind"], "error"); + assert_eq!(payload[2]["outcome"]["category"], "gateway_timeout"); + assert_eq!(payload[2]["outcome"]["status"], 504_i64); + } + + #[test] + fn fanout_with_empty_input_returns_an_empty_array() { + let ctx = fanout_context(&serde_json::json!({"paths": []}).to_string()); + let response = block_on(fanout(ctx)).expect("fanout response"); + let payload: serde_json::Value = response.body().to_json().expect("json"); + assert_eq!(payload, serde_json::json!([])); + } + + #[test] + fn fanout_rejects_more_than_eight_paths() { + let ctx = fanout_context( + &serde_json::json!({"paths": ["1", "2", "3", "4", "5", "6", "7", "8", "9"]}) + .to_string(), + ); + let error = block_on(fanout(ctx)).expect_err("oversized fanout must fail"); + assert_eq!(error.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + #[test] fn proxy_demo_without_handle_returns_placeholder() { - let _lock = env_lock().lock().expect("env lock"); let ctx = context_with_params("/proxy/status/200", &[("rest", "status/200")]); let response = block_on(proxy_demo(ctx)).expect("response"); assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); } #[test] - fn proxy_demo_uses_injected_handle() { - let _lock = env_lock().lock().expect("env lock"); - + fn generated_outbound_http_smoke() { let mut request = request_builder() .method(Method::GET) .uri("/proxy/status/201") @@ -256,7 +592,7 @@ mod tests { .expect("request"); request .extensions_mut() - .insert(ProxyHandle::with_client(TestProxyClient)); + .insert(HttpClient::with_client(TestOutboundClient::default())); let mut params = HashMap::new(); params.insert("rest".to_owned(), "status/201".to_owned()); @@ -266,6 +602,27 @@ mod tests { assert_eq!(response.status(), StatusCode::CREATED); } + #[test] + fn generated_outbound_client_retains_its_clock_in_responses() { + let start = MonotonicInstant::now(); + let observed = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&observed); + let client = TestOutboundClient { + clock: MonotonicClock::new(move || *clock_now.lock().expect("clock lock")), + }; + let request = outbound_policy( + OutboundRequest::get("https://example.com/status/201").expect("request"), + ); + let response = block_on(client.send(request)).expect("response"); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + *observed.lock().expect("clock lock") = deadline; + + let error = block_on(response.into_bytes_bounded_until(1, Deadline::at_instant(deadline))) + .expect_err("retained clock reached deadline"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + fn empty_context(path: &str) -> RequestContext { let request = request_builder() .method(Method::GET) @@ -275,6 +632,18 @@ mod tests { RequestContext::new(request, PathParams::default()) } + fn fanout_context(json: &str) -> RequestContext { + let mut request = request_builder() + .method(Method::POST) + .uri("/fanout") + .body(Body::from(json)) + .expect("request"); + request + .extensions_mut() + .insert(HttpClient::with_client(TestOutboundClient::default())); + RequestContext::new(request, PathParams::default()) + } + fn context_with_params(path: &str, params: &[(&str, &str)]) -> RequestContext { let request = request_builder() .method(Method::GET) diff --git a/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs b/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs index dee67bfee..a5e928674 100644 --- a/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs +++ b/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs @@ -1,4 +1,433 @@ pub mod config; mod handlers; -edgezero_core::app!("../../edgezero.toml"); +use std::error::Error; +use std::fmt; +use std::time::Duration; + +use edgezero_core::app::App as EdgeZeroApp; +use edgezero_core::http::StatusCode; +use edgezero_core::{ + AdmissionDecision, BufferedIngressResponse, ConfigExtractionLimits, EdgeError, IngressGrant, + MemoryCeiling, MemoryCeilingScope, MemoryEnvelope, MemoryEnvelopeValidation, + MemoryEnvelopeValidationError, MemoryResource, PlatformMetadata, ResponseEgressCompletion, + RouteResolution, +}; + +const DEFAULT_INGRESS_READ_BUDGET: Duration = Duration::from_secs(30); +const FALLBACK_INGRESS_BODY_BYTES: usize = 4 * 1024; +const FALLBACK_INGRESS_READ_BUDGET: Duration = Duration::from_secs(5); +const OUTBOUND_INGRESS_READ_BUDGET: Duration = Duration::from_secs(10); +const EXPECTED_FIXED_MEMORY_BYTES: u64 = 4 * 1024 * 1024; +const EXPECTED_PER_LIVE_REQUEST_BYTES: u64 = 1024 * 1024; +const EXPECTED_SEPARATE_STACK_BYTES: u64 = 256 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PlatformMemoryStartupError { + Exceeds { + available_bytes: u64, + required_bytes: u64, + resource: MemoryResource, + }, + UnsupportedValidationOutcome, + Validation { + source: MemoryEnvelopeValidationError, + }, +} + +impl fmt::Display for PlatformMemoryStartupError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Self::Exceeds { + available_bytes, + required_bytes, + resource, + } => { + let resource_label = match resource { + MemoryResource::Primary => "primary memory", + MemoryResource::SeparateStack => "separate stack", + _ => "unrecognized memory resource", + }; + write!( + f, + "application memory envelope exceeds {resource_label}: required {required_bytes} bytes, available {available_bytes} bytes" + ) + } + Self::UnsupportedValidationOutcome => { + f.write_str("unsupported platform memory validation outcome") + } + Self::Validation { source } => source.fmt(f), + } + } +} + +#[expect( + clippy::missing_trait_methods, + reason = "the typed validation source is the only nested startup error" +)] +impl Error for PlatformMemoryStartupError { + #[expect( + clippy::pattern_type_mismatch, + reason = "match ergonomics preserve the borrowed validation source" + )] + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Validation { source } => Some(source), + Self::Exceeds { .. } | Self::UnsupportedValidationOutcome => None, + } + } +} + +/// Installs request-lifecycle policy before any adapter begins polling a body. +fn configure_app(app: &mut EdgeZeroApp) -> Result<(), EdgeError> { + platform_memory_startup(app.platform()).map_err(EdgeError::internal)?; + app.set_config_extraction_limits(ConfigExtractionLimits::default())?; + app.set_ingress_admission_policy(|head| match head.route_resolution().clone() { + RouteResolution::Matched(metadata) => { + let read_budget = if metadata.class() == Some("outbound") { + OUTBOUND_INGRESS_READ_BUDGET + } else { + DEFAULT_INGRESS_READ_BUDGET + }; + AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(read_budget), + } + } + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound => { + AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + max_body_bytes: FALLBACK_INGRESS_BODY_BYTES, + read_deadline: head.read_deadline_after(FALLBACK_INGRESS_READ_BUDGET), + on_exceeded: BufferedIngressResponse::text( + StatusCode::BAD_REQUEST, + "request body too large\n", + ), + on_timeout: BufferedIngressResponse::text( + StatusCode::REQUEST_TIMEOUT, + "request timeout\n", + ), + } + } + _ => AdmissionDecision::Abort, + }); + Ok(()) +} + +fn platform_memory_validation( + platform: PlatformMetadata, +) -> Result { + let known_ceiling = platform.memory_ceiling().value(); + let scope = known_ceiling.map_or(MemoryCeilingScope::PerExecution, MemoryCeiling::scope); + let (fixed_bytes, separate_stack_bytes) = + match known_ceiling.and_then(MemoryCeiling::stack_bytes) { + Some(_) => ( + EXPECTED_FIXED_MEMORY_BYTES, + Some(EXPECTED_SEPARATE_STACK_BYTES), + ), + None => ( + EXPECTED_FIXED_MEMORY_BYTES + EXPECTED_SEPARATE_STACK_BYTES, + None, + ), + }; + let envelope = MemoryEnvelope::new( + scope, + fixed_bytes, + EXPECTED_PER_LIVE_REQUEST_BYTES, + separate_stack_bytes, + ); + platform.validate_memory_envelope(envelope) +} + +fn platform_memory_startup(platform: PlatformMetadata) -> Result<(), PlatformMemoryStartupError> { + let validation = platform_memory_validation(platform) + .map_err(|source| PlatformMemoryStartupError::Validation { source })?; + match validation { + MemoryEnvelopeValidation::Fits { .. } => Ok(()), + MemoryEnvelopeValidation::Indeterminate { .. } => { + // Unknown facts remain indeterminate; startup may continue without claiming `Fits` + // or adding an application concurrency gate. + Ok(()) + } + MemoryEnvelopeValidation::Exceeds { + available_bytes, + required_bytes, + resource, + } => Err(PlatformMemoryStartupError::Exceeds { + available_bytes, + required_bytes, + resource, + }), + _ => Err(PlatformMemoryStartupError::UnsupportedValidationOutcome), + } +} + +edgezero_core::app!("../../edgezero.toml", configure = crate::configure_app); + +#[cfg(test)] +mod lifecycle_tests { + use std::num::NonZeroU32; + use std::time::Duration; + + use edgezero_core::ResponseEgressEnvelope; + use edgezero_core::app::App as EdgeZeroApp; + use edgezero_core::body::Body; + use edgezero_core::http::{HeaderMap, Method, Response, Version, request_builder}; + use edgezero_core::ingress::{IngressBeginOutcome, IngressHeadParts}; + use edgezero_core::router::RouteResolution; + use edgezero_core::time::MonotonicInstant; + use futures::executor::block_on; + + #[test] + fn manifest_route_classes_reach_route_resolution() { + let resolved = crate::build_router().resolve(&Method::GET, "/proxy/status/200"); + let RouteResolution::Matched(metadata) = resolved.resolution().clone() else { + panic!("proxy route must resolve"); + }; + assert_eq!(metadata.class(), Some("outbound")); + } + + #[test] + fn platform_memory_validation_preserves_unknown_facts() { + let validation = + super::platform_memory_validation(edgezero_core::PlatformMetadata::default()) + .expect("valid generated envelope"); + + let edgezero_core::MemoryEnvelopeValidation::Indeterminate { reasons, .. } = validation + else { + panic!("unknown platform facts must prevent complete validation"); + }; + assert_eq!(reasons.len(), 3); + } + + fn known_platform( + ceiling: edgezero_core::MemoryCeiling, + population: NonZeroU32, + ) -> edgezero_core::PlatformMetadata { + let source = edgezero_core::PlatformResourceSource::PlatformLimit { + provider: "test platform", + }; + edgezero_core::PlatformMetadata::new( + edgezero_core::PlatformFact::known(ceiling, source), + edgezero_core::PlatformFact::known( + edgezero_core::InboundRequestPopulationBound::new(population), + source, + ), + edgezero_core::PlatformFact::known( + edgezero_core::HostIngressMemoryAccounting::OutsideCeiling, + source, + ), + ) + } + + #[test] + fn platform_memory_validation_accounts_for_both_stack_shapes() { + let primary_without_separate_stack = super::EXPECTED_FIXED_MEMORY_BYTES + + super::EXPECTED_SEPARATE_STACK_BYTES + + super::EXPECTED_PER_LIVE_REQUEST_BYTES; + let without_separate_stack = super::platform_memory_validation(known_platform( + edgezero_core::MemoryCeiling::new( + primary_without_separate_stack, + edgezero_core::MemoryCeilingScope::PerExecution, + None, + ), + NonZeroU32::MIN, + )) + .expect("valid envelope without separate stack"); + assert!(matches!( + without_separate_stack, + edgezero_core::MemoryEnvelopeValidation::Fits { + required_primary_bytes, + required_separate_stack_bytes: None, + .. + } if required_primary_bytes == primary_without_separate_stack + )); + + let primary_with_separate_stack = + super::EXPECTED_FIXED_MEMORY_BYTES + super::EXPECTED_PER_LIVE_REQUEST_BYTES; + let with_separate_stack = super::platform_memory_validation(known_platform( + edgezero_core::MemoryCeiling::new( + primary_with_separate_stack, + edgezero_core::MemoryCeilingScope::PerExecution, + Some(super::EXPECTED_SEPARATE_STACK_BYTES), + ), + NonZeroU32::MIN, + )) + .expect("valid envelope with separate stack"); + assert!(matches!( + with_separate_stack, + edgezero_core::MemoryEnvelopeValidation::Fits { + required_primary_bytes, + required_separate_stack_bytes: Some(required_stack_bytes), + .. + } if required_primary_bytes == primary_with_separate_stack + && required_stack_bytes == super::EXPECTED_SEPARATE_STACK_BYTES + )); + } + + #[test] + fn platform_memory_startup_accepts_fit() { + let required_primary = + super::EXPECTED_FIXED_MEMORY_BYTES + super::EXPECTED_PER_LIVE_REQUEST_BYTES; + let platform = known_platform( + edgezero_core::MemoryCeiling::new( + required_primary, + edgezero_core::MemoryCeilingScope::PerExecution, + Some(super::EXPECTED_SEPARATE_STACK_BYTES), + ), + NonZeroU32::MIN, + ); + + assert_eq!(super::platform_memory_startup(platform), Ok(())); + } + + #[test] + fn platform_memory_startup_preserves_primary_excess() { + let platform = known_platform( + edgezero_core::MemoryCeiling::new( + 1, + edgezero_core::MemoryCeilingScope::PerExecution, + None, + ), + NonZeroU32::MIN, + ); + + assert!(matches!( + super::platform_memory_startup(platform), + Err(super::PlatformMemoryStartupError::Exceeds { + available_bytes: 1, + required_bytes, + resource: edgezero_core::MemoryResource::Primary, + }) if required_bytes == super::EXPECTED_FIXED_MEMORY_BYTES + + super::EXPECTED_SEPARATE_STACK_BYTES + + super::EXPECTED_PER_LIVE_REQUEST_BYTES + )); + } + + #[test] + fn platform_memory_startup_preserves_stack_excess() { + let platform = known_platform( + edgezero_core::MemoryCeiling::new( + super::EXPECTED_FIXED_MEMORY_BYTES + super::EXPECTED_PER_LIVE_REQUEST_BYTES, + edgezero_core::MemoryCeilingScope::PerExecution, + Some(1), + ), + NonZeroU32::MIN, + ); + + assert_eq!( + super::platform_memory_startup(platform), + Err(super::PlatformMemoryStartupError::Exceeds { + available_bytes: 1, + required_bytes: super::EXPECTED_SEPARATE_STACK_BYTES, + resource: edgezero_core::MemoryResource::SeparateStack, + }) + ); + } + + #[test] + fn platform_memory_startup_keeps_indeterminate_distinct_from_fit() { + assert_eq!( + super::platform_memory_startup(edgezero_core::PlatformMetadata::default()), + Ok(()) + ); + } + + #[test] + fn platform_memory_startup_preserves_validation_error() { + let platform = known_platform( + edgezero_core::MemoryCeiling::new( + u64::MAX, + edgezero_core::MemoryCeilingScope::PerExecution, + Some(super::EXPECTED_SEPARATE_STACK_BYTES), + ), + NonZeroU32::new(2).expect("non-zero population"), + ); + + assert!(matches!( + super::platform_memory_startup(platform), + Err(super::PlatformMemoryStartupError::Validation { + source: + edgezero_core::MemoryEnvelopeValidationError::NonUnitPerExecutionPopulation { + max_live_requests, + }, + }) if max_live_requests.get() == 2 + )); + } + + #[test] + fn configured_admission_uses_finite_class_aware_deadlines() { + let app = EdgeZeroApp::build::(edgezero_core::PlatformMetadata::default()) + .expect("configured app"); + let start = MonotonicInstant::now(); + + let outbound = begin_ingress(&app, "/proxy/status/200", start); + assert_eq!( + outbound.read_deadline().instant(), + start + .checked_add(Duration::from_secs(10)) + .expect("deadline") + ); + + let health = begin_ingress(&app, "/", start); + assert_eq!( + health.read_deadline().instant(), + start + .checked_add(Duration::from_secs(30)) + .expect("deadline") + ); + + let fallback = begin_ingress(&app, "/missing", start); + assert_eq!( + fallback.read_deadline().instant(), + start.checked_add(Duration::from_secs(5)).expect("deadline") + ); + } + + #[test] + fn admission_handler_consumes_the_grant_once() { + let app = EdgeZeroApp::build::(edgezero_core::PlatformMetadata::default()) + .expect("configured app"); + let start = MonotonicInstant::now(); + let prepared = begin_ingress(&app, "/admission", start); + let request = request_builder() + .method(Method::GET) + .uri("/admission") + .body(Body::empty()) + .expect("request"); + + let response = complete_envelope(block_on(app.dispatch_admitted(prepared, request))); + let payload: serde_json::Value = response.body().to_json().expect("json"); + assert_eq!(payload["route_class"], "diagnostic"); + assert_eq!(payload["grant_consumed_once"], true); + } + + fn complete_envelope(envelope: ResponseEgressEnvelope) -> Response { + let Ok((prepared, _, mut attempt, clock)) = envelope.begin() else { + panic!("response egress begin"); + }; + assert!(attempt.begin_writing()); + assert!(attempt.complete(clock.now())); + prepared.into_response() + } + + fn begin_ingress( + app: &EdgeZeroApp, + path: &str, + start: MonotonicInstant, + ) -> edgezero_core::PreparedIngress { + let head = IngressHeadParts::new( + Method::GET, + path.parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + match app.begin_ingress(head, start).expect("begin ingress") { + IngressBeginOutcome::Admitted(prepared) => prepared, + IngressBeginOutcome::Refused(_) => panic!("demo policy must admit request"), + IngressBeginOutcome::Aborted | _ => panic!("unknown admission outcome"), + } + } +} diff --git a/crates/edgezero-cli/src/templates/root/README.md.hbs b/crates/edgezero-cli/src/templates/root/README.md.hbs index 402d5c4f4..53bf24193 100644 --- a/crates/edgezero-cli/src/templates/root/README.md.hbs +++ b/crates/edgezero-cli/src/templates/root/README.md.hbs @@ -13,15 +13,54 @@ This workspace demonstrates a multi-target EdgeZero app. - `GET /headers` - prints the incoming `user-agent` - `GET /stream` - emits a short streamed response body - `POST /echo` - echoes JSON payloads -- `GET|POST /proxy/{*rest}` - forwards requests to `API_BASE_URL`, falling back to a 501 when the active adapter does not expose a proxy client +- `GET /admission` - confirms route-aware admission and one-time grant consumption +- `GET|POST /proxy/{*rest}` - sends the request to `API_BASE_URL`, falling back to a 501 when the active adapter does not inject an outbound HTTP client +- `POST /fanout` - attempts up to eight relative paths before harvest and returns index-aligned status, error category, unresolved state, and per-slot elapsed milliseconds + +## Outbound HTTP + +The generated `edgezero.toml` declares `outbound-http` as optional and grants +HTTPS destinations only. An optional capability accepts documented behavioral +deviations and may fail at runtime when a deployment prerequisite has not been +verified. If application behavior depends on successful outbound requests, +promote `outbound-http` to `required` and choose an adapter and deployment whose +capability check passes. See the +[capability guide](https://stackpop.github.io/edgezero/guide/capabilities) for +the current support matrix and deployment prerequisites. + +Both outbound examples bypass adapter-managed caches and set a 5-second timeout, a 1 MiB request-body cap, +independent 2 MiB encoded / 1 MiB decoded / 2 MiB final-buffer response caps, +64 KiB / 100-field response-header caps, and explicit Brotli window and decoder +memory limits. `/fanout` also gives every slot one shared absolute deadline and +returns the adapter-recorded elapsed time for each terminal slot. This elapsed value +includes EdgeZero and provider processing rather than representing pure wire +RTT. Cross-slot timing isolation is adapter-dependent; deployments that require +it must declare `outbound-batch-slot-isolation` as a required capability. Applications +that retain partial results after cancellation should also require the appropriate +`outbound-batch-completion-order` and `outbound-batch-cancellation` support levels. ## Dev {{{readme_adapter_dev}}} +## Logging + +Axum and Fastly entrypoints delegate logging to the reusable adapter runners; +do not initialize a second CLI logger before a runtime runner. Ordinary levels +come from `EDGEZERO__LOGGING__LEVEL` (Fastly reads its runtime Config Store), with +`Info` as the absent/invalid fallback. Managed boot warnings use `edgezero::boot` +at `Warn`, independently of ordinary `Error` or `Off`. + +Cloudflare and Spin logger initializers are no-ops, not Rust facade backends. +To own logging on any target, set `owns_logging = true` in `app!`, install your +backend before entering the runner, and configure both backend and facade filters. +Reuse `edgezero_core::resolve_logging_level(&EnvConfig)`, `BOOT_LOG_TARGET`, and +`BOOT_LOG_LEVEL`; raising only `log::set_max_level` cannot restore backend-filtered +messages. No logging implementation is duplicated in the generated entrypoints. + ## Configuration -Environment variables are declared in `edgezero.toml`. Set `API_BASE_URL` to the upstream origin you want `/proxy/...` to target. Adapter-specific secrets are opt-in: uncomment the `#[secret]` field in `crates/{{proj_core}}/src/config.rs` and the matching `[stores.secrets]` block in `edgezero.toml`, then provide the secret value through the wired backend (Cloudflare Worker secret, Fastly Secret Store, Spin secret variable, ...). The Axum dev server reads secret key `demo_api_token` from the environment variable `EDGEZERO__SECRETS__DEMO_API_TOKEN`, which is the `EDGEZERO__SECRETS__` prefix plus the key in uppercase. +Environment variables are declared in `edgezero.toml`. Set `API_BASE_URL` to the upstream origin you want `/proxy/...` to target; the handlers read it through `RequestContext::variable`, including from the declared Cloudflare Worker binding. Adapter-specific secrets are opt-in: uncomment the `#[secret]` field in `crates/{{proj_core}}/src/config.rs` and the matching `[stores.secrets]` block in `edgezero.toml`, then provide the secret value through the wired backend (Cloudflare Worker secret, Fastly Secret Store, Spin secret variable, ...). The Axum dev server reads secret key `demo_api_token` from the environment variable `EDGEZERO__SECRETS__DEMO_API_TOKEN`, which is the `EDGEZERO__SECRETS__` prefix plus the key in uppercase. ### Typed app config (`{{name}}.toml`) @@ -50,6 +89,11 @@ cargo run -p {{proj_cli}} -- config diff --adapter --format json --exit-c Uncomment `[stores.config]` in `edgezero.toml` (and the matching adapter binding in the per-adapter manifest) before running `config push`. +For Axum, push before starting the server or restart after updating config: the +runner loads bounded immutable snapshots before listener binding. Configure allocation +budgets through `EDGEZERO__ADAPTER__CONFIG_STORE__*`; invalid budgets fail startup. +Raw custom store implementations return shared `edgezero_core::ConfigValue`, while +the typed config extractor and generated handlers require no manual store reads. Use `--key ` for per-environment overrides (staging / canary) on adapters that support custom runtime keys: diff --git a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs index c8e53cfab..388d50745 100644 --- a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs +++ b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs @@ -7,12 +7,19 @@ version = "0.1.0" kind = "http" entry = "crates/{{proj_core}}" +[capabilities] +optional = ["outbound-http"] + +[capabilities.outbound] +hosts = ["https://*:*"] + [[triggers.http]] id = "root" path = "/" methods = ["GET"] handler = "{{proj_core_mod}}::handlers::root" adapters = [{{{adapter_list}}}] +class = "health" description = "Default health-check endpoint" [[triggers.http]] @@ -44,6 +51,14 @@ methods = ["POST"] handler = "{{proj_core_mod}}::handlers::echo_json" adapters = [{{{adapter_list}}}] +[[triggers.http]] +id = "admission" +path = "/admission" +methods = ["GET"] +handler = "{{proj_core_mod}}::handlers::admission" +adapters = [{{{adapter_list}}}] +class = "diagnostic" +description = "Confirms route-aware admission and one-time grant consumption" [[triggers.http]] id = "proxy_demo" @@ -51,6 +66,16 @@ path = "/proxy/{*rest}" methods = ["GET", "POST"] handler = "{{proj_core_mod}}::handlers::proxy_demo" adapters = [{{{adapter_list}}}] +class = "outbound" + +[[triggers.http]] +id = "fanout" +path = "/fanout" +methods = ["POST"] +handler = "{{proj_core_mod}}::handlers::fanout" +adapters = [{{{adapter_list}}}] +class = "outbound" +description = "Concurrent positional outbound batch with per-slot elapsed time" # -- Introspection routes ------------------------------------------------------ @@ -60,6 +85,7 @@ path = "/_{{name}}/manifest" methods = ["GET"] handler = "edgezero_core::introspection::manifest" adapters = [{{{adapter_list}}}] +class = "diagnostic" description = "App manifest as JSON" [[triggers.http]] @@ -68,6 +94,7 @@ path = "/_{{name}}/config" methods = ["GET"] handler = "edgezero_core::introspection::config" adapters = [{{{adapter_list}}}] +class = "diagnostic" description = "Effective app config (secret-safe)" [[triggers.http]] @@ -76,6 +103,7 @@ path = "/_{{name}}/routes" methods = ["GET"] handler = "edgezero_core::introspection::routes" adapters = [{{{adapter_list}}}] +class = "diagnostic" description = "Registered route table" # -- Stores ---------------------------------------------------------------- @@ -104,18 +132,40 @@ description = "Registered route table" # [stores.secrets] # ids = ["default"] -# [environment] +[environment] + +[[environment.variables]] +name = "API_BASE_URL" +description = "Base URL for upstream API calls" +env = "API_BASE_URL" + +# Axum enforces finite pre-admission HTTP/1 limits by default. To override a +# limit through the CLI, uncomment an adapter-filtered default below. Parent +# environment values take precedence. See docs/guide/adapters/axum.md. +# [[environment.variables]] +# name = "EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS" +# value = "256" +# adapters = ["axum"] # # [[environment.variables]] -# name = "API_BASE_URL" -# description = "Base URL for upstream API calls" -# env = "API_BASE_URL" -# value = "https://example.com/api" +# name = "EDGEZERO__ADAPTER__INGRESS__MAX_RAW_HEAD_BYTES" +# value = "65536" +# adapters = ["axum"] # +# [[environment.variables]] +# name = "EDGEZERO__ADAPTER__INGRESS__MAX_HEADER_COUNT" +# value = "100" +# adapters = ["axum"] +# +# [[environment.variables]] +# name = "EDGEZERO__ADAPTER__INGRESS__HEAD_READ_TIMEOUT_MS" +# value = "10000" +# adapters = ["axum"] + # [[environment.secrets]] # name = "API_TOKEN" # description = "Secret token used when calling the upstream API" # adapters = [{{{adapter_list}}}] # env = "API_TOKEN" -{{{adapter_manifest_sections}}} \ No newline at end of file +{{{adapter_manifest_sections}}} diff --git a/crates/edgezero-cli/tests/demo_logging.rs b/crates/edgezero-cli/tests/demo_logging.rs new file mode 100644 index 000000000..a9108679d --- /dev/null +++ b/crates/edgezero-cli/tests/demo_logging.rs @@ -0,0 +1,61 @@ +//! Subprocess probes keep application logger installation isolated from the test runner. +#![cfg(all(feature = "cli", feature = "demo-example"))] + +#[cfg(test)] +mod tests { + use std::net::TcpListener; + use std::process::Command; + + fn demo_startup(level: &str, host: &str) -> (String, String) { + let directory = tempfile::tempdir().expect("isolated demo state"); + let listener = TcpListener::bind("127.0.0.1:0").expect("reserve demo port"); + let port = listener.local_addr().expect("reserved address").port(); + let output = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .arg("demo") + .current_dir(directory.path()) + .env("EDGEZERO__LOGGING__LEVEL", level) + .env("EDGEZERO__ADAPTER__HOST", host) + .env("EDGEZERO__ADAPTER__PORT", port.to_string()) + .output() + .expect("demo subprocess"); + assert!( + !output.status.success(), + "reserved port must prevent serving" + ); + ( + String::from_utf8(output.stdout).expect("demo stdout"), + String::from_utf8(output.stderr).expect("demo stderr"), + ) + } + + #[test] + fn demo_respects_off_without_hiding_terminal_errors() { + let (stdout, stderr) = demo_startup("off", "not-an-ip"); + assert!(!stdout.contains("starting axum server"), "{stdout}"); + assert!(stdout.contains("[edgezero::boot]"), "{stdout}"); + assert!(stdout.contains("not a valid IP address"), "{stdout}"); + assert!( + stderr.contains("[edgezero] demo server error: failed to bind"), + "{stderr}" + ); + assert!( + !stderr.contains("initialize application logger"), + "{stderr}" + ); + } + + #[test] + fn demo_uses_application_logger_not_the_cli_backend() { + let (stdout, stderr) = demo_startup("info", "127.0.0.1"); + assert!(stdout.contains("INFO"), "{stdout}"); + assert!( + stdout.contains("[edgezero_adapter_axum::dev_server]"), + "{stdout}" + ); + assert!(stdout.contains("starting axum server"), "{stdout}"); + assert!( + stderr.contains("[edgezero] demo server error: failed to bind"), + "{stderr}" + ); + } +} diff --git a/crates/edgezero-cli/tests/generated_project_builds.rs b/crates/edgezero-cli/tests/generated_project_builds.rs index be2808852..ce7e2a28c 100644 --- a/crates/edgezero-cli/tests/generated_project_builds.rs +++ b/crates/edgezero-cli/tests/generated_project_builds.rs @@ -20,6 +20,38 @@ mod tests { use std::path::Path; use std::process::{Command, ExitStatus}; + fn require_listed_test(output: &str, sentinel: &str) -> Result<(), String> { + let tests: Vec<_> = output + .lines() + .filter_map(|line| line.trim().strip_suffix(": test")) + .collect(); + if tests.is_empty() { + return Err("generated core test listing contained zero tests".to_owned()); + } + let matches = tests + .iter() + .filter(|name| { + **name == sentinel + || name + .strip_suffix(sentinel) + .is_some_and(|prefix| prefix.ends_with("::")) + }) + .count(); + if matches != 1 { + return Err(format!( + "expected exactly one generated `{sentinel}` test, found {matches}" + )); + } + Ok(()) + } + + #[test] + fn generated_core_test_gate_rejects_zero_tests() { + let error = require_listed_test("0 tests, 0 benchmarks\n", "generated_outbound_http_smoke") + .expect_err("a zero-test listing must fail closed"); + assert!(error.contains("zero tests")); + } + /// Targets installed for the toolchain that builds `project`. A wasm /// check is skipped when its target is absent (e.g. a local run where /// the project sits outside a checkout that pins the wasm targets); CI @@ -43,6 +75,24 @@ mod tests { .unwrap_or_else(|err| panic!("run generated CLI with {args:?}: {err}")) } + fn assert_cargo_success(project: &Path, args: &[&str], expectation: &str) { + let status = Command::new(env!("CARGO")) + .args(args) + .current_dir(project) + .status() + .unwrap_or_else(|err| panic!("run cargo with {args:?}: {err}")); + assert!(status.success(), "{expectation}"); + } + + fn assert_edgezero_success(project: &Path, args: &[&str], expectation: &str) { + let status = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .args(args) + .current_dir(project) + .status() + .unwrap_or_else(|err| panic!("run edgezero with {args:?}: {err}")); + assert!(status.success(), "{expectation}"); + } + #[test] #[ignore = "compiles a generated workspace and may fetch crates; run explicitly"] #[expect( @@ -70,13 +120,9 @@ mod tests { // `cargo check` so a manifest/config drift surfaces as a // fast, clear error -- not as a compilation cascade from // a downstream macro tripping over the bad config. - let validate = Command::new(env!("CARGO_BIN_EXE_edgezero")) - .args(["config", "validate"]) - .current_dir(&project) - .status() - .expect("run `edgezero config validate` on the generated workspace"); - assert!( - validate.success(), + assert_edgezero_success( + &project, + &["config", "validate"], "generated workspace should pass `edgezero config validate`", ); @@ -87,26 +133,57 @@ mod tests { // with a malformed handler or a manifest that violates the // adapter capability matrix would silently pass plain // validate but fail under strict. - let validate_strict = Command::new(env!("CARGO_BIN_EXE_edgezero")) - .args(["config", "validate", "--strict"]) - .current_dir(&project) - .status() - .expect("run `edgezero config validate --strict` on the generated workspace"); - assert!( - validate_strict.success(), + assert_edgezero_success( + &project, + &["config", "validate", "--strict"], "generated workspace should pass `edgezero config validate --strict`", ); + assert_cargo_success( + &project, + &["fmt", "--all", "--", "--check"], + "generated workspace should be rustfmt-clean", + ); + // Host target: the whole workspace, including the generated CLI // crate that imports `edgezero_cli`. - let host = Command::new(env!("CARGO")) - .args(["check", "--workspace"]) + assert_cargo_success( + &project, + &["check", "--workspace"], + "generated workspace should compile for the host target", + ); + + assert_cargo_success( + &project, + &[ + "clippy", + "--workspace", + "--all-targets", + "--all-features", + "--", + "-D", + "warnings", + ], + "generated workspace should pass strict clippy", + ); + + let listed = Command::new(env!("CARGO")) + .args(["test", "-p", "scaffold-probe-core", "--lib", "--", "--list"]) .current_dir(&project) - .status() - .expect("run `cargo check` on the generated workspace"); + .output() + .expect("list generated core tests"); assert!( - host.success(), - "generated workspace should compile for the host target", + listed.status.success(), + "generated core test list should succeed" + ); + let listed_stdout = String::from_utf8_lossy(&listed.stdout); + require_listed_test(&listed_stdout, "generated_outbound_http_smoke") + .expect("generated outbound smoke test must be listed exactly once"); + + assert_cargo_success( + &project, + &["test", "-p", "scaffold-probe-core", "--lib"], + "generated core tests should pass", ); // Typed config validation via the generated `-cli` binary. @@ -146,8 +223,9 @@ mod tests { continue; } let crate_name = format!("scaffold-probe-adapter-{adapter}"); - let wasm = Command::new(env!("CARGO")) - .args([ + assert_cargo_success( + &project, + &[ "check", "-p", &crate_name, @@ -155,13 +233,8 @@ mod tests { target, "--features", adapter, - ]) - .current_dir(&project) - .status() - .expect("run `cargo check` for a wasm adapter target"); - assert!( - wasm.success(), - "generated {adapter} adapter should compile for {target}", + ], + &format!("generated {adapter} adapter should compile for {target}"), ); } } diff --git a/crates/edgezero-cli/tests/platform_memory_docs.rs b/crates/edgezero-cli/tests/platform_memory_docs.rs new file mode 100644 index 000000000..3907c6ba7 --- /dev/null +++ b/crates/edgezero-cli/tests/platform_memory_docs.rs @@ -0,0 +1,184 @@ +#![cfg(all( + feature = "edgezero-adapter-axum", + feature = "edgezero-adapter-cloudflare", + feature = "edgezero-adapter-fastly", + feature = "edgezero-adapter-spin" +))] + +#[cfg(test)] +mod tests { + use edgezero_core::{ + HostIngressMemoryAccounting, InboundRequestPopulationBound, MemoryCeiling, + MemoryCeilingScope, PlatformFact, PlatformMetadata, PlatformResourceSource, + PlatformUnknownReason, + }; + + const CAPABILITY_GUIDE: &str = include_str!("../../../docs/guide/capabilities.md"); + + fn normalized_row(row: &str) -> String { + row.trim() + .trim_matches('|') + .split('|') + .map(str::trim) + .collect::>() + .join("|") + } + + fn guide_contains_row(expected: &str) -> bool { + let normalized_expected = normalized_row(expected); + CAPABILITY_GUIDE + .lines() + .any(|line| normalized_row(line) == normalized_expected) + } + + fn scope_label(scope: MemoryCeilingScope) -> &'static str { + match scope { + MemoryCeilingScope::PerExecution => "Per execution", + MemoryCeilingScope::PerInstance => "Per instance", + _ => panic!("unrecognized memory ceiling scope"), + } + } + + fn source_label(source: PlatformResourceSource) -> String { + match source { + PlatformResourceSource::PlatformLimit { provider } => { + format!("Platform limit: {provider}") + } + PlatformResourceSource::HostedDefault { provider } => { + format!("Hosted default: {provider}") + } + PlatformResourceSource::RuntimeConfiguration { runtime } => { + format!("Runtime configuration: {runtime}") + } + _ => panic!("unrecognized platform resource source"), + } + } + + fn unknown_reason_label(reason: PlatformUnknownReason) -> &'static str { + match reason { + PlatformUnknownReason::OperatorConfigured => "Unknown: Operator configured", + PlatformUnknownReason::ProviderUnpublished => "Unknown: Provider unpublished", + PlatformUnknownReason::RuntimeConfigured => "Unknown: Runtime configured", + PlatformUnknownReason::Unspecified => "Unknown: Unspecified", + _ => panic!("unrecognized platform unknown reason"), + } + } + + fn byte_unit_label(bytes: u64) -> String { + const BYTES_PER_MB: u64 = 1_000_000; + const BYTES_PER_MIB: u64 = 1024 * 1024; + + if let Some(megabytes) = bytes + .checked_div(BYTES_PER_MB) + .filter(|_| bytes.is_multiple_of(BYTES_PER_MB)) + { + format!("{megabytes} MB") + } else if let Some(mebibytes) = bytes + .checked_div(BYTES_PER_MIB) + .filter(|_| bytes.is_multiple_of(BYTES_PER_MIB)) + { + format!("{mebibytes} MiB") + } else { + format!("{bytes} B") + } + } + + fn bytes_with_unit(bytes: u64) -> String { + format!("{bytes} bytes ({})", byte_unit_label(bytes)) + } + + #[test] + fn byte_units_are_derived_from_canonical_values() { + let bytes_134_217_728 = 128 * 1024 * 1024; + + assert_eq!(byte_unit_label(128_000_000), "128 MB"); + assert_eq!(byte_unit_label(1_000_000), "1 MB"); + assert_eq!(byte_unit_label(bytes_134_217_728), "128 MiB"); + assert_eq!(byte_unit_label(1_234), "1234 B"); + } + + fn memory_cells(fact: PlatformFact) -> (String, String, String, String) { + match fact { + PlatformFact::Known { value, source } => { + let primary = bytes_with_unit(value.total_bytes()); + let stack = value + .stack_bytes() + .map_or_else(|| "None".to_owned(), bytes_with_unit); + ( + primary, + stack, + scope_label(value.scope()).to_owned(), + source_label(source), + ) + } + PlatformFact::Unknown { reason } => ( + "Unknown".to_owned(), + "Unknown".to_owned(), + "Unknown".to_owned(), + unknown_reason_label(reason).to_owned(), + ), + _ => panic!("unrecognized memory fact variant"), + } + } + + fn population_cells(fact: PlatformFact) -> (String, String) { + match fact { + PlatformFact::Known { value, source } => { + (value.max_live_requests().to_string(), source_label(source)) + } + PlatformFact::Unknown { reason } => ( + "Unknown".to_owned(), + unknown_reason_label(reason).to_owned(), + ), + _ => panic!("unrecognized population fact variant"), + } + } + + fn host_accounting_cells(fact: PlatformFact) -> (String, String) { + match fact { + PlatformFact::Known { value, source } => { + let accounting = match value { + HostIngressMemoryAccounting::CountsTowardCeiling => "Counts toward ceiling", + HostIngressMemoryAccounting::OutsideCeiling => "Outside ceiling", + _ => panic!("unrecognized host ingress memory accounting"), + }; + (accounting.to_owned(), source_label(source)) + } + PlatformFact::Unknown { reason } => ( + "Unknown".to_owned(), + unknown_reason_label(reason).to_owned(), + ), + _ => panic!("unrecognized host accounting fact variant"), + } + } + + fn assert_documented_platform(target: &str, platform: PlatformMetadata) { + let (primary, stack, scope, memory_source) = memory_cells(platform.memory_ceiling()); + let (population, population_source) = + population_cells(platform.inbound_request_population_bound()); + let (host_accounting, host_accounting_source) = + host_accounting_cells(platform.host_ingress_memory_accounting()); + let row = format!( + "| {target} | {primary} | {stack} | {scope} | {memory_source} | {population} | {population_source} | {host_accounting} | {host_accounting_source} |" + ); + assert!( + guide_contains_row(&row), + "capability guide is missing canonical row: {row}" + ); + } + + #[test] + fn platform_resource_table_matches_adapter_constants() { + assert_documented_platform("Axum", edgezero_adapter_axum::AXUM_PLATFORM); + assert_documented_platform( + "Cloudflare Workers", + edgezero_adapter_cloudflare::CLOUDFLARE_PLATFORM, + ); + assert_documented_platform("Fastly Compute", edgezero_adapter_fastly::FASTLY_PLATFORM); + assert_documented_platform("Spin (generic)", edgezero_adapter_spin::SPIN_PLATFORM); + assert_documented_platform( + "Akamai Functions (Spin)", + edgezero_adapter_spin::AKAMAI_FUNCTIONS_PLATFORM, + ); + } +} diff --git a/crates/edgezero-core/Cargo.toml b/crates/edgezero-core/Cargo.toml index 108197ebb..9fc1c82ab 100644 --- a/crates/edgezero-core/Cargo.toml +++ b/crates/edgezero-core/Cargo.toml @@ -12,11 +12,20 @@ workspace = true [dependencies] edgezero-macros = { path = "../edgezero-macros" } +alloc-no-stdlib = { workspace = true } +alloc-stdlib = { workspace = true } +adler2 = { workspace = true } anyhow = { workspace = true } async-compression = { workspace = true } async-stream = { workspace = true } async-trait = { workspace = true } +brotli = { workspace = true } +brotli-decompressor = { workspace = true } bytes = { workspace = true } +compression-codecs = { workspace = true } +compression-core = { workspace = true } +crc32fast = { workspace = true } +flate2 = { workspace = true } futures = { workspace = true } futures-util = { workspace = true } http = { workspace = true } @@ -32,8 +41,11 @@ thiserror = { workspace = true } toml = { workspace = true } tower-service = { workspace = true } tracing = { workspace = true } +url = { workspace = true } validator = { workspace = true } log = { workspace = true } +miniz_oxide = { workspace = true } +simd-adler32 = { workspace = true } # `web-time` is intentionally unconditional: `std::time::Instant` is # unavailable on `wasm32-unknown-unknown` (Cloudflare Workers target). # `web_time::Instant` is a zero-cost drop-in on native and a JS-backed @@ -47,6 +59,4 @@ web-time = { workspace = true } test-utils = [] [dev-dependencies] -brotli = { workspace = true } -flate2 = { workspace = true } tempfile = { workspace = true } diff --git a/crates/edgezero-core/src/app.rs b/crates/edgezero-core/src/app.rs index 369b08b98..90aa03765 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -1,5 +1,27 @@ -use crate::manifest::ResolvedLoggingConfig; -use crate::router::RouterService; +use std::sync::Arc; + +use crate::body::Body; +use crate::config_store::ConfigExtractionLimits; +use crate::error::EdgeError; +use crate::http::header::CONTENT_TYPE; +use crate::http::{HeaderValue, Method, Request, Response, StatusCode}; +use crate::ingress::{ + AdmissionDecision, IngressAdmissionOutcome, IngressAdmissionPolicy, IngressBeginOutcome, + IngressDispatchOutcome, IngressFraming, IngressHead, IngressHeadAccounting, IngressHeadLimits, + IngressHeadParts, PreparedIngress, apply_admission_policy, default_admission_policy, +}; +use crate::manifest::{BakedManifest, ResolvedLoggingConfig}; +use crate::platform::PlatformMetadata; +use crate::response::IntoResponse as _; +use crate::response_egress::{ + DEFAULT_RESPONSE_WRITE_BUDGET, DetachedResponseEgressDecision, + DetachedResponseEgressDecisionFactory, DetachedResponseEgressHead, ResponseEgressCompletion, + ResponseEgressDeadline, ResponseEgressEnvelope, ResponseEgressHead, ResponseEgressObserver, + ResponseEgressObserverHandle, ResponseEgressPolicy, ResponseEgressPolicyCallback, + ResponseEgressRequestMetadata, default_response_egress_policy, +}; +use crate::router::{RouteMetadata, RouterService}; +use crate::time::{MonotonicClock, MonotonicInstant}; /// Canonical adapter name for the Axum adapter. pub const AXUM_ADAPTER: &str = "axum"; @@ -10,14 +32,142 @@ const DEFAULT_APP_NAME: &str = "EdgeZero App"; pub const FASTLY_ADAPTER: &str = "fastly"; /// Canonical adapter name for the Spin adapter. pub const SPIN_ADAPTER: &str = "spin"; +type ErrorResponseRenderer = Arc Response + Send + Sync + 'static>; /// Lightweight container around a `RouterService` that can be extended via hook implementations. pub struct App { + config_extraction_limits: ConfigExtractionLimits, + detached_response_egress_decision_factory: DetachedResponseEgressDecisionFactory, + error_response_renderer: ErrorResponseRenderer, + ingress_head_limits: IngressHeadLimits, + ingress_policy: IngressAdmissionPolicy, + monotonic_clock: MonotonicClock, name: String, + platform: PlatformMetadata, + response_egress_observer: ResponseEgressObserverHandle, + response_egress_policy: ResponseEgressPolicyCallback, router: RouterService, } impl App { + /// Runs the configured body-blind admission policy exactly once. + /// + /// # Errors + /// Returns an internal policy error if the resulting absolute deadline cannot be + /// normalized safely or if fallback draining is selected for a matched route. + #[inline] + pub fn admit_ingress(&self, head: &IngressHead) -> Result { + match apply_admission_policy(&self.ingress_policy, head, self.monotonic_clock())? { + IngressAdmissionOutcome::Admitted { + completion, + ingress, + } => Ok(IngressAdmissionOutcome::Admitted { + completion, + ingress: ingress.with_config_extraction_limits(self.config_extraction_limits), + }), + IngressAdmissionOutcome::Refused { + completion, + response, + } => Ok(IngressAdmissionOutcome::Refused { + completion, + response, + }), + IngressAdmissionOutcome::Aborted => Ok(IngressAdmissionOutcome::Aborted), + } + } + + /// Converts a failure that occurred after successful admission into app-owned egress. + /// + /// Consuming `prepared` releases its single-use grant exactly once while retaining the + /// admitted request metadata in the resulting response-egress attempt. + /// + #[must_use] + #[inline] + pub fn admitted_error_egress( + &self, + prepared: PreparedIngress, + error: EdgeError, + ) -> ResponseEgressEnvelope { + let request_method = prepared.request_method().clone(); + let request_start = prepared.request_start(); + let route = prepared.route_metadata().cloned(); + let (_resolved, _admitted, completion) = prepared.into_parts(); + self.response_egress_envelope( + self.render_error_response(error), + request_method, + request_start, + route, + completion, + ) + } + + /// Resolves and admits a normalized request head before an adapter transfers native body + /// ownership into a core [`Request`]. + /// + /// # Errors + /// Returns an internal policy error if the admission deadline cannot be normalized safely + /// or if fallback draining is selected for a matched route. + #[inline] + pub fn begin_ingress( + &self, + head_parts: IngressHeadParts, + request_start: MonotonicInstant, + ) -> Result { + let request_method = head_parts.method().clone(); + let resolved = self + .router + .resolve(head_parts.method(), head_parts.target().path()); + let head = head_parts.into_head(request_start, resolved.resolution().clone()); + + match self.admit_ingress(&head)? { + IngressAdmissionOutcome::Admitted { + completion, + ingress, + } => Ok(IngressBeginOutcome::Admitted(PreparedIngress::new( + resolved, ingress, completion, + ))), + IngressAdmissionOutcome::Refused { + completion, + response, + } => Ok(IngressBeginOutcome::Refused(self.detached_response_egress( + response, + request_method, + request_start, + completion, + ))), + IngressAdmissionOutcome::Aborted => Ok(IngressBeginOutcome::Aborted), + } + } + + /// Construct an application from hooks after installing target metadata. + /// + /// This is the sole adapter-facing assembly path. Application hooks observe the installed + /// platform; successful configuration must preserve its complete metadata. + /// + /// # Errors + /// Returns the typed configuration error, or an internal error when configuration replaces + /// the selected platform metadata, without exposing a partially configured app. + #[inline] + pub fn build(platform: PlatformMetadata) -> Result + where + A: Hooks, + { + let mut app = Self::with_name_and_platform(A::routes(), A::name(), platform); + A::configure(&mut app)?; + if app.platform() != platform { + return Err(EdgeError::internal(anyhow::anyhow!( + "application configuration changed adapter-selected platform metadata" + ))); + } + Ok(app) + } + + #[must_use] + #[inline] + pub fn config_extraction_limits(&self) -> ConfigExtractionLimits { + self.config_extraction_limits + } + /// Default name used when none is provided. #[must_use] #[inline] @@ -25,6 +175,120 @@ impl App { DEFAULT_APP_NAME } + /// Chooses detached egress or a non-response abort for a pre-admission failure. + /// + /// A sent response uses the adapter response converter without invoking the application's + /// response-egress policy or observer. An abort constructs no response. + /// + #[must_use] + #[inline] + pub fn detached_ingress_error_egress( + &self, + error: EdgeError, + request_method: Method, + request_start: MonotonicInstant, + ) -> IngressDispatchOutcome { + let head = DetachedResponseEgressHead::new( + &request_method, + request_start, + error.status(), + error.kind(), + ); + match (self.detached_response_egress_decision_factory)(&head) { + DetachedResponseEgressDecision::Abort => IngressDispatchOutcome::Aborted, + DetachedResponseEgressDecision::Send { + completion, + deadline, + } => { + let mut response = self.render_error_response(error); + response + .extensions_mut() + .insert(ResponseEgressDeadline::at(deadline)); + IngressDispatchOutcome::Response(Box::new(self.detached_response_egress( + response, + request_method, + request_start, + completion, + ))) + } + } + } + + fn detached_response_egress( + &self, + response: Response, + request_method: Method, + request_start: MonotonicInstant, + completion: ResponseEgressCompletion, + ) -> ResponseEgressEnvelope { + ResponseEgressEnvelope::detached( + response, + request_start, + request_method, + completion, + self.monotonic_clock(), + ) + } + + /// Dispatches a request whose native body was wrapped after admission. + /// + #[inline] + pub async fn dispatch_admitted( + &self, + prepared: PreparedIngress, + request: Request, + ) -> ResponseEgressEnvelope { + let request_method = request.method().clone(); + let request_start = prepared.request_start(); + let route = prepared.route_metadata().cloned(); + let (resolved, admitted, completion) = prepared.into_parts(); + let response = match self + .router + .dispatch_resolved(resolved, request, admitted) + .await + { + Ok(response) => response, + Err(error) => self.render_error_response(error), + }; + self.response_egress_envelope(response, request_method, request_start, route, completion) + } + + /// Resolves, admits, and dispatches one normalized inbound request into owned egress. + /// + /// Adapters must call this before polling the request body. A response outcome owns its + /// envelope through the transmission boundary; an aborted outcome produces no HTTP response. + /// The route selected for admission is consumed during dispatch, so handlers cannot observe a + /// different route identity. + /// + /// # Errors + /// Returns an error only when admission cannot begin. Handler, routing, body-read, and + /// post-admission conversion errors are converted with the app's error-response renderer. + #[inline] + pub async fn dispatch_ingress( + &self, + request: Request, + request_start: MonotonicInstant, + head_accounting: IngressHeadAccounting, + framing: IngressFraming, + ) -> Result { + let head_parts = IngressHeadParts::from_request(&request, head_accounting, framing); + match self.begin_ingress(head_parts, request_start)? { + IngressBeginOutcome::Admitted(prepared) => Ok(IngressDispatchOutcome::Response( + Box::new(self.dispatch_admitted(prepared, request).await), + )), + IngressBeginOutcome::Refused(response) => { + Ok(IngressDispatchOutcome::Response(Box::new(response))) + } + IngressBeginOutcome::Aborted => Ok(IngressDispatchOutcome::Aborted), + } + } + + #[must_use] + #[inline] + pub fn ingress_head_limits(&self) -> IngressHeadLimits { + self.ingress_head_limits + } + /// Consume the app and return the contained router service. #[must_use] #[inline] @@ -32,6 +296,20 @@ impl App { self.router } + /// Returns the application clock used to stamp and enforce admitted ingress lifetimes. + #[must_use] + #[inline] + pub fn monotonic_clock(&self) -> MonotonicClock { + self.monotonic_clock.clone() + } + + /// Captures one instant from the application clock. + #[must_use] + #[inline] + pub fn monotonic_now(&self) -> MonotonicInstant { + self.monotonic_clock.now() + } + /// Name assigned to the application. #[must_use] #[inline] @@ -46,6 +324,57 @@ impl App { Self::with_name(router, DEFAULT_APP_NAME) } + /// Target properties installed before application configuration. + #[must_use] + #[inline] + pub const fn platform(&self) -> PlatformMetadata { + self.platform + } + + fn render_error_response(&self, error: EdgeError) -> Response { + let status = error.status(); + let required_headers = match error.required_response_headers() { + Ok(headers) => headers, + Err(header_error) => return render_default_error_response(header_error), + }; + let mut response = (self.error_response_renderer)(error); + *response.status_mut() = status; + response.headers_mut().extend(required_headers); + response + } + + fn response_egress_envelope( + &self, + response: Response, + request_method: Method, + request_start: MonotonicInstant, + route: Option, + completion: ResponseEgressCompletion, + ) -> ResponseEgressEnvelope { + ResponseEgressEnvelope::new( + response, + ResponseEgressRequestMetadata::new(request_method, request_start, route), + completion, + self.response_egress_policy(), + self.response_egress_observer(), + self.monotonic_clock(), + ) + } + + /// Returns an owned handle to the configured terminal response-egress observer. + #[must_use] + #[inline] + pub fn response_egress_observer(&self) -> ResponseEgressObserverHandle { + self.response_egress_observer.clone() + } + + /// Returns an owned handle to the configured synchronous response-egress policy callback. + #[must_use] + #[inline] + pub fn response_egress_policy(&self) -> ResponseEgressPolicyCallback { + Arc::clone(&self.response_egress_policy) + } + /// Access the underlying router service. #[must_use] #[inline] @@ -53,6 +382,67 @@ impl App { &self.router } + /// Installs validated typed-config extraction limits. + /// + /// # Errors + /// Returns an internal startup-policy error when limits are zero, inconsistent, or unbounded. + #[inline] + pub fn set_config_extraction_limits( + &mut self, + limits: ConfigExtractionLimits, + ) -> Result<(), EdgeError> { + self.config_extraction_limits = limits.validate()?; + Ok(()) + } + + /// Installs the synchronous decision factory for detached normalized-ingress errors. + #[inline] + pub fn set_detached_response_egress_decision_factory(&mut self, factory: Factory) + where + Factory: for<'head> Fn(&DetachedResponseEgressHead<'head>) -> DetachedResponseEgressDecision + + Send + + Sync + + 'static, + { + self.detached_response_egress_decision_factory = Arc::new(factory); + } + + /// Installs the synchronous renderer for app-owned framework and handler errors. + /// + /// The renderer is called exactly once for admitted dispatch errors and detached normalized + /// ingress errors. Applications that require a bounded wire format should return a fixed-size + /// response here. `EdgeZero` reapplies the error status and mandatory protocol headers after the + /// callback. Explicit admission-refusal and fallback terminal responses bypass this hook + /// because the application supplied those responses directly. + #[inline] + pub fn set_error_response_renderer(&mut self, renderer: Renderer) + where + Renderer: Fn(EdgeError) -> Response + Send + Sync + 'static, + { + self.error_response_renderer = Arc::new(renderer); + } + + /// Installs the synchronous body-blind ingress admission callback. + #[inline] + pub fn set_ingress_admission_policy(&mut self, policy: Policy) + where + Policy: Fn(&IngressHead) -> AdmissionDecision + Send + Sync + 'static, + { + self.ingress_policy = Arc::new(policy); + } + + /// Installs already-validated finite request-head limits. + #[inline] + pub fn set_ingress_head_limits(&mut self, limits: IngressHeadLimits) { + self.ingress_head_limits = limits; + } + + /// Installs the monotonic clock used by subsequent ingress attempts. + #[inline] + pub fn set_monotonic_clock(&mut self, clock: MonotonicClock) { + self.monotonic_clock = clock; + } + /// Update the application name. #[inline] pub fn set_name(&mut self, name: S) @@ -62,15 +452,64 @@ impl App { self.name = name.into(); } + /// Installs the terminal response-egress observer used by adapter conversion attempts. + #[inline] + pub fn set_response_egress_observer(&mut self, observer: Observer) + where + Observer: ResponseEgressObserver, + { + self.response_egress_observer = ResponseEgressObserverHandle::new(observer); + } + + /// Installs the synchronous body-blind response-egress policy callback. + #[inline] + pub fn set_response_egress_policy(&mut self, policy: Policy) + where + Policy: for<'head> Fn(&ResponseEgressHead<'head>, MonotonicInstant) -> ResponseEgressPolicy + + Send + + Sync + + 'static, + { + self.response_egress_policy = Arc::new(policy); + } + /// Construct a new application with the provided router and name. #[inline] pub fn with_name(router: RouterService, name: S) -> Self + where + S: Into, + { + Self::with_name_and_platform(router, name, PlatformMetadata::default()) + } + + /// Construct a new application with the provided router, name, and target metadata. + #[must_use] + #[inline] + pub fn with_name_and_platform( + router: RouterService, + name: S, + platform: PlatformMetadata, + ) -> Self where S: Into, { Self { - router, + config_extraction_limits: ConfigExtractionLimits::default(), + detached_response_egress_decision_factory: Arc::new(|head| { + DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::empty(), + deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET), + } + }), + error_response_renderer: Arc::new(render_default_error_response), + ingress_head_limits: IngressHeadLimits::default(), + ingress_policy: default_admission_policy(), + monotonic_clock: MonotonicClock::default(), name: name.into(), + platform, + response_egress_observer: ResponseEgressObserverHandle::default(), + response_egress_policy: Arc::new(default_response_egress_policy), + router, } } } @@ -104,22 +543,17 @@ pub struct StoresMetadata { /// Trait implemented by application hook adapters. pub trait Hooks { - /// Construct an `App` by wiring the routes and invoking the configuration hook. - #[must_use] - #[inline] - fn build_app() -> App - where - Self: Sized, - { - let mut app = App::with_name(Self::routes(), Self::name()); - Self::configure(&mut app); - app - } - /// Allow implementations to mutate the freshly constructed application before use. /// The default implementation performs no changes. + /// Successful configuration must preserve the adapter-selected [`PlatformMetadata`]. + /// [`App::build`] rejects changed metadata rather than silently restoring it. + /// + /// # Errors + /// Returns a typed startup-policy error when configuration cannot be completed. #[inline] - fn configure(_app: &mut App) {} + fn configure(_app: &mut App) -> Result<(), EdgeError> { + Ok(()) + } /// Logging settings for one adapter, baked from the application manifest. /// @@ -132,6 +566,23 @@ pub trait Hooks { ResolvedLoggingConfig::default() } + /// Parsed and finalized manifest contract baked by `app!`. + /// + /// The default is deliberately uncached: every macro-generated application + /// supplies its own per-implementation cache. + #[must_use] + #[inline] + fn manifest() -> BakedManifest { + BakedManifest::Absent + } + + /// Raw manifest JSON baked at compile time by `app!`. + #[must_use] + #[inline] + fn manifest_json() -> Option<&'static str> { + None + } + /// Display name for the application. Defaults to `"EdgeZero App"`. #[must_use] #[inline] @@ -139,8 +590,17 @@ pub trait Hooks { App::default_name() } - /// When `true`, an adapter's `run_app` skips its own logger initialization; - /// the app is responsible for installing a `log` backend. Default `false`. + /// When `true`, an adapter's entrypoint skips its own logger initialization + /// and must not reconfigure the application's backend or facade maximum. + /// Default `false`. + /// + /// The application must install its `log` backend before the adapter + /// entrypoint and apply configured levels to both backend filters and the + /// facade maximum. [`crate::logging::resolve_logging_level`] resolves the + /// runtime level without mutating logging state. Startup diagnostics use + /// [`crate::logging::BOOT_LOG_TARGET`]; preserving those warnings requires + /// a separate backend target filter and a facade maximum that admits them. + /// Setting only `log::set_max_level` cannot restore backend-filtered records. #[must_use] #[inline] fn owns_logging() -> bool { @@ -162,21 +622,109 @@ pub trait Hooks { } } +fn render_default_error_response(error: EdgeError) -> Response { + match error.into_response() { + Ok(response) => response, + Err(render_error) => { + log::error!("failed to render typed ingress error response: {render_error}"); + // Keep the last-resort response independent of JSON serialization. + let mut response = Response::new(Body::from( + r#"{"error":{"kind":"internal","message":"internal server error","status":500}}"#, + )); + *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + response + } + } +} + #[cfg(test)] mod tests { + use std::num::NonZeroU32; + use std::sync::Arc; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::{Context, Poll}; + use std::time::Duration; + use std::{future::Future as _, str}; + use super::*; use crate::body::Body; + use crate::config_store::ConfigExtractionLimits; use crate::context::RequestContext; - use crate::error::EdgeError; - use crate::http::{Method, StatusCode, request_builder}; - use crate::manifest::LogLevel; + use crate::error::{EdgeError, StoreExtractionReason}; + use crate::http::{ + HeaderMap, HeaderValue, Method, StatusCode, Version, request_builder, response_builder, + }; + use crate::ingress::{ + BufferedIngressResponse, IngressBeginOutcome, IngressDispatchOutcome, IngressFraming, + IngressGrant, IngressHeadAccounting, IngressHeadParts, + }; + use crate::manifest::{BakedManifest, LogLevel}; + use crate::middleware::{Middleware, Next}; + use crate::platform::{ + HostIngressMemoryAccounting, InboundRequestPopulationBound, MemoryCeiling, + MemoryCeilingScope, PlatformFact, PlatformMetadata, PlatformResourceSource, + PlatformUnknownReason, + }; + use crate::response_egress::{ + DEFAULT_RESPONSE_WRITE_BUDGET, DetachedResponseEgressDecision, ResponseEgressAttempt, + ResponseEgressHead, ResponseEgressObserver, ResponseEgressOutcome, ResponseEgressPolicy, + ResponseEgressReport, + }; + use crate::router::{RouteMetadata, RouteResolution}; + use crate::time::{DEADLINE_FAR_FUTURE, Deadline, MonotonicClock, MonotonicInstant}; + use async_trait::async_trait; + use bytes::Bytes; + use futures::StreamExt as _; use futures::executor::block_on; + use futures::stream::{iter, poll_fn}; + use futures::task::noop_waker_ref; use tower_service::Service as _; struct DefaultHooks; + struct FailingHooks; + + struct MemoryAwareHooks; + + struct ReplacingHooks; + + struct ReplacingFailingHooks; + + struct MemoryPreservingReplacingHooks; + struct TestHooks; + struct CountingMiddleware(Arc); + + struct GrantDropProbe(Arc); + + #[derive(Clone)] + struct AppEgressObserver(Arc>>); + + impl ResponseEgressObserver for AppEgressObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.0.lock().expect("reports lock").push(report.clone()); + } + } + + #[async_trait(?Send)] + impl Middleware for CountingMiddleware { + async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + next.run(ctx).await + } + } + + impl Drop for GrantDropProbe { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + #[expect( clippy::missing_trait_methods, reason = "test stub — only `routes` is overridden; every other Hooks method intentionally uses its trait default" @@ -191,13 +739,107 @@ mod tests { } } + #[expect( + clippy::missing_trait_methods, + reason = "test stub exercises only fallible application configuration" + )] + impl Hooks for FailingHooks { + fn configure(_app: &mut App) -> Result<(), EdgeError> { + Err(EdgeError::service_unavailable("configuration unavailable")) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[expect( + clippy::missing_trait_methods, + reason = "test stub exercises platform metadata visibility during configuration" + )] + impl Hooks for MemoryAwareHooks { + fn configure(app: &mut App) -> Result<(), EdgeError> { + let expected = PlatformFact::known( + MemoryCeiling::new(128_000_000, MemoryCeilingScope::PerInstance, None), + PlatformResourceSource::PlatformLimit { + provider: "test-platform", + }, + ); + if app.platform().memory_ceiling() == expected { + Ok(()) + } else { + Err(EdgeError::service_unavailable( + "platform metadata unavailable during configuration", + )) + } + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[expect( + clippy::missing_trait_methods, + reason = "test stub exercises replacement of adapter-selected metadata" + )] + impl Hooks for ReplacingHooks { + fn configure(app: &mut App) -> Result<(), EdgeError> { + *app = App::new(Self::routes()); + Ok(()) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[expect( + clippy::missing_trait_methods, + reason = "test stub exercises error precedence after replacing the application" + )] + impl Hooks for ReplacingFailingHooks { + fn configure(app: &mut App) -> Result<(), EdgeError> { + *app = App::new(Self::routes()); + Err(EdgeError::service_unavailable( + "replacement configuration failed", + )) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + #[expect( + clippy::missing_trait_methods, + reason = "test stub changes a platform fact other than the memory ceiling" + )] + impl Hooks for MemoryPreservingReplacingHooks { + fn configure(app: &mut App) -> Result<(), EdgeError> { + let platform = app.platform(); + let changed = PlatformMetadata::new( + platform.memory_ceiling(), + PlatformFact::unknown(PlatformUnknownReason::OperatorConfigured), + platform.host_ingress_memory_accounting(), + ); + *app = App::build::(changed)?; + Ok(()) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + #[expect( clippy::missing_trait_methods, reason = "test stub — `build_app` intentionally uses the trait default; other methods are overridden for test coverage" )] impl Hooks for TestHooks { - fn configure(app: &mut App) { + fn configure(app: &mut App) -> Result<(), EdgeError> { app.set_name("configured"); + Ok(()) } fn name() -> &'static str { @@ -227,10 +869,117 @@ mod tests { } } + fn assert_buffered_terminal_response( + response: &Response, + status: StatusCode, + marker: &str, + body: &[u8], + ) { + assert_eq!(response.status(), status); + assert_eq!( + response + .headers() + .get("x-fallback-terminal") + .expect("terminal marker"), + marker + ); + assert_eq!(response.body().as_bytes().expect("buffered body"), body); + } + + fn assert_no_fallback_dispatch(handler_calls: &AtomicUsize, middleware_calls: &AtomicUsize) { + assert_eq!(handler_calls.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + } + + fn buffered_terminal( + status: StatusCode, + marker: &'static str, + body: &'static [u8], + ) -> BufferedIngressResponse { + let mut headers = HeaderMap::new(); + headers.insert("x-fallback-terminal", HeaderValue::from_static(marker)); + BufferedIngressResponse::new(status, headers, Bytes::from_static(body)) + } + + fn configure_fallback_policy( + app: &mut App, + drops: &Arc, + max_body_bytes: usize, + read_deadline: Deadline, + on_exceeded: BufferedIngressResponse, + on_timeout: BufferedIngressResponse, + ) { + let grant_drops = Arc::clone(drops); + app.set_ingress_admission_policy(move |_| AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::new(GrantDropProbe(Arc::clone(&grant_drops))), + max_body_bytes, + read_deadline, + on_exceeded: on_exceeded.clone(), + on_timeout: on_timeout.clone(), + }); + } + + fn dispatch_fallback(app: &App, body: Body) -> Response { + let request = request_builder() + .method(Method::POST) + .uri("/known") + .body(body) + .expect("request"); + let outcome = block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"); + complete_dispatch(outcome) + } + + fn complete_envelope(envelope: ResponseEgressEnvelope) -> Response { + let Ok((prepared, _, mut attempt, clock)) = envelope.begin() else { + panic!("response egress begin"); + }; + assert!(attempt.begin_writing()); + assert!(attempt.complete(clock.now())); + prepared.into_response() + } + + fn complete_dispatch(outcome: IngressDispatchOutcome) -> Response { + let IngressDispatchOutcome::Response(envelope) = outcome else { + panic!("expected response"); + }; + complete_envelope(*envelope) + } + + fn counted_completion(calls: &Arc) -> ResponseEgressCompletion { + let observed_calls = Arc::clone(calls); + ResponseEgressCompletion::new(move |_report| { + observed_calls.fetch_add(1, Ordering::SeqCst); + }) + } + fn empty_router() -> RouterService { RouterService::builder().build() } + fn guarded_fallback_app() -> (App, Arc, Arc) { + let handler_calls = Arc::new(AtomicUsize::new(0)); + let handler_counter = Arc::clone(&handler_calls); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .get("/known", move |_ctx: RequestContext| { + let call_counter = Arc::clone(&handler_counter); + async move { + call_counter.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("unexpected") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + (App::new(router), handler_calls, middleware_calls) + } + #[test] fn app_can_be_retained_in_a_shared_owner() { fn assert_send_sync() {} @@ -239,7 +988,7 @@ mod tests { #[test] fn build_app_invokes_hooks_for_routes_and_configuration() { - let app = TestHooks::build_app(); + let app = App::build::(PlatformMetadata::default()).expect("configured app"); assert_eq!(app.name(), "configured"); let stores = TestHooks::stores(); let config = stores.config.expect("config store metadata"); @@ -261,12 +1010,1577 @@ mod tests { assert_eq!(response.body().as_bytes().expect("buffered"), b"ok"); } + #[test] + fn build_app_propagates_configuration_failure() { + let Err(error) = App::build::(PlatformMetadata::default()) else { + panic!("configuration failure must stop application assembly"); + }; + assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn build_app_exposes_memory_ceiling_during_configuration() { + let source = PlatformResourceSource::PlatformLimit { + provider: "test-platform", + }; + let ceiling = MemoryCeiling::new(128_000_000, MemoryCeilingScope::PerInstance, None); + let population = InboundRequestPopulationBound::new(NonZeroU32::MIN); + let host_accounting = HostIngressMemoryAccounting::OutsideCeiling; + let platform = PlatformMetadata::new( + PlatformFact::known(ceiling, source), + PlatformFact::known(population, source), + PlatformFact::known(host_accounting, source), + ); + + let app = App::build::(platform).expect("configured app"); + + assert_eq!(app.platform(), platform); + assert_eq!( + app.platform().inbound_request_population_bound(), + PlatformFact::known(population, source) + ); + assert_eq!( + app.platform().host_ingress_memory_accounting(), + PlatformFact::known(host_accounting, source) + ); + } + + #[test] + fn build_app_rejects_replaced_platform_metadata() { + let platform = PlatformMetadata::new( + PlatformFact::known( + MemoryCeiling::new(128_000_000, MemoryCeilingScope::PerInstance, None), + PlatformResourceSource::PlatformLimit { + provider: "test-platform", + }, + ), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + ); + let Err(error) = App::build::(platform) else { + panic!("replaced platform metadata must stop application assembly"); + }; + assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + error.to_string(), + "internal error: application configuration changed adapter-selected platform metadata" + ); + } + + #[test] + fn build_app_preserves_hook_failure_after_platform_replacement() { + let platform = PlatformMetadata::new( + PlatformFact::unknown(PlatformUnknownReason::OperatorConfigured), + PlatformFact::unknown(PlatformUnknownReason::OperatorConfigured), + PlatformFact::unknown(PlatformUnknownReason::OperatorConfigured), + ); + let Err(error) = App::build::(platform) else { + panic!("configuration failure must stop application assembly"); + }; + assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(error.message(), "replacement configuration failed"); + } + + #[test] + fn build_app_compares_platform_facts_beyond_the_memory_ceiling() { + let platform = PlatformMetadata::new( + PlatformFact::known( + MemoryCeiling::new(128_000_000, MemoryCeilingScope::PerInstance, None), + PlatformResourceSource::PlatformLimit { + provider: "test-platform", + }, + ), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + ); + let Err(error) = App::build::(platform) else { + panic!("changing only population metadata must stop assembly"); + }; + assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn build_app_accepts_unknown_platform_metadata() { + let app = App::build::(PlatformMetadata::default()).expect("default app"); + + assert_eq!(app.platform(), PlatformMetadata::default()); + assert_eq!( + app.platform().memory_ceiling(), + PlatformFact::unknown(PlatformUnknownReason::Unspecified) + ); + assert_eq!( + app.platform().inbound_request_population_bound(), + PlatformFact::unknown(PlatformUnknownReason::Unspecified) + ); + assert_eq!( + app.platform().host_ingress_memory_accounting(), + PlatformFact::unknown(PlatformUnknownReason::Unspecified) + ); + } + #[test] fn default_app_uses_constant_name() { let app = App::new(empty_router()); assert_eq!(app.name(), App::default_name()); } + #[test] + fn app_pairs_admitted_ingress_with_its_injected_clock() { + let start = MonotonicInstant::now(); + let now = Arc::new(Mutex::new(start)); + let observed_now = Arc::clone(&now); + let mut app = App::new(empty_router()); + app.set_monotonic_clock(MonotonicClock::new(move || { + *observed_now.lock().expect("clock lock") + })); + + let request_start = app.monotonic_now(); + assert_eq!(request_start, start); + let head = IngressHeadParts::new( + Method::GET, + "/".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + let IngressBeginOutcome::Admitted(prepared) = + app.begin_ingress(head, request_start).expect("admission") + else { + panic!("expected admission"); + }; + assert_eq!(prepared.request_start(), start); + + let advanced = start + .checked_add(Duration::from_secs(1)) + .expect("advanced instant"); + *now.lock().expect("clock lock") = advanced; + assert_eq!(prepared.monotonic_clock().now(), advanced); + } + + #[test] + fn app_owns_default_and_configurable_response_egress_policy() { + let mut app = App::new(empty_router()); + let headers = HeaderMap::new(); + let request_start = MonotonicInstant::now(); + let egress_started_at = request_start + .checked_add(Duration::from_millis(5)) + .expect("egress start"); + let route = RouteMetadata::new(Method::GET, "/items/{id}"); + let head = ResponseEgressHead::new( + StatusCode::OK, + Version::HTTP_11, + &headers, + None, + request_start, + Some(&route), + ); + + let default_policy = app.response_egress_policy(); + assert_eq!( + default_policy(&head, egress_started_at) + .write_deadline + .instant(), + egress_started_at + .checked_add(DEFAULT_RESPONSE_WRITE_BUDGET) + .expect("default deadline") + ); + + app.set_response_egress_policy(move |response_head, started_at| { + assert_eq!(response_head.status(), StatusCode::OK); + assert_eq!(response_head.request_start(), request_start); + assert_eq!( + response_head.route().map(RouteMetadata::pattern), + Some("/items/{id}") + ); + assert_eq!(started_at, egress_started_at); + ResponseEgressPolicy { + write_deadline: Deadline::at_instant(started_at), + } + }); + let configured_policy = app.response_egress_policy(); + assert_eq!( + configured_policy(&head, egress_started_at) + .write_deadline + .instant(), + egress_started_at + ); + } + + #[test] + fn app_owns_configurable_response_egress_observer() { + let reports = Arc::new(Mutex::new(Vec::new())); + let mut app = App::new(empty_router()); + app.set_response_egress_observer(AppEgressObserver(Arc::clone(&reports))); + + let started_at = MonotonicInstant::now(); + let headers = HeaderMap::new(); + let head = ResponseEgressHead::new( + StatusCode::NO_CONTENT, + Version::HTTP_11, + &headers, + None, + started_at, + None, + ); + let mut attempt = ResponseEgressAttempt::new( + &head, + started_at, + ResponseEgressCompletion::empty(), + app.response_egress_observer(), + app.monotonic_clock(), + ); + assert!(attempt.begin_writing()); + assert!(attempt.complete(started_at)); + + let observed_reports = reports.lock().expect("reports lock"); + assert_eq!(observed_reports.len(), 1); + assert_eq!( + observed_reports[0].outcome, + ResponseEgressOutcome::Completed + ); + } + + #[test] + fn config_extraction_limits_are_validated_and_reach_routed_context() { + let limits = ConfigExtractionLimits { + max_backend_bytes: 64, + max_blob_bytes: 32, + max_secret_bytes: 16, + max_total_bytes: 48, + max_json_depth: 8, + max_json_nodes: 32, + timeout: Duration::from_secs(2), + }; + let router = RouterService::builder() + .get("/limits", move |ctx: RequestContext| async move { + assert_eq!(ctx.config_extraction_limits(), limits); + Ok::<_, EdgeError>("ok") + }) + .build(); + let mut app = App::new(router); + app.set_config_extraction_limits(limits) + .expect("valid extraction limits"); + + let request = request_builder() + .method(Method::GET) + .uri("/limits") + .body(Body::empty()) + .expect("request"); + let response = complete_dispatch( + block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"), + ); + assert_eq!(response.status(), StatusCode::OK); + } + + #[test] + fn default_app_admits_with_finite_deadline_and_empty_grant() { + let app = App::new(empty_router()); + let start = MonotonicInstant::now(); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let head = IngressHead::from_request( + &request, + start, + RouteResolution::NotFound, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + + let IngressAdmissionOutcome::Admitted { + completion, + ingress: admitted, + } = app.admit_ingress(&head).expect("admitted") + else { + panic!("expected admission"); + }; + drop(completion); + assert_eq!(admitted.request_start(), start); + assert!(admitted.read_deadline().instant() > start); + assert!( + admitted.read_deadline().instant() + <= start.checked_add(DEADLINE_FAR_FUTURE).expect("maximum") + ); + let (_, _, grant, _, _) = admitted.into_parts(); + grant.downcast::<()>().expect_err("empty grant"); + } + + #[test] + fn ingress_refusal_skips_handler_and_body_poll() { + let handler_calls = Arc::new(AtomicUsize::new(0)); + let handler_counter = Arc::clone(&handler_calls); + let middleware_calls = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .post("/upload", move |_ctx: RequestContext| { + let call_counter = Arc::clone(&handler_counter); + async move { + call_counter.fetch_add(1, Ordering::SeqCst); + Ok::<_, EdgeError>("unexpected") + } + }) + .middleware(CountingMiddleware(Arc::clone(&middleware_calls))) + .build(); + let mut app = App::new(router); + let admission_calls = Arc::new(AtomicUsize::new(0)); + let admission_counter = Arc::clone(&admission_calls); + app.set_ingress_admission_policy(move |_| { + admission_counter.fetch_add(1, Ordering::SeqCst); + AdmissionDecision::Refuse { + completion: ResponseEgressCompletion::empty(), + response: response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("x-admission", "saturated") + .body(Body::from("busy")) + .expect("response"), + } + }); + let body_polls = Arc::new(AtomicUsize::new(0)); + let poll_counter = Arc::clone(&body_polls); + let body = Body::stream(poll_fn(move |_| { + poll_counter.fetch_add(1, Ordering::SeqCst); + Poll::Ready(None::) + })); + let request = request_builder() + .method(Method::POST) + .uri("/upload") + .body(body) + .expect("request"); + + let response = complete_dispatch( + block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"), + ); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + response.headers().get("x-admission").expect("header"), + "saturated" + ); + assert_eq!(response.body().as_bytes().expect("body"), b"busy"); + assert_eq!(admission_calls.load(Ordering::SeqCst), 1); + assert_eq!(handler_calls.load(Ordering::SeqCst), 0); + assert_eq!(middleware_calls.load(Ordering::SeqCst), 0); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + } + + #[test] + fn admitted_response_retains_completion_until_terminal_egress() { + let completions = Arc::new(AtomicUsize::new(0)); + let observed_completions = Arc::clone(&completions); + let mut app = App::new( + RouterService::builder() + .get("/ok", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("ok") + }) + .build(), + ); + app.set_ingress_admission_policy(move |head| AdmissionDecision::Admit { + completion: counted_completion(&observed_completions), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + }); + let request = request_builder() + .method(Method::GET) + .uri("/ok") + .body(Body::empty()) + .expect("request"); + + let outcome = block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("dispatch"); + assert_eq!(completions.load(Ordering::SeqCst), 0); + let IngressDispatchOutcome::Response(envelope) = outcome else { + panic!("expected response"); + }; + assert_eq!(complete_envelope(*envelope).status(), StatusCode::OK); + assert_eq!(completions.load(Ordering::SeqCst), 1); + } + + #[test] + fn refusal_response_retains_completion_until_terminal_egress() { + let completions = Arc::new(AtomicUsize::new(0)); + let observed_completions = Arc::clone(&completions); + let mut app = App::new(empty_router()); + app.set_ingress_admission_policy(move |_| AdmissionDecision::Refuse { + completion: counted_completion(&observed_completions), + response: response_builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .body(Body::empty()) + .expect("response"), + }); + let head = IngressHeadParts::new( + Method::GET, + "/".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + + let IngressBeginOutcome::Refused(envelope) = app + .begin_ingress(head, MonotonicInstant::now()) + .expect("begin ingress") + else { + panic!("expected refusal"); + }; + assert_eq!(completions.load(Ordering::SeqCst), 0); + assert_eq!( + complete_envelope(envelope).status(), + StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!(completions.load(Ordering::SeqCst), 1); + } + + #[test] + fn fallback_terminal_response_retains_completion_until_terminal_egress() { + let completions = Arc::new(AtomicUsize::new(0)); + let observed_completions = Arc::clone(&completions); + let mut app = App::new(empty_router()); + app.set_ingress_admission_policy(move |head| AdmissionDecision::ReadBodyBeforeFallback { + completion: counted_completion(&observed_completions), + grant: IngressGrant::empty(), + max_body_bytes: 1, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text(StatusCode::PAYLOAD_TOO_LARGE, "too large"), + on_timeout: BufferedIngressResponse::text(StatusCode::REQUEST_TIMEOUT, "late"), + }); + let request = request_builder() + .method(Method::POST) + .uri("/missing") + .body(Body::from("ab")) + .expect("request"); + + let outcome = block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("dispatch"); + assert_eq!(completions.load(Ordering::SeqCst), 0); + let IngressDispatchOutcome::Response(envelope) = outcome else { + panic!("expected response"); + }; + assert_eq!( + complete_envelope(*envelope).status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + assert_eq!(completions.load(Ordering::SeqCst), 1); + } + + #[test] + fn admission_abort_has_no_body_or_response_lifecycle() { + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let mut app = App::new(empty_router()); + app.set_ingress_admission_policy(|_| AdmissionDecision::Abort); + let request = request_builder() + .method(Method::POST) + .uri("/missing") + .body(Body::stream(poll_fn(move |_| { + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::Ready(None::) + }))) + .expect("request"); + + let outcome = block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("dispatch"); + assert!(matches!(outcome, IngressDispatchOutcome::Aborted)); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + } + + #[test] + fn ingress_refusal_is_detached_from_application_response_egress() { + let router = RouterService::builder() + .post("/upload/{id}", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("unexpected") + }) + .build(); + let reports = Arc::new(Mutex::new(Vec::new())); + let policy_calls = Arc::new(AtomicUsize::new(0)); + let mut app = App::new(router); + app.set_ingress_admission_policy(|_| AdmissionDecision::Refuse { + completion: ResponseEgressCompletion::empty(), + response: response_builder() + .status(StatusCode::TOO_MANY_REQUESTS) + .body(Body::empty()) + .expect("response"), + }); + app.set_response_egress_observer(AppEgressObserver(Arc::clone(&reports))); + let request_start = MonotonicInstant::now(); + let started_at = request_start + .checked_add(Duration::from_millis(5)) + .expect("egress start"); + app.set_monotonic_clock(MonotonicClock::new(move || started_at)); + let observed_policy_calls = Arc::clone(&policy_calls); + app.set_response_egress_policy(move |_head, egress_started_at| { + observed_policy_calls.fetch_add(1, Ordering::SeqCst); + ResponseEgressPolicy { + write_deadline: Deadline::at_instant( + egress_started_at + .checked_add(Duration::from_secs(1)) + .expect("deadline"), + ), + } + }); + let head = IngressHeadParts::new( + Method::POST, + "/upload/42".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + + let IngressBeginOutcome::Refused(egress) = + app.begin_ingress(head, request_start).expect("refusal") + else { + panic!("expected refusal"); + }; + let Ok((prepared, _, mut attempt, egress_clock)) = egress.begin() else { + panic!("begin egress"); + }; + let response = prepared.into_response(); + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(egress_clock.now(), started_at); + assert!(attempt.begin_writing()); + assert!(attempt.complete(started_at)); + + assert_eq!(policy_calls.load(Ordering::SeqCst), 0); + assert!(reports.lock().expect("reports lock").is_empty()); + } + + #[test] + fn detached_ingress_error_send_uses_application_completion_and_earlier_deadline() { + let router = RouterService::builder().build(); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let observed_heads = Arc::new(Mutex::new(Vec::new())); + let mut app = App::new(router); + let observed_factory_calls = Arc::clone(&factory_calls); + let observed_completion_calls = Arc::clone(&completion_calls); + let captured_heads = Arc::clone(&observed_heads); + app.set_detached_response_egress_decision_factory(move |head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + captured_heads.lock().expect("heads lock").push(( + head.request_method().clone(), + head.request_start(), + head.status(), + head.error_kind(), + )); + let terminal_calls = Arc::clone(&observed_completion_calls); + DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::new(move |_report| { + terminal_calls.fetch_add(1, Ordering::SeqCst); + }), + deadline: head.write_deadline_after(Duration::from_secs(5)), + } + }); + let request_start = MonotonicInstant::now(); + let egress_started_at = request_start + .checked_add(Duration::from_millis(10)) + .expect("egress start"); + app.set_monotonic_clock(MonotonicClock::new(move || egress_started_at)); + + let IngressDispatchOutcome::Response(egress) = app.detached_ingress_error_egress( + EdgeError::uri_too_long("request target exceeded configured limit"), + Method::POST, + request_start, + ) else { + panic!("expected detached response"); + }; + + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert_eq!(completion_calls.load(Ordering::SeqCst), 0); + assert_eq!( + observed_heads.lock().expect("heads lock").as_slice(), + &[( + Method::POST, + request_start, + StatusCode::URI_TOO_LONG, + "uri_too_long", + )] + ); + let Ok((prepared, policy, mut attempt, clock)) = egress.begin() else { + panic!("begin detached egress"); + }; + assert_eq!( + policy.write_deadline.instant(), + request_start + .checked_add(Duration::from_secs(5)) + .expect("detached deadline") + ); + assert_eq!(prepared.into_response().status(), StatusCode::URI_TOO_LONG); + assert!(attempt.begin_writing()); + assert!(attempt.complete(clock.now())); + assert_eq!(completion_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn detached_ingress_error_send_uses_earlier_default_policy_deadline() { + let request_start = MonotonicInstant::now(); + let egress_started_at = request_start + .checked_add(Duration::from_secs(1)) + .expect("egress start"); + let mut app = App::new(RouterService::builder().build()); + app.set_monotonic_clock(MonotonicClock::new(move || egress_started_at)); + app.set_detached_response_egress_decision_factory(|head| { + DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::empty(), + deadline: head.write_deadline_after(Duration::from_mins(1)), + } + }); + + let IngressDispatchOutcome::Response(egress) = app.detached_ingress_error_egress( + EdgeError::bad_request("invalid request head"), + Method::GET, + request_start, + ) else { + panic!("expected detached response"); + }; + let Ok((_prepared, policy, _attempt, _clock)) = egress.begin() else { + panic!("begin detached egress"); + }; + + assert_eq!( + policy.write_deadline.instant(), + egress_started_at + .checked_add(DEFAULT_RESPONSE_WRITE_BUDGET) + .expect("default policy deadline") + ); + } + + #[test] + fn detached_ingress_error_send_preserves_expired_deadline() { + let request_start = MonotonicInstant::now(); + let egress_started_at = request_start + .checked_add(Duration::from_secs(1)) + .expect("egress start"); + let mut app = App::new(RouterService::builder().build()); + app.set_monotonic_clock(MonotonicClock::new(move || egress_started_at)); + app.set_detached_response_egress_decision_factory(move |_| { + DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::empty(), + deadline: Deadline::at_instant(request_start), + } + }); + + let IngressDispatchOutcome::Response(egress) = app.detached_ingress_error_egress( + EdgeError::bad_request("invalid request head"), + Method::GET, + request_start, + ) else { + panic!("expected detached response"); + }; + let Ok((_prepared, policy, _attempt, _clock)) = egress.begin() else { + panic!("begin detached egress"); + }; + + assert_eq!(policy.write_deadline.instant(), request_start); + assert!(policy.write_deadline.is_expired_at(egress_started_at)); + } + + #[test] + fn abandoned_detached_ingress_error_releases_factory_resource_without_report() { + let resource_drops = Arc::new(AtomicUsize::new(0)); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let mut app = App::new(RouterService::builder().build()); + let observed_resource_drops = Arc::clone(&resource_drops); + let observed_completion_calls = Arc::clone(&completion_calls); + app.set_detached_response_egress_decision_factory(move |head| { + let resource = GrantDropProbe(Arc::clone(&observed_resource_drops)); + let terminal_calls = Arc::clone(&observed_completion_calls); + DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::new(move |_report| { + let _resource = resource; + terminal_calls.fetch_add(1, Ordering::SeqCst); + }), + deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET), + } + }); + + let IngressDispatchOutcome::Response(egress) = app.detached_ingress_error_egress( + EdgeError::bad_request("invalid request head"), + Method::GET, + MonotonicInstant::now(), + ) else { + panic!("expected detached response"); + }; + drop(egress); + + assert_eq!(completion_calls.load(Ordering::SeqCst), 0); + assert_eq!(resource_drops.load(Ordering::SeqCst), 1); + } + + #[test] + fn detached_ingress_error_abort_returns_non_response_outcome() { + let factory_calls = Arc::new(AtomicUsize::new(0)); + let completion_calls = Arc::new(AtomicUsize::new(0)); + let observed_factory_calls = Arc::clone(&factory_calls); + let observed_completion_calls = Arc::clone(&completion_calls); + let mut app = App::new(RouterService::builder().build()); + app.set_detached_response_egress_decision_factory(move |_head| { + observed_factory_calls.fetch_add(1, Ordering::SeqCst); + let terminal_calls = Arc::clone(&observed_completion_calls); + drop(ResponseEgressCompletion::new(move |_report| { + terminal_calls.fetch_add(1, Ordering::SeqCst); + })); + DetachedResponseEgressDecision::Abort + }); + + let outcome = app.detached_ingress_error_egress( + EdgeError::bad_request("invalid request head"), + Method::GET, + MonotonicInstant::now(), + ); + + assert!(matches!(outcome, IngressDispatchOutcome::Aborted)); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + assert_eq!(completion_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn detached_ingress_error_default_factory_uses_request_start_deadline() { + let request_start = MonotonicInstant::now(); + let egress_started_at = request_start + .checked_add(Duration::from_secs(1)) + .expect("egress start"); + let mut app = App::new(RouterService::builder().build()); + app.set_monotonic_clock(MonotonicClock::new(move || egress_started_at)); + + let IngressDispatchOutcome::Response(egress) = app.detached_ingress_error_egress( + EdgeError::bad_request("invalid request head"), + Method::GET, + request_start, + ) else { + panic!("default detached decision must send a response"); + }; + let Ok((prepared, policy, mut attempt, clock)) = egress.begin() else { + panic!("begin detached egress"); + }; + + assert_eq!( + policy.write_deadline.instant(), + request_start + .checked_add(DEFAULT_RESPONSE_WRITE_BUDGET) + .expect("default detached deadline") + ); + assert_eq!(prepared.into_response().status(), StatusCode::BAD_REQUEST); + assert!(attempt.begin_writing()); + assert!(attempt.complete(clock.now())); + } + + #[test] + fn post_admission_failure_uses_owned_error_egress() { + let router = RouterService::builder() + .post("/upload/{id}", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("unexpected") + }) + .build(); + let reports = Arc::new(Mutex::new(Vec::new())); + let grant_drops = Arc::new(AtomicUsize::new(0)); + let observed_grant_drops = Arc::clone(&grant_drops); + let mut app = App::new(router); + app.set_ingress_admission_policy(move |head| AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::new(GrantDropProbe(Arc::clone(&observed_grant_drops))), + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + }); + app.set_error_response_renderer(|error| { + let mut response = Response::new(Body::from(error.kind())); + *response.status_mut() = error.status(); + response + }); + app.set_response_egress_observer(AppEgressObserver(Arc::clone(&reports))); + let request_start = MonotonicInstant::now(); + let head = IngressHeadParts::new( + Method::POST, + "/upload/42".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + let IngressBeginOutcome::Admitted(prepared) = + app.begin_ingress(head, request_start).expect("admission") + else { + panic!("expected admission"); + }; + + let egress = app.admitted_error_egress( + prepared, + EdgeError::internal(anyhow::anyhow!("post-admission failure")), + ); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + let Ok((prepared_egress, _, mut attempt, clock)) = egress.begin() else { + panic!("begin egress"); + }; + let response = prepared_egress.into_response(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + response.body().as_bytes().expect("buffered body"), + b"internal" + ); + assert!(attempt.begin_writing()); + assert!(attempt.complete(clock.now())); + + let observed = reports.lock().expect("reports lock"); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].request_start, request_start); + assert_eq!( + observed[0].route.as_ref().map(RouteMetadata::pattern), + Some("/upload/{id}") + ); + } + + #[test] + fn ingress_method_reaches_owned_response_framing() { + let mut app = App::new(RouterService::builder().build()); + app.set_ingress_admission_policy(|_| AdmissionDecision::Refuse { + completion: ResponseEgressCompletion::empty(), + response: response_builder() + .status(StatusCode::OK) + .header("content-length", "7") + .body(Body::from("ignored")) + .expect("response"), + }); + let head = IngressHeadParts::new( + Method::HEAD, + "/missing".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + + let IngressBeginOutcome::Refused(egress) = app + .begin_ingress(head, MonotonicInstant::now()) + .expect("refusal") + else { + panic!("expected refusal"); + }; + let Ok((prepared, _, mut attempt, clock)) = egress.begin() else { + panic!("owned response egress failed"); + }; + assert!(!prepared.transmits_body()); + assert_eq!( + prepared + .into_response() + .body() + .as_bytes() + .expect("suppressed body"), + b"" + ); + assert!(attempt.begin_writing()); + assert!(attempt.complete(clock.now())); + } + + #[test] + fn fallback_body_policy_rejects_matched_routes_before_body_construction() { + let router = RouterService::builder() + .post("/upload", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("unexpected") + }) + .build(); + let mut app = App::new(router); + app.set_ingress_admission_policy(|head| AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + max_body_bytes: 4_096, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text(StatusCode::PAYLOAD_TOO_LARGE, "too large"), + on_timeout: BufferedIngressResponse::text(StatusCode::REQUEST_TIMEOUT, "timeout"), + }); + let head = IngressHeadParts::new( + Method::POST, + "/upload".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + + assert!(matches!( + app.begin_ingress(head, MonotonicInstant::now()), + Err(EdgeError::Internal { .. }) + )); + } + + #[test] + fn fallback_body_exact_cap_preserves_not_found() { + let mut app = App::new(empty_router()); + app.set_ingress_admission_policy(|head| AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + max_body_bytes: 4, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text(StatusCode::PAYLOAD_TOO_LARGE, "too large"), + on_timeout: BufferedIngressResponse::text(StatusCode::REQUEST_TIMEOUT, "timeout"), + }); + let request = request_builder() + .method(Method::POST) + .uri("/missing") + .body(Body::stream(iter([ + Bytes::from_static(b"ab"), + Bytes::from_static(b"cd"), + ]))) + .expect("request"); + + let response = complete_dispatch( + block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"), + ); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn fallback_body_exact_cap_preserves_method_not_allowed_allow_header() { + let router = RouterService::builder() + .get("/known", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("unexpected") + }) + .build(); + let mut app = App::new(router); + app.set_ingress_admission_policy(|head| AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + max_body_bytes: 4, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text(StatusCode::PAYLOAD_TOO_LARGE, "too large"), + on_timeout: BufferedIngressResponse::text(StatusCode::REQUEST_TIMEOUT, "timeout"), + }); + let request = request_builder() + .method(Method::POST) + .uri("/known") + .body(Body::stream(iter([ + Bytes::from_static(b"ab"), + Bytes::from_static(b"cd"), + ]))) + .expect("request"); + + let response = complete_dispatch( + block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"), + ); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!( + response.headers().get("allow").expect("Allow header"), + "GET" + ); + } + + #[test] + fn custom_error_response_renderer_handles_admitted_dispatch_errors() { + let router = RouterService::builder() + .get("/fails", |_ctx: RequestContext| async move { + Err::<&'static str, _>(EdgeError::bad_request("private diagnostic")) + }) + .build(); + let mut app = App::new(router); + app.set_error_response_renderer(|error| { + response_builder() + .status(error.status()) + .header("x-error-renderer", "application") + .body(Body::from(error.kind())) + .expect("response") + }); + let request = request_builder() + .method(Method::GET) + .uri("/fails") + .body(Body::empty()) + .expect("request"); + + let response = complete_dispatch( + block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"), + ); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.headers()["x-error-renderer"], "application"); + assert_eq!( + response.body().as_bytes().expect("buffered body"), + b"bad_request" + ); + } + + #[test] + fn custom_error_response_renderer_handles_detached_ingress_errors() { + let mut app = App::new(empty_router()); + app.set_error_response_renderer(|error| { + response_builder() + .status(error.status()) + .header("x-error-renderer", "application") + .body(Body::from(error.kind())) + .expect("response") + }); + + let response = complete_dispatch(app.detached_ingress_error_egress( + EdgeError::uri_too_long("private diagnostic"), + Method::GET, + MonotonicInstant::now(), + )); + + assert_eq!(response.status(), StatusCode::URI_TOO_LONG); + assert_eq!(response.headers()["x-error-renderer"], "application"); + assert_eq!( + response.body().as_bytes().expect("buffered body"), + b"uri_too_long" + ); + } + + #[test] + fn custom_error_response_renderer_cannot_remove_required_allow_header() { + let router = RouterService::builder() + .get("/known", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("unexpected") + }) + .build(); + let mut app = App::new(router); + app.set_error_response_renderer(|_error| Response::new(Body::from("bounded error"))); + let request = request_builder() + .method(Method::POST) + .uri("/known") + .body(Body::empty()) + .expect("request"); + + let response = complete_dispatch( + block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"), + ); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!( + response.headers().get("allow").expect("Allow header"), + "GET" + ); + assert_eq!( + response.body().as_bytes().expect("buffered body"), + b"bounded error" + ); + } + + #[test] + fn custom_error_response_renderer_cannot_remove_config_retry_after_header() { + let mut app = App::new(empty_router()); + app.set_error_response_renderer(|_error| { + response_builder() + .header("retry-after", "1") + .body(Body::from("bounded error")) + .expect("response") + }); + + for error in [ + EdgeError::config_out_of_date("stale config", "feature.enabled"), + EdgeError::StoreExtraction { + reason: StoreExtractionReason::MissingSecret, + message: "missing secret".to_owned(), + field_path: Some("credentials.token".to_owned()), + }, + ] { + let response = app.render_error_response(error); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + response + .headers() + .get("retry-after") + .expect("Retry-After header"), + "60" + ); + assert_eq!( + response.body().as_bytes().expect("buffered body"), + b"bounded error" + ); + } + } + + #[test] + fn custom_error_response_renderer_does_not_add_retry_after_to_generic_503() { + let mut app = App::new(empty_router()); + app.set_error_response_renderer(|_error| Response::new(Body::from("bounded error"))); + + let response = app.render_error_response(EdgeError::service_unavailable("busy")); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(response.headers().get("retry-after").is_none()); + } + + #[test] + fn fallback_body_overflow_precedes_method_not_allowed() { + let router = RouterService::builder() + .get("/known", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("unexpected") + }) + .build(); + let mut app = App::new(router); + app.set_ingress_admission_policy(|head| AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + max_body_bytes: 4, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text(StatusCode::PAYLOAD_TOO_LARGE, "too large"), + on_timeout: BufferedIngressResponse::text(StatusCode::REQUEST_TIMEOUT, "timeout"), + }); + let request = request_builder() + .method(Method::POST) + .uri("/known") + .body(Body::stream(iter([ + Bytes::from_static(b"abcd"), + Bytes::from_static(b"e"), + ]))) + .expect("request"); + + let response = complete_dispatch( + block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"), + ); + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + + #[test] + fn fallback_body_zero_cap_accepts_only_empty_body() { + let mut app = App::new(empty_router()); + app.set_ingress_admission_policy(|head| AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + max_body_bytes: 0, + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + on_exceeded: BufferedIngressResponse::text(StatusCode::PAYLOAD_TOO_LARGE, "too large"), + on_timeout: BufferedIngressResponse::text(StatusCode::REQUEST_TIMEOUT, "timeout"), + }); + + for (body, expected) in [ + (Body::empty(), StatusCode::NOT_FOUND), + (Body::from_bytes("a"), StatusCode::PAYLOAD_TOO_LARGE), + ] { + let request = request_builder() + .method(Method::POST) + .uri("/missing") + .body(body) + .expect("request"); + let response = complete_dispatch( + block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"), + ); + + assert_eq!(response.status(), expected); + } + } + + #[test] + fn fallback_body_deadline_precedes_body_poll_and_not_found() { + let start = MonotonicInstant::now(); + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let body = Body::stream(poll_fn(move |_| { + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::Ready(Some(Bytes::from_static(b"a"))) + })); + let mut app = App::new(empty_router()); + app.set_monotonic_clock(MonotonicClock::new(move || start)); + app.set_ingress_admission_policy(|head| AdmissionDecision::ReadBodyBeforeFallback { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + max_body_bytes: 4_096, + read_deadline: Deadline::at_instant(head.request_start()), + on_exceeded: BufferedIngressResponse::text(StatusCode::PAYLOAD_TOO_LARGE, "too large"), + on_timeout: BufferedIngressResponse::text( + StatusCode::GATEWAY_TIMEOUT, + "fallback timeout", + ), + }); + let request = request_builder() + .method(Method::POST) + .uri("/missing") + .body(body) + .expect("request"); + + let response = complete_dispatch( + block_on(app.dispatch_ingress( + request, + start, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"), + ); + + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + assert_eq!( + response.body().as_bytes().expect("buffered body"), + b"fallback timeout" + ); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + } + + #[test] + fn fallback_grant_is_live_during_exact_cap_eof_and_drops_before_egress() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let grant_drops = Arc::new(AtomicUsize::new(0)); + configure_fallback_policy( + &mut app, + &grant_drops, + 4, + Deadline::after(Duration::from_secs(1)), + buffered_terminal(StatusCode::PAYLOAD_TOO_LARGE, "exceeded", b"over"), + buffered_terminal(StatusCode::GATEWAY_TIMEOUT, "timeout", b"late"), + ); + let observed_drops = Arc::clone(&grant_drops); + let mut poll = 0_usize; + let body = Body::stream(poll_fn(move |_| { + assert_eq!(observed_drops.load(Ordering::SeqCst), 0); + let next = match poll { + 0 => Some(Bytes::from_static(b"ab")), + 1 => Some(Bytes::from_static(b"cd")), + 2 => None, + _ => panic!("body polled after EOF"), + }; + poll += 1; + Poll::Ready(next) + })); + let head = IngressHeadParts::new( + Method::POST, + "/known".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + let IngressBeginOutcome::Admitted(prepared) = app + .begin_ingress(head, MonotonicInstant::now()) + .expect("begin ingress") + else { + panic!("expected admission"); + }; + let request = request_builder() + .method(Method::POST) + .uri("/known") + .body(body) + .expect("request"); + + let egress = block_on(app.dispatch_admitted(prepared, request)); + + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_eq!( + complete_envelope(egress).status(), + StatusCode::METHOD_NOT_ALLOWED + ); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn fallback_overflow_returns_exact_application_response_and_drops_grant_once() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let grant_drops = Arc::new(AtomicUsize::new(0)); + configure_fallback_policy( + &mut app, + &grant_drops, + 4, + Deadline::after(Duration::from_secs(1)), + buffered_terminal( + StatusCode::PAYLOAD_TOO_LARGE, + "exceeded", + b"limit\0exceeded", + ), + buffered_terminal(StatusCode::GATEWAY_TIMEOUT, "timeout", b"late"), + ); + let observed_drops = Arc::clone(&grant_drops); + let body = Body::stream( + iter([Bytes::from_static(b"abcd"), Bytes::from_static(b"e")]).inspect(move |_| { + assert_eq!(observed_drops.load(Ordering::SeqCst), 0); + }), + ); + + let response = dispatch_fallback(&app, body); + + assert_buffered_terminal_response( + &response, + StatusCode::PAYLOAD_TOO_LARGE, + "exceeded", + b"limit\0exceeded", + ); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn fallback_deadline_expiry_returns_exact_application_response_and_drops_grant_once() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let start = MonotonicInstant::now(); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let now = Arc::new(Mutex::new(start)); + let observed_now = Arc::clone(&now); + app.set_monotonic_clock(MonotonicClock::new(move || { + *observed_now.lock().expect("clock lock") + })); + let grant_drops = Arc::new(AtomicUsize::new(0)); + configure_fallback_policy( + &mut app, + &grant_drops, + 4, + Deadline::at_instant(deadline), + buffered_terminal(StatusCode::PAYLOAD_TOO_LARGE, "exceeded", b"over"), + buffered_terminal(StatusCode::GATEWAY_TIMEOUT, "timeout", b"deadline\0expired"), + ); + let observed_drops = Arc::clone(&grant_drops); + let body = Body::stream(poll_fn(move |_| { + assert_eq!(observed_drops.load(Ordering::SeqCst), 0); + *now.lock().expect("clock lock") = deadline; + Poll::Ready(Some(Bytes::from_static(b"a"))) + })); + + let response = dispatch_fallback(&app, body); + + assert_buffered_terminal_response( + &response, + StatusCode::GATEWAY_TIMEOUT, + "timeout", + b"deadline\0expired", + ); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn fallback_adapter_request_timeout_uses_application_timeout_response() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let grant_drops = Arc::new(AtomicUsize::new(0)); + configure_fallback_policy( + &mut app, + &grant_drops, + 4, + Deadline::after(Duration::from_secs(1)), + buffered_terminal(StatusCode::PAYLOAD_TOO_LARGE, "exceeded", b"over"), + buffered_terminal(StatusCode::GATEWAY_TIMEOUT, "timeout", b"adapter timeout"), + ); + let observed_drops = Arc::clone(&grant_drops); + let body = Body::from_stream(poll_fn(move |_| { + assert_eq!(observed_drops.load(Ordering::SeqCst), 0); + Poll::Ready(Some(Err(EdgeError::request_timeout( + "adapter deadline wrapper expired", + )))) + })); + + let response = dispatch_fallback(&app, body); + + assert_buffered_terminal_response( + &response, + StatusCode::GATEWAY_TIMEOUT, + "timeout", + b"adapter timeout", + ); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn fallback_non_timeout_source_error_is_preserved_and_drops_grant_once() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let grant_drops = Arc::new(AtomicUsize::new(0)); + configure_fallback_policy( + &mut app, + &grant_drops, + 4, + Deadline::after(Duration::from_secs(1)), + buffered_terminal(StatusCode::PAYLOAD_TOO_LARGE, "exceeded", b"over"), + buffered_terminal(StatusCode::GATEWAY_TIMEOUT, "timeout", b"late"), + ); + let observed_drops = Arc::clone(&grant_drops); + let body = Body::from_stream(poll_fn(move |_| { + assert_eq!(observed_drops.load(Ordering::SeqCst), 0); + Poll::Ready(Some(Err(EdgeError::service_unavailable( + "source unavailable", + )))) + })); + + let response = dispatch_fallback(&app, body); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(response.headers().get("x-fallback-terminal").is_none()); + assert!( + str::from_utf8(response.body().as_bytes().expect("buffered body")) + .expect("JSON body") + .contains("service_unavailable") + ); + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn cancelling_fallback_dispatch_drops_live_grant_once() { + let (mut app, handler_calls, middleware_calls) = guarded_fallback_app(); + let grant_drops = Arc::new(AtomicUsize::new(0)); + configure_fallback_policy( + &mut app, + &grant_drops, + 4, + Deadline::after(Duration::from_secs(1)), + buffered_terminal(StatusCode::PAYLOAD_TOO_LARGE, "exceeded", b"over"), + buffered_terminal(StatusCode::GATEWAY_TIMEOUT, "timeout", b"late"), + ); + let observed_drops = Arc::clone(&grant_drops); + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let body = Body::stream(poll_fn(move |_| { + assert_eq!(observed_drops.load(Ordering::SeqCst), 0); + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::Pending::> + })); + let head = IngressHeadParts::new( + Method::POST, + "/known".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + let IngressBeginOutcome::Admitted(prepared) = app + .begin_ingress(head, MonotonicInstant::now()) + .expect("begin ingress") + else { + panic!("expected admission"); + }; + let request = request_builder() + .method(Method::POST) + .uri("/known") + .body(body) + .expect("request"); + let mut dispatch = Box::pin(app.dispatch_admitted(prepared, request)); + let mut cx = Context::from_waker(noop_waker_ref()); + + assert!(matches!(dispatch.as_mut().poll(&mut cx), Poll::Pending)); + assert_eq!(body_polls.load(Ordering::SeqCst), 1); + assert_eq!(grant_drops.load(Ordering::SeqCst), 0); + + drop(dispatch); + + assert_eq!(grant_drops.load(Ordering::SeqCst), 1); + assert_no_fallback_dispatch(&handler_calls, &middleware_calls); + } + + #[test] + fn ordinary_admit_does_not_poll_fallback_bodies() { + let router = RouterService::builder() + .get("/known", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("unexpected") + }) + .build(); + let app = App::new(router); + + for (path, expected) in [ + ("/missing", StatusCode::NOT_FOUND), + ("/known", StatusCode::METHOD_NOT_ALLOWED), + ] { + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + let body = Body::stream(poll_fn(move |_| { + observed_polls.fetch_add(1, Ordering::SeqCst); + Poll::Ready(None::) + })); + let request = request_builder() + .method(Method::POST) + .uri(path) + .body(body) + .expect("request"); + + let response = complete_dispatch( + block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + )) + .expect("response"), + ); + + assert_eq!(response.status(), expected); + assert_eq!(body_polls.load(Ordering::SeqCst), 0); + } + } + + #[test] + fn ingress_two_phase_admission_precedes_body_construction() { + let router = RouterService::builder() + .post("/upload", |_ctx: RequestContext| async move { + Ok::<_, EdgeError>("accepted") + }) + .build(); + let mut app = App::new(router); + app.set_ingress_admission_policy(|head| { + assert!(matches!( + head.route_resolution(), + RouteResolution::Matched(metadata) if metadata.pattern() == "/upload" + )); + AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + read_deadline: Deadline::after(Duration::from_secs(1)), + } + }); + let head = IngressHeadParts::new( + Method::POST, + "/upload".parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + let start = MonotonicInstant::now(); + + let IngressBeginOutcome::Admitted(prepared) = + app.begin_ingress(head, start).expect("begin ingress") + else { + panic!("expected admission"); + }; + assert_eq!(prepared.request_start(), start); + + let request = request_builder() + .method(Method::POST) + .uri("/upload") + .body(Body::from("payload")) + .expect("request"); + let response = block_on(app.dispatch_admitted(prepared, request)); + assert_eq!(complete_envelope(response).status(), StatusCode::OK); + } + #[test] fn default_hooks_do_not_own_logging() { assert!(!DefaultHooks::owns_logging()); @@ -282,8 +2596,10 @@ mod tests { #[test] fn default_hooks_use_default_name_and_into_router() { - let app = DefaultHooks::build_app(); + let app = App::build::(PlatformMetadata::default()).expect("default app"); assert_eq!(app.name(), App::default_name()); + assert!(matches!(DefaultHooks::manifest(), BakedManifest::Absent)); + assert_eq!(DefaultHooks::manifest_json(), None); assert_eq!(DefaultHooks::stores(), StoresMetadata::default()); let router = app.into_router(); assert!(router.routes().is_empty()); diff --git a/crates/edgezero-core/src/body.rs b/crates/edgezero-core/src/body.rs index 43056e296..4eeee51f3 100644 --- a/crates/edgezero-core/src/body.rs +++ b/crates/edgezero-core/src/body.rs @@ -8,12 +8,14 @@ use serde::de::DeserializeOwned; use crate::error::EdgeError; +pub type BodyStream = LocalBoxStream<'static, Result>; + /// Lightweight HTTP body that can either contain a single `Bytes` buffer or a streaming source of /// chunks. The streaming variant is implemented with `LocalBoxStream` so it remains compatible with /// `wasm32` targets that lack thread support. pub enum Body { Once(Bytes), - Stream(LocalBoxStream<'static, Result>), + Stream(BodyStream), } impl Body { @@ -43,18 +45,28 @@ impl Body { } #[inline] - pub fn from_stream(stream: S) -> Self + pub fn from_external_stream(stream: S) -> Self where S: Stream> + 'static, anyhow::Error: From, { Self::Stream( stream - .map(|res| res.map_err(anyhow::Error::from)) + .map(|result| { + result.map_err(|error| EdgeError::internal(anyhow::Error::from(error))) + }) .boxed_local(), ) } + #[inline] + pub fn from_stream(stream: S) -> Self + where + S: Stream> + 'static, + { + Self::Stream(stream.boxed_local()) + } + /// Consume a buffered body and return its bytes, or `None` if this is a /// streaming body. To collect a streaming body, use /// [`Body::into_bytes_bounded`]. @@ -84,11 +96,14 @@ impl Body { Body::Stream(mut stream) => { let mut buf = Vec::new(); while let Some(result) = StreamExt::next(&mut stream).await { - let chunk = result.map_err(EdgeError::internal)?; - buf.extend_from_slice(&chunk); - if buf.len() > max_size { + let chunk = result?; + let next_len = buf.len().checked_add(chunk.len()).ok_or_else(|| { + EdgeError::bad_request("request body size accounting overflow") + })?; + if next_len > max_size { return Err(EdgeError::bad_request("request body too large")); } + buf.extend_from_slice(&chunk); } Ok(Bytes::from(buf)) } @@ -96,7 +111,7 @@ impl Body { } #[inline] - pub fn into_stream(self) -> Option>> { + pub fn into_stream(self) -> Option { match self { Body::Once(_) => None, Body::Stream(stream) => Some(stream), @@ -123,7 +138,7 @@ impl Body { where S: Stream + 'static, { - Self::Stream(stream.map(Ok::).boxed_local()) + Self::Stream(stream.map(Ok::).boxed_local()) } #[inline] @@ -170,6 +185,13 @@ impl fmt::Debug for Body { } } +impl From for Body { + #[inline] + fn from(value: Bytes) -> Self { + Body::Once(value) + } +} + impl From> for Body { #[inline] fn from(value: Vec) -> Self { @@ -201,9 +223,12 @@ impl From for Body { #[cfg(test)] mod tests { use super::*; + use crate::error::ResponseLimitReason; use futures::executor::block_on; use futures_util::stream; + use std::cell::Cell; use std::io; + use std::rc::Rc; #[test] fn as_bytes_returns_none_for_stream() { @@ -248,12 +273,12 @@ mod tests { } #[test] - fn from_stream_maps_errors() { + fn body_from_external_stream_maps_to_internal() { let source = stream::iter(vec![ Ok(Bytes::from_static(b"ok")), Err(io::Error::other("boom")), ]); - let body = Body::from_stream(source); + let body = Body::from_external_stream(source); let mut chunks = body.into_stream().expect("stream"); let (first, second) = block_on(async { let first = chunks.next().await.expect("first").expect("ok"); @@ -262,9 +287,67 @@ mod tests { }); assert_eq!(first, Bytes::from_static(b"ok")); let err = second.expect_err("error"); + assert!(matches!(err, EdgeError::Internal { .. })); assert!(err.to_string().contains("boom")); } + #[test] + fn body_from_stream_preserves_edge_error() { + let source = stream::iter([Err(EdgeError::response_too_large_with_reason( + "encoded cap", + ResponseLimitReason::EncodedBody, + ))]); + let body = Body::from_stream(source); + let mut chunks = body.into_stream().expect("stream"); + let error = block_on(chunks.next()) + .expect("item") + .expect_err("typed error"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::EncodedBody, + .. + } + )); + } + + #[test] + fn body_into_bytes_bounded_preserves_edge_error() { + let source = stream::iter([Err(EdgeError::gateway_timeout("expired"))]); + let error = + block_on(Body::from_stream(source).into_bytes_bounded(100)).expect_err("typed error"); + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[test] + fn body_stream_accepts_infallible_bytes() { + let body = Body::stream(stream::iter([ + Bytes::from_static(b"one"), + Bytes::from_static(b"two"), + ])); + let bytes = block_on(body.into_bytes_bounded(6)).expect("body"); + assert_eq!(bytes, Bytes::from_static(b"onetwo")); + assert_eq!( + Body::from(Bytes::from_static(b"bytes")).as_bytes(), + Some(b"bytes".as_slice()) + ); + } + + #[test] + fn body_bounded_checks_before_append() { + let polls = Rc::new(Cell::new(0_usize)); + let observed = Rc::clone(&polls); + let source = stream::iter([ + Ok(Bytes::from_static(b"too-large")), + Ok(Bytes::from_static(b"must-not-poll")), + ]) + .inspect(move |_| observed.set(observed.get().saturating_add(1))); + let error = + block_on(Body::from_stream(source).into_bytes_bounded(3)).expect_err("over limit"); + assert!(matches!(error, EdgeError::BadRequest { .. })); + assert_eq!(polls.get(), 1); + } + #[test] fn from_vec_u8_builds_buffered_body() { let body = Body::from(vec![1_u8, 2_u8, 3_u8]); diff --git a/crates/edgezero-core/src/canonical_form.rs b/crates/edgezero-core/src/canonical_form.rs index a3372110c..3dafc4bbe 100644 --- a/crates/edgezero-core/src/canonical_form.rs +++ b/crates/edgezero-core/src/canonical_form.rs @@ -17,19 +17,46 @@ use serde_json::Value; use sha2::{Digest as _, Sha256}; -use std::fmt::Write as _; +use std::fmt::{self, Write}; -/// SHA-256 of the canonical form of `data`. See module docs. +struct CanonicalHasher(Sha256); + +impl CanonicalHasher { + fn push(&mut self, character: char) { + let mut bytes = [0_u8; 4]; + self.push_str(character.encode_utf8(&mut bytes)); + } + + fn push_str(&mut self, text: &str) { + self.0.update(text.as_bytes()); + } +} + +impl Write for CanonicalHasher { + fn write_char(&mut self, c: char) -> fmt::Result { + self.push(c); + Ok(()) + } + + fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result { + fmt::write(self, args) + } + + fn write_str(&mut self, s: &str) -> fmt::Result { + self.push_str(s); + Ok(()) + } +} + +/// SHA-256 of the canonical form of `data`, without a serialized output buffer. #[must_use] #[inline] pub fn canonical_data_sha256(data: &Value) -> String { #[cfg(test)] test_hooks::CALL_COUNT.with(|cell| cell.set(cell.get().saturating_add(1))); - let mut buf = String::new(); - write_canonical(&mut buf, data); - let mut hasher = Sha256::new(); - hasher.update(buf.as_bytes()); - format!("{:x}", hasher.finalize()) + let mut sink = CanonicalHasher(Sha256::new()); + write_canonical(&mut sink, data); + format!("{:x}", sink.0.finalize()) } #[cfg(test)] @@ -45,7 +72,7 @@ pub(crate) mod test_hooks { } } -fn write_canonical(out: &mut String, value: &Value) { +fn write_canonical(out: &mut CanonicalHasher, value: &Value) { match value { Value::Null => out.push_str("null"), Value::Bool(true) => out.push_str("true"), @@ -86,9 +113,9 @@ fn write_canonical(out: &mut String, value: &Value) { clippy::panic, reason = "unreachable branch — serde_json::Number always exposes i64/u64/f64" )] -fn write_number(out: &mut String, n: &serde_json::Number) { +fn write_number(out: &mut CanonicalHasher, n: &serde_json::Number) { if let Some(int_val) = n.as_i64() { - // Infallible: writing to a String never fails. + // Infallible: this sink feeds bytes directly into the hasher. write!(out, "{int_val}").unwrap_or_default(); } else if let Some(uint_val) = n.as_u64() { write!(out, "{uint_val}").unwrap_or_default(); @@ -110,7 +137,7 @@ fn write_number(out: &mut String, n: &serde_json::Number) { } } -fn write_string(out: &mut String, raw: &str) { +fn write_string(out: &mut CanonicalHasher, raw: &str) { // Escape table byte-identical to serde_json::to_string(raw)'s // default. Pinned by spec 4.2 — DO NOT change without bumping // BlobEnvelope::version. @@ -141,6 +168,16 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn large_escaped_payload_matches_canonical_wire_hash() { + let value = json!({ "payload": "\n\t\u{0001}\"\\".repeat(0x0001_0000) }); + let wire = serde_json::to_vec(&value).expect("canonical single-key fixture"); + assert_eq!( + canonical_data_sha256(&value), + format!("{:x}", Sha256::digest(&wire)) + ); + } + #[test] fn object_keys_sorted_by_utf8_bytes() { let val_ba = json!({ "b": 1_i32, "a": 2_i32 }); diff --git a/crates/edgezero-core/src/compression.rs b/crates/edgezero-core/src/compression.rs index 5c1243f73..63ecc2d98 100644 --- a/crates/edgezero-core/src/compression.rs +++ b/crates/edgezero-core/src/compression.rs @@ -1,85 +1,791 @@ +use async_compression::futures::bufread::{ + BrotliDecoder as AsyncBrotliDecoder, GzipDecoder as AsyncGzipDecoder, + ZlibDecoder as AsyncZlibDecoder, +}; +use async_stream::stream; +use bytes::Bytes; +use compression_codecs::{ + BrotliDecoder as PinnedBrotliCodec, GzipDecoder as PinnedGzipCodec, + ZlibDecoder as PinnedZlibCodec, +}; +use compression_core::util::PartialBuffer; +use futures::io::AsyncReadExt as _; +use futures_util::{StreamExt as _, TryStreamExt as _, stream as futures_stream}; use std::io; -use async_compression::futures::bufread::{BrotliDecoder, GzipDecoder}; -use async_stream::try_stream; -use bytes::Bytes; -use futures::TryStream; -use futures::io::{AsyncReadExt as _, BufReader}; -use futures::stream::Stream; -use futures_util::TryStreamExt as _; +use crate::body::BodyStream; +use crate::error::{BadGatewayDecodeReason, BadGatewayReason, EdgeError, ResponseLimitReason}; +use crate::http::HeaderMap; +use crate::http::header::CONTENT_ENCODING; const BUFFER_SIZE: usize = 8 * 1024; +const _: fn(brotli::BrotliResult) -> brotli_decompressor::BrotliResult = |result| result; +const _: fn(PartialBuffer<&[u8]>) = consume_pinned_compression_core; + +/// Conservative non-window charge for the decoder implementation pinned by +/// the workspace lockfiles. +/// +/// The source audit in +/// `docs/audits/2026-09-27-brotli-decoder-memory-accounting.md` derives a +/// 3,442,530-byte allocation-payload subtotal for the boxed decoder state, +/// Huffman storage, context maps, and bridge buffer in the pinned decoder +/// graph. The 16 MiB accounting reservation leaves conservative headroom for +/// wrapper objects and allocator overhead that are not claimed as exact heap +/// bounds. The ring allocation is charged separately as `2^WBITS`; dependency +/// upgrades must repeat the audit before changing either pin or charge. +#[expect( + clippy::decimal_literal_representation, + reason = "the decimal byte count is the reviewed public contract" +)] +pub const BROTLI_DECODER_FIXED_CHARGE_BYTES: u64 = 16_777_216; + +/// Conservative decoder-state charge shared by the pinned gzip and zlib codecs. +/// +/// The source audit in +/// `docs/audits/2026-09-29-flate-decoder-memory-accounting.md` derives a +/// 73,728-byte reviewed subtotal. The 1 MiB reservation leaves conservative +/// headroom for wrapper objects, the boxed coroutine, alignment, and allocator +/// variance that are not claimed as exact heap bounds. Dependency upgrades must +/// repeat the audit before changing either the pins or this charge. +pub const FLATE_DECODER_FIXED_CHARGE_BYTES: u64 = 0x0010_0000; + +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +struct EdgeErrorCarrier(EdgeError); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BrotliPrefix { + NeedSecondByte, + WindowBits(u8), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ContentEncoding { + Brotli, + Deflate, + Gzip, + Identity, + Passthrough(PassthroughReason), +} + +#[non_exhaustive] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PassthroughReason { + Malformed, + Stacked, + Unsupported(String), +} + +fn consume_pinned_compression_core(_buffer: PartialBuffer<&[u8]>) {} + +#[must_use] +#[inline] +pub fn classify_content_encoding(headers: &HeaderMap) -> ContentEncoding { + let mut values = headers.get_all(CONTENT_ENCODING).iter(); + let Some(header_value) = values.next() else { + return ContentEncoding::Identity; + }; + if values.next().is_some() { + return ContentEncoding::Passthrough(PassthroughReason::Stacked); + } + let raw_encoding = trim_optional_whitespace(header_value.as_bytes()); + let mut codings = raw_encoding.split(|byte| *byte == b','); + let Some(first_coding) = codings.next() else { + return ContentEncoding::Passthrough(PassthroughReason::Malformed); + }; + let first = trim_optional_whitespace(first_coding); + if !is_content_coding_token(first) { + return ContentEncoding::Passthrough(PassthroughReason::Malformed); + } + let mut stacked = false; + for coding in codings { + if !is_content_coding_token(trim_optional_whitespace(coding)) { + return ContentEncoding::Passthrough(PassthroughReason::Malformed); + } + stacked = true; + } + if stacked { + return ContentEncoding::Passthrough(PassthroughReason::Stacked); + } + if first.eq_ignore_ascii_case(b"br") { + ContentEncoding::Brotli + } else if first.eq_ignore_ascii_case(b"deflate") { + ContentEncoding::Deflate + } else if first.eq_ignore_ascii_case(b"gzip") { + ContentEncoding::Gzip + } else if first.eq_ignore_ascii_case(b"identity") { + ContentEncoding::Identity + } else { + let token = first + .iter() + .map(|byte| char::from(byte.to_ascii_lowercase())) + .collect(); + ContentEncoding::Passthrough(PassthroughReason::Unsupported(token)) + } +} + +fn is_content_coding_token(value: &[u8]) -> bool { + !value.is_empty() + && value.iter().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) +} + +fn trim_optional_whitespace(value: &[u8]) -> &[u8] { + let start = value + .iter() + .position(|byte| !matches!(byte, b' ' | b'\t')) + .unwrap_or(value.len()); + let end = value + .iter() + .rposition(|byte| !matches!(byte, b' ' | b'\t')) + .map_or(start, |index| index.saturating_add(1)); + value.get(start..end).unwrap_or_default() +} + +/// Return the conservative decoder-state charge for an advertised Brotli window. +/// +/// # Errors +/// Returns [`EdgeError::BadRequest`] when `window_bits` is outside the Brotli range accepted by +/// `EdgeZero`, or a typed response-limit error if decoder-memory accounting overflows. +#[inline] +pub fn brotli_decoder_memory_charge(window_bits: u8) -> Result { + if !(10..=30).contains(&window_bits) { + return Err(EdgeError::bad_request( + "brotli window bits must be between 10 and 30 inclusive", + )); + } + let window = 1_u64.checked_shl(u32::from(window_bits)).ok_or_else(|| { + EdgeError::response_too_large_with_reason( + "brotli decoder memory accounting overflow", + ResponseLimitReason::DecoderMemory, + ) + })?; + BROTLI_DECODER_FIXED_CHARGE_BYTES + .checked_add(window) + .ok_or_else(|| { + EdgeError::response_too_large_with_reason( + "brotli decoder memory accounting overflow", + ResponseLimitReason::DecoderMemory, + ) + }) +} + /// Decode a stream of gzip-compressed chunks into plain bytes. +#[must_use] #[inline] -pub fn decode_gzip_stream(stream: S) -> impl Stream> -where - S: TryStream, Error = io::Error> + Unpin, -{ - try_stream! { - let reader = BufReader::new(stream.into_async_read()); - let mut decoder = GzipDecoder::new(reader); +pub fn decode_gzip_stream(stream: BodyStream, max_decoder_bytes: u64) -> BodyStream { + stream! { + if let Err(limit_error) = enforce_flate_decoder_memory(max_decoder_bytes, "gzip") { + yield Err(release_before_terminal(stream, limit_error)); + return; + } + let reader = stream.map_err(edge_error_to_io).into_async_read(); + let mut decoder = AsyncGzipDecoder::with_codec(reader, PinnedGzipCodec::new()); + decoder.multiple_members(true); let mut buffer = vec![0_u8; BUFFER_SIZE]; loop { - let read = decoder.read(&mut buffer).await?; + let read = match decoder.read(&mut buffer).await { + Ok(read) => read, + Err(source_error) => { + let error = release_before_terminal( + (decoder, buffer), + io_to_edge_error(source_error, BadGatewayDecodeReason::Gzip), + ); + yield Err(error); + return; + } + }; if read == 0 { break; } - let chunk = buffer.get(..read).ok_or_else(|| { - io::Error::other(format!( - "decoder reported {read}-byte read into a {BUFFER_SIZE}-byte buffer" - )) - })?; - yield Bytes::copy_from_slice(chunk); + let Some(chunk) = buffer.get(..read) else { + let decoder_error = codec_error( + BadGatewayDecodeReason::Gzip, + format!("decoder reported {read}-byte read into a {BUFFER_SIZE}-byte buffer"), + ); + let terminal_error = + release_before_terminal((decoder, buffer), decoder_error); + yield Err(terminal_error); + return; + }; + yield Ok(Bytes::copy_from_slice(chunk)); + } + } + .boxed_local() +} + +/// Decode one RFC 1950 zlib-wrapped DEFLATE stream into plain bytes. +#[must_use] +#[inline] +pub fn decode_deflate_stream(stream: BodyStream, max_decoder_bytes: u64) -> BodyStream { + stream! { + if let Err(limit_error) = enforce_flate_decoder_memory(max_decoder_bytes, "deflate") { + yield Err(release_before_terminal(stream, limit_error)); + return; } + let reader = stream.map_err(edge_error_to_io).into_async_read(); + let mut decoder = AsyncZlibDecoder::with_codec(reader, PinnedZlibCodec::new()); + let mut buffer = vec![0_u8; BUFFER_SIZE]; + + loop { + let read = match decoder.read(&mut buffer).await { + Ok(read) => read, + Err(source_error) => { + let error = release_before_terminal( + (decoder, buffer), + io_to_edge_error(source_error, BadGatewayDecodeReason::Deflate), + ); + yield Err(error); + return; + } + }; + if read == 0 { + break; + } + let Some(chunk) = buffer.get(..read) else { + let decoder_error = codec_error( + BadGatewayDecodeReason::Deflate, + format!("decoder reported {read}-byte read into a {BUFFER_SIZE}-byte buffer"), + ); + let terminal_error = + release_before_terminal((decoder, buffer), decoder_error); + yield Err(terminal_error); + return; + }; + yield Ok(Bytes::copy_from_slice(chunk)); + } + + let mut native_reader = decoder.into_inner(); + let mut trailing = [0_u8; 1]; + match native_reader.read(&mut trailing).await { + Ok(0) => {} + Ok(_) => { + let trailing_error = codec_error( + BadGatewayDecodeReason::Deflate, + "deflate response contains trailing data or a second stream", + ); + let terminal_error = release_before_terminal(native_reader, trailing_error); + yield Err(terminal_error); + } + Err(source_error) => { + let terminal_error = release_before_terminal( + native_reader, + io_to_edge_error(source_error, BadGatewayDecodeReason::Deflate), + ); + yield Err(terminal_error); + } + } + } + .boxed_local() +} + +fn enforce_flate_decoder_memory(max_decoder_bytes: u64, coding: &str) -> Result<(), EdgeError> { + if FLATE_DECODER_FIXED_CHARGE_BYTES > max_decoder_bytes { + return Err(EdgeError::response_too_large_with_reason( + format!( + "{coding} decoder requires {FLATE_DECODER_FIXED_CHARGE_BYTES} bytes; limit is {max_decoder_bytes}" + ), + ResponseLimitReason::DecoderMemory, + )); } + Ok(()) } /// Decode a stream of brotli-compressed chunks into plain bytes. +#[must_use] #[inline] -pub fn decode_brotli_stream(stream: S) -> impl Stream> -where - S: TryStream, Error = io::Error> + Unpin, -{ - try_stream! { - let reader = BufReader::new(stream.into_async_read()); - let mut decoder = BrotliDecoder::new(reader); +pub fn decode_brotli_stream( + mut source: BodyStream, + max_window_bits: u8, + max_decoder_bytes: u64, +) -> BodyStream { + stream! { + let mut prefix = Vec::with_capacity(2); + let mut initial_chunks = Vec::new(); + let window_bits = loop { + let Some(item) = source.next().await else { + let prefix_error = codec_error( + BadGatewayDecodeReason::Brotli, + "brotli stream ended before its window prefix", + ); + let terminal_error = release_before_terminal( + (source, initial_chunks, prefix), + prefix_error, + ); + yield Err(terminal_error); + return; + }; + let chunk = match item { + Ok(chunk) => chunk, + Err(source_error) => { + let terminal_error = + release_before_terminal((source, initial_chunks, prefix), source_error); + yield Err(terminal_error); + return; + } + }; + if chunk.is_empty() { + continue; + } + let prefix_remaining = 2_usize.saturating_sub(prefix.len()); + prefix.extend(chunk.iter().take(prefix_remaining)); + initial_chunks.push(chunk); + + match parse_brotli_prefix(&prefix) { + Ok(BrotliPrefix::WindowBits(bits)) => break bits, + Ok(BrotliPrefix::NeedSecondByte) => {} + Err(prefix_error) => { + let terminal_error = + release_before_terminal((source, initial_chunks, prefix), prefix_error); + yield Err(terminal_error); + return; + } + } + }; + + if window_bits > max_window_bits { + let limit_error = EdgeError::response_too_large_with_reason( + format!( + "brotli response advertises a {window_bits}-bit window; limit is {max_window_bits}" + ), + ResponseLimitReason::BrotliWindow, + ); + let terminal_error = + release_before_terminal((source, initial_chunks, prefix), limit_error); + yield Err(terminal_error); + return; + } + let charge = match brotli_decoder_memory_charge(window_bits) { + Ok(charge) => charge, + Err(charge_error) => { + let terminal_error = + release_before_terminal((source, initial_chunks, prefix), charge_error); + yield Err(terminal_error); + return; + } + }; + if charge > max_decoder_bytes { + let limit_error = EdgeError::response_too_large_with_reason( + format!("brotli decoder requires {charge} bytes; limit is {max_decoder_bytes}"), + ResponseLimitReason::DecoderMemory, + ); + let terminal_error = + release_before_terminal((source, initial_chunks, prefix), limit_error); + yield Err(terminal_error); + return; + } + + drop(prefix); + let mut decoded = decode_brotli_payload(source, initial_chunks); + while let Some(item) = decoded.next().await { + match item { + Ok(bytes) => yield Ok(bytes), + Err(error) => { + drop(decoded); + yield Err(error); + return; + } + } + } + } + .boxed_local() +} + +fn decode_brotli_payload(source: BodyStream, initial_chunks: Vec) -> BodyStream { + stream! { + let replayed_stream = futures_stream::iter(initial_chunks.into_iter().map(Ok)).chain(source); + let reader = replayed_stream.map_err(edge_error_to_io).into_async_read(); + let mut decoder = + AsyncBrotliDecoder::with_codec(reader, PinnedBrotliCodec::new()); let mut buffer = vec![0_u8; BUFFER_SIZE]; loop { - let read = decoder.read(&mut buffer).await?; + let read = match decoder.read(&mut buffer).await { + Ok(read) => read, + Err(source_error) => { + let terminal_error = release_before_terminal( + (decoder, buffer), + io_to_edge_error(source_error, BadGatewayDecodeReason::Brotli), + ); + yield Err(terminal_error); + return; + } + }; if read == 0 { break; } - let chunk = buffer.get(..read).ok_or_else(|| { - io::Error::other(format!( - "decoder reported {read}-byte read into a {BUFFER_SIZE}-byte buffer" - )) - })?; - yield Bytes::copy_from_slice(chunk); + let Some(chunk) = buffer.get(..read) else { + let decoder_error = codec_error( + BadGatewayDecodeReason::Brotli, + format!("decoder reported {read}-byte read into a {BUFFER_SIZE}-byte buffer"), + ); + let terminal_error = + release_before_terminal((decoder, buffer), decoder_error); + yield Err(terminal_error); + return; + }; + yield Ok(Bytes::copy_from_slice(chunk)); + } + + // Brotli has no multi-member HTTP representation. Recover any input + // read ahead by the decoder, then poll the native source once more so + // trailing bytes and late transport errors cannot be hidden by codec EOF. + let mut native_reader = decoder.into_inner(); + let mut trailing = [0_u8; 1]; + match native_reader.read(&mut trailing).await { + Ok(0) => {} + Ok(_) => { + let trailing_error = codec_error( + BadGatewayDecodeReason::Brotli, + "brotli response contains trailing data or a second stream", + ); + let terminal_error = release_before_terminal(native_reader, trailing_error); + yield Err(terminal_error); + } + Err(source_error) => { + let terminal_error = release_before_terminal( + native_reader, + io_to_edge_error(source_error, BadGatewayDecodeReason::Brotli), + ); + yield Err(terminal_error); + } } } + .boxed_local() +} + +fn release_before_terminal(owned: Owned, error: EdgeError) -> EdgeError { + drop(owned); + error +} + +fn codec_error(reason: BadGatewayDecodeReason, message: impl Into) -> EdgeError { + EdgeError::bad_gateway_with_reason(message, BadGatewayReason::Decode(reason)) +} + +fn edge_error_to_io(error: EdgeError) -> io::Error { + io::Error::other(EdgeErrorCarrier(error)) +} + +fn io_to_edge_error(error: io::Error, reason: BadGatewayDecodeReason) -> EdgeError { + let diagnostic = error.to_string(); + if let Some(source) = error.into_inner() + && let Ok(carrier) = source.downcast::() + { + return carrier.0; + } + codec_error( + reason, + format!("upstream response decode failed: {diagnostic}"), + ) +} + +fn parse_brotli_prefix(prefix: &[u8]) -> Result { + let Some(&first) = prefix.first() else { + return Ok(BrotliPrefix::NeedSecondByte); + }; + if first & 1 == 0 { + return Ok(BrotliPrefix::WindowBits(16)); + } + let short_code = match first & 0x0f { + 0x03 => Some(18), + 0x05 => Some(19), + 0x07 => Some(20), + 0x09 => Some(21), + 0x0b => Some(22), + 0x0d => Some(23), + 0x0f => Some(24), + _ => None, + }; + if let Some(bits) = short_code { + return Ok(BrotliPrefix::WindowBits(bits)); + } + let long_code = match first & 0x7f { + 0x71 => Some(15), + 0x61 => Some(14), + 0x51 => Some(13), + 0x41 => Some(12), + 0x31 => Some(11), + 0x21 => Some(10), + 0x01 => Some(17), + _ => None, + }; + if let Some(bits) = long_code { + return Ok(BrotliPrefix::WindowBits(bits)); + } + if first != 0x11 { + return Err(codec_error( + BadGatewayDecodeReason::Brotli, + "invalid brotli window prefix", + )); + } + let Some(&second) = prefix.get(1) else { + return Ok(BrotliPrefix::NeedSecondByte); + }; + let bits = second & 0x3f; + if !(10..=30).contains(&bits) { + return Err(codec_error( + BadGatewayDecodeReason::Brotli, + "invalid brotli large-window prefix", + )); + } + Ok(BrotliPrefix::WindowBits(bits)) } #[cfg(test)] mod tests { use super::*; - use brotli::CompressorWriter; - use flate2::{Compression, write::GzEncoder}; + use crate::error::BudgetSource; + use crate::http::{HeaderMap, HeaderValue}; + use brotli::{BrotliState, CompressorWriter, HuffmanCode, enc::StandardAlloc}; + use bytes::Bytes; + use flate2::{ + Compression, + write::{DeflateEncoder, GzEncoder, ZlibEncoder}, + }; use futures::executor::block_on; - use futures_util::stream; + use futures_util::FutureExt as _; + use futures_util::stream::{self, poll_fn}; + use std::cell::Cell; use std::io::Write as _; + use std::mem::size_of; + use std::rc::Rc; + use std::task::Poll; - #[test] - fn decode_gzip_stream_yields_plain_bytes() { + struct DropSignal(Rc>); + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.set(self.0.get().saturating_add(1)); + } + } + + fn source(items: Vec>) -> BodyStream { + stream::iter(items).boxed_local() + } + + fn tracked_source(first_item: Result, drops: &Rc>) -> BodyStream { + let signal = DropSignal(Rc::clone(drops)); + let mut pending_item = Some(first_item); + poll_fn(move |_context| { + let _keep_signal_alive = &signal; + Poll::Ready(pending_item.take()) + }) + .boxed_local() + } + + fn gzip(input: &[u8]) -> Vec { let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(b"hello gzip").unwrap(); - let compressed = encoder.finish().unwrap(); + encoder.write_all(input).unwrap(); + encoder.finish().unwrap() + } + + fn raw_deflate(input: &[u8]) -> Vec { + let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(input).unwrap(); + encoder.finish().unwrap() + } + + fn zlib(input: &[u8]) -> Vec { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(input).unwrap(); + encoder.finish().unwrap() + } + + fn brotli(input: &[u8]) -> Vec { + let mut encoded = Vec::new(); + let mut compressor = CompressorWriter::new(&mut encoded, 4096, 5, 21); + compressor.write_all(input).unwrap(); + drop(compressor); + encoded + } + + #[test] + fn content_encoding_classifier_covers_every_visible_shape() { + let cases = vec![ + (None, ContentEncoding::Identity), + (Some(b"identity".as_slice()), ContentEncoding::Identity), + (Some(b" GZIP \t".as_slice()), ContentEncoding::Gzip), + (Some(b"Br".as_slice()), ContentEncoding::Brotli), + (Some(b" DeFlAtE \t".as_slice()), ContentEncoding::Deflate), + ( + Some(b"zstd".as_slice()), + ContentEncoding::Passthrough(PassthroughReason::Unsupported(String::from("zstd"))), + ), + ( + Some(b" X_Custom ".as_slice()), + ContentEncoding::Passthrough(PassthroughReason::Unsupported(String::from( + "x_custom", + ))), + ), + ( + Some(b"gzip, deflate".as_slice()), + ContentEncoding::Passthrough(PassthroughReason::Stacked), + ), + ( + Some(b"gzip; level=1".as_slice()), + ContentEncoding::Passthrough(PassthroughReason::Malformed), + ), + ( + Some(b"gzip,,deflate".as_slice()), + ContentEncoding::Passthrough(PassthroughReason::Malformed), + ), + ( + Some(b"gzip,".as_slice()), + ContentEncoding::Passthrough(PassthroughReason::Malformed), + ), + ( + Some(b"".as_slice()), + ContentEncoding::Passthrough(PassthroughReason::Malformed), + ), + ( + Some(b" \t".as_slice()), + ContentEncoding::Passthrough(PassthroughReason::Malformed), + ), + ( + Some(b"\xff".as_slice()), + ContentEncoding::Passthrough(PassthroughReason::Malformed), + ), + ]; + + for (value, expected) in cases { + let mut headers = HeaderMap::new(); + if let Some(raw_value) = value { + headers.append( + "content-encoding", + HeaderValue::from_bytes(raw_value).expect("header"), + ); + } + assert_eq!(classify_content_encoding(&headers), expected); + } + + let mut repeated = HeaderMap::new(); + repeated.append("content-encoding", HeaderValue::from_static("gzip")); + repeated.append("content-encoding", HeaderValue::from_static("gzip")); + assert_eq!( + classify_content_encoding(&repeated), + ContentEncoding::Passthrough(PassthroughReason::Stacked) + ); + + let mut repeated_with_malformed_value = HeaderMap::new(); + repeated_with_malformed_value.append( + "content-encoding", + HeaderValue::from_bytes(b"\xff").expect("header"), + ); + repeated_with_malformed_value.append("content-encoding", HeaderValue::from_static("gzip")); + assert_eq!( + classify_content_encoding(&repeated_with_malformed_value), + ContentEncoding::Passthrough(PassthroughReason::Stacked) + ); + } + + #[test] + fn brotli_decoder_memory_charge_is_pinned_and_checked() { + assert_eq!(brotli_decoder_memory_charge(24_u8).unwrap(), 0x0200_0000); + assert!(matches!( + brotli_decoder_memory_charge(9), + Err(EdgeError::BadRequest { .. }) + )); + assert!(matches!( + brotli_decoder_memory_charge(31), + Err(EdgeError::BadRequest { .. }) + )); + } + + #[test] + fn brotli_fixed_charge_covers_source_audited_payload_subtotal() { + type DecoderState = BrotliState; + + const HUFFMAN_CODE_BYTES: u64 = 4; + const MAX_BLOCK_TYPES_OR_TREES: u64 = 256; + const MAX_HUFFMAN_TABLE_ENTRIES: u64 = 1_080; + const DECODER_STATE_RESERVE_BYTES: u64 = 0x0001_0000; + const RING_FIXED_SLACK_BYTES: u64 = 42 + 24; + + let decode_buffer_bytes = u64::try_from(BUFFER_SIZE).expect("buffer size fits u64"); + + assert_eq!( + u64::try_from(size_of::()).expect("HuffmanCode size fits u64"), + HUFFMAN_CODE_BYTES + ); + assert!( + u64::try_from(size_of::()).expect("decoder state size fits u64") + <= DECODER_STATE_RESERVE_BYTES + ); + + let context_map_table = MAX_HUFFMAN_TABLE_ENTRIES * HUFFMAN_CODE_BYTES; + let block_trees = 2 * 3 * MAX_HUFFMAN_TABLE_ENTRIES * HUFFMAN_CODE_BYTES; + let context_modes = MAX_BLOCK_TYPES_OR_TREES; + let context_maps = MAX_BLOCK_TYPES_OR_TREES * 64 + MAX_BLOCK_TYPES_OR_TREES * 4; + let huffman_group_indices = 3 * MAX_BLOCK_TYPES_OR_TREES * 4; + let huffman_group_codes = + 3 * MAX_BLOCK_TYPES_OR_TREES * MAX_HUFFMAN_TABLE_ENTRIES * HUFFMAN_CODE_BYTES; + let audited_payload_bytes = DECODER_STATE_RESERVE_BYTES + + context_map_table + + block_trees + + context_modes + + context_maps + + huffman_group_indices + + huffman_group_codes + + decode_buffer_bytes + + RING_FIXED_SLACK_BYTES; + + assert_eq!(audited_payload_bytes, 3_442_530); + assert!(audited_payload_bytes <= BROTLI_DECODER_FIXED_CHARGE_BYTES); + } + + #[test] + fn flate_fixed_charge_covers_source_audited_payload_subtotal() { + use miniz_oxide::inflate::stream::InflateState; - let stream = stream::iter(vec![Ok::, io::Error>(compressed)]); + const INFLATE_STATE_RESERVE_BYTES: u64 = 0x0001_0000; + const CODEC_WRAPPER_RESERVE_BYTES: u64 = 4_096; + const PUBLIC_CHARGE_BYTES: u64 = 0x0010_0000; + + let decode_buffer_bytes = u64::try_from(BUFFER_SIZE).expect("buffer size fits u64"); + let inflate_state_bytes = + u64::try_from(size_of::()).expect("inflate state size fits u64"); + let gzip_codec_bytes = + u64::try_from(size_of::()).expect("gzip codec size fits u64"); + let zlib_codec_bytes = + u64::try_from(size_of::()).expect("zlib codec size fits u64"); + + assert!(inflate_state_bytes <= INFLATE_STATE_RESERVE_BYTES); + assert!(gzip_codec_bytes <= CODEC_WRAPPER_RESERVE_BYTES); + assert!(zlib_codec_bytes <= CODEC_WRAPPER_RESERVE_BYTES); + assert_eq!(decode_buffer_bytes, 8_192); + assert_eq!(FLATE_DECODER_FIXED_CHARGE_BYTES, PUBLIC_CHARGE_BYTES); + + let audited_payload_bytes = INFLATE_STATE_RESERVE_BYTES + decode_buffer_bytes; + assert_eq!(audited_payload_bytes, 73_728); + assert!(audited_payload_bytes <= FLATE_DECODER_FIXED_CHARGE_BYTES); + } + + #[test] + fn decode_gzip_stream_yields_plain_bytes() { + let stream = source(vec![Ok(Bytes::from(gzip(b"hello gzip")))]); let decoded = block_on(async { - decode_gzip_stream(stream) + decode_gzip_stream(stream, FLATE_DECODER_FIXED_CHARGE_BYTES) .try_collect::>() .await .map(|chunks| chunks.concat()) @@ -90,15 +796,41 @@ mod tests { } #[test] - fn decode_brotli_stream_yields_plain_bytes() { - let mut brotli_bytes = Vec::new(); - let mut compressor = CompressorWriter::new(&mut brotli_bytes, 4096, 5, 21); - compressor.write_all(b"hello brotli").unwrap(); - drop(compressor); + fn decode_deflate_stream_yields_plain_bytes_from_zlib_framing() { + let stream = source(vec![Ok(Bytes::from(zlib(b"hello deflate")))]); + let decoded = block_on(async { + decode_deflate_stream(stream, FLATE_DECODER_FIXED_CHARGE_BYTES) + .try_collect::>() + .await + .map(|chunks| chunks.concat()) + }) + .unwrap(); + + assert_eq!(decoded, b"hello deflate"); + } + + #[test] + fn decode_deflate_stream_rejects_raw_rfc1951_framing() { + let stream = source(vec![Ok(Bytes::from(raw_deflate(b"raw deflate")))]); + let result = block_on( + decode_deflate_stream(stream, FLATE_DECODER_FIXED_CHARGE_BYTES) + .try_collect::>(), + ); - let stream = stream::iter(vec![Ok::, io::Error>(brotli_bytes)]); + assert!(matches!( + result, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Decode(BadGatewayDecodeReason::Deflate), + .. + }) + )); + } + + #[test] + fn decode_brotli_stream_yields_plain_bytes() { + let stream = source(vec![Ok(Bytes::from(brotli(b"hello brotli")))]); let decoded = block_on(async { - decode_brotli_stream(stream) + decode_brotli_stream(stream, 24, 1_u64 << 25) .try_collect::>() .await .map(|chunks| chunks.concat()) @@ -110,23 +842,365 @@ mod tests { #[test] fn decode_gzip_stream_surfaces_error_on_invalid_input() { - let garbage = b"this is definitely not a gzip member".to_vec(); - let stream = stream::iter(vec![Ok::, io::Error>(garbage)]); - let result = - block_on(async { decode_gzip_stream(stream).try_collect::>().await }); - assert!(result.is_err(), "invalid gzip must decode to an error"); + let garbage = Bytes::from_static(b"this is definitely not a gzip member"); + let stream = source(vec![Ok(garbage)]); + let result = block_on(async { + decode_gzip_stream(stream, FLATE_DECODER_FIXED_CHARGE_BYTES) + .try_collect::>() + .await + }); + assert!(matches!( + result, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Decode(BadGatewayDecodeReason::Gzip), + .. + }) + )); + } + + #[test] + fn decode_deflate_stream_surfaces_error_on_invalid_input() { + let garbage = Bytes::from_static(b"this is not a zlib-wrapped deflate stream"); + let result = block_on( + decode_deflate_stream(source(vec![Ok(garbage)]), FLATE_DECODER_FIXED_CHARGE_BYTES) + .try_collect::>(), + ); + assert!(matches!( + result, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Decode(BadGatewayDecodeReason::Deflate), + .. + }) + )); } #[test] fn decode_brotli_stream_surfaces_error_on_invalid_input() { // A high-bit-set lead byte is not a valid brotli stream prefix. - let garbage = vec![0xFF_u8; 64]; - let stream = stream::iter(vec![Ok::, io::Error>(garbage)]); + let garbage = Bytes::from(vec![0xFF_u8; 64]); + let stream = source(vec![Ok(garbage)]); let result = block_on(async { - decode_brotli_stream(stream) + decode_brotli_stream(stream, 24, 1_u64 << 25) .try_collect::>() .await }); - assert!(result.is_err(), "invalid brotli must decode to an error"); + assert!(matches!( + result, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Decode(BadGatewayDecodeReason::Brotli), + .. + }) + )); + } + + #[test] + fn terminal_stream_error_releases_source_before_yield() { + let gzip_drops = Rc::new(Cell::new(0_usize)); + let mut gzip_stream = decode_gzip_stream( + tracked_source(Ok(Bytes::from_static(b"not a gzip stream")), &gzip_drops), + FLATE_DECODER_FIXED_CHARGE_BYTES, + ); + block_on(gzip_stream.next()) + .expect("gzip result") + .expect_err("gzip decode error"); + assert_eq!(gzip_drops.get(), 1_usize); + + let deflate_drops = Rc::new(Cell::new(0_usize)); + let mut deflate_stream = decode_deflate_stream( + tracked_source( + Ok(Bytes::from_static(b"not a deflate stream")), + &deflate_drops, + ), + FLATE_DECODER_FIXED_CHARGE_BYTES, + ); + block_on(deflate_stream.next()) + .expect("deflate result") + .expect_err("deflate decode error"); + assert_eq!(deflate_drops.get(), 1_usize); + + let brotli_drops = Rc::new(Cell::new(0_usize)); + let mut brotli_stream = decode_brotli_stream( + tracked_source(Ok(Bytes::from_static(&[0xff])), &brotli_drops), + 24, + 1_u64 << 25, + ); + block_on(brotli_stream.next()) + .expect("brotli result") + .expect_err("brotli prefix error"); + assert_eq!(brotli_drops.get(), 1_usize); + } + + #[test] + fn decoder_carrier_restores_exact_edge_error() { + let expected = BudgetSource::RequestDeadline; + let stream = source(vec![ + Ok(Bytes::from_static(&[0x1f, 0x8b])), + Err(EdgeError::gateway_timeout_caused("late", expected)), + ]); + let result = block_on( + decode_gzip_stream(stream, FLATE_DECODER_FIXED_CHARGE_BYTES).try_collect::>(), + ); + assert!(matches!( + result, + Err(EdgeError::GatewayTimeout { cause, .. }) if cause == expected + )); + } + + #[test] + fn decode_gzip_drains_every_member_to_native_eof() { + let mut encoded = gzip(b"one"); + encoded.extend(gzip(b"two")); + let decoded = block_on( + decode_gzip_stream( + source(vec![Ok(Bytes::from(encoded))]), + FLATE_DECODER_FIXED_CHARGE_BYTES, + ) + .try_collect::>(), + ) + .unwrap() + .concat(); + assert_eq!(decoded, b"onetwo"); + + let encoded_member = gzip(b"one"); + let result = block_on( + decode_gzip_stream( + source(vec![ + Ok(Bytes::from(encoded_member)), + Err(EdgeError::bad_gateway_with_reason( + "late transport failure", + BadGatewayReason::Transport, + )), + ]), + FLATE_DECODER_FIXED_CHARGE_BYTES, + ) + .try_collect::>(), + ); + assert!(matches!( + result, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Transport, + .. + }) + )); + } + + #[test] + fn decode_deflate_requires_native_eof_and_preserves_late_source_errors() { + let mut with_trailing = zlib(b"one"); + with_trailing.extend_from_slice(b"trailing"); + let trailing_result = block_on( + decode_deflate_stream( + source(vec![Ok(Bytes::from(with_trailing))]), + FLATE_DECODER_FIXED_CHARGE_BYTES, + ) + .try_collect::>(), + ); + assert!(matches!( + trailing_result, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Decode(BadGatewayDecodeReason::Deflate), + .. + }) + )); + + let late_error_result = block_on( + decode_deflate_stream( + source(vec![ + Ok(Bytes::from(zlib(b"one"))), + Err(EdgeError::bad_gateway_with_reason( + "late transport failure", + BadGatewayReason::Transport, + )), + ]), + FLATE_DECODER_FIXED_CHARGE_BYTES, + ) + .try_collect::>(), + ); + assert!(matches!( + late_error_result, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Transport, + .. + }) + )); + } + + #[test] + fn decode_deflate_rejects_a_second_zlib_stream() { + let mut encoded = zlib(b"one"); + encoded.extend(zlib(b"two")); + + let result = block_on( + decode_deflate_stream( + source(vec![Ok(Bytes::from(encoded))]), + FLATE_DECODER_FIXED_CHARGE_BYTES, + ) + .try_collect::>(), + ); + + assert!(matches!( + result, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Decode(BadGatewayDecodeReason::Deflate), + .. + }) + )); + } + + #[test] + fn decode_deflate_waits_for_native_eof_after_codec_eof() { + let mut encoded = Some(Bytes::from(zlib(b"one"))); + let native = poll_fn(move |_context| { + if let Some(chunk) = encoded.take() { + Poll::Ready(Some(Ok(chunk))) + } else { + Poll::Pending + } + }) + .boxed_local(); + let mut decoded = decode_deflate_stream(native, FLATE_DECODER_FIXED_CHARGE_BYTES); + + let first = block_on(decoded.next()) + .expect("decoded item") + .expect("decoded bytes"); + assert_eq!(first, Bytes::from_static(b"one")); + assert!(decoded.next().now_or_never().is_none()); + } + + #[test] + fn flate_memory_limit_rejects_before_source_poll() { + let gzip_polls = Rc::new(Cell::new(0_usize)); + let observed_gzip_polls = Rc::clone(&gzip_polls); + let gzip_source = poll_fn(move |_context| { + observed_gzip_polls.set(observed_gzip_polls.get().saturating_add(1)); + Poll::Ready(None) + }) + .boxed_local(); + let gzip_error = block_on( + decode_gzip_stream( + gzip_source, + FLATE_DECODER_FIXED_CHARGE_BYTES.saturating_sub(1), + ) + .try_collect::>(), + ); + assert!(matches!( + gzip_error, + Err(EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::DecoderMemory, + .. + }) + )); + assert_eq!(gzip_polls.get(), 0_usize); + + let deflate_polls = Rc::new(Cell::new(0_usize)); + let observed_deflate_polls = Rc::clone(&deflate_polls); + let deflate_source = poll_fn(move |_context| { + observed_deflate_polls.set(observed_deflate_polls.get().saturating_add(1)); + Poll::Ready(None) + }) + .boxed_local(); + let deflate_error = block_on( + decode_deflate_stream( + deflate_source, + FLATE_DECODER_FIXED_CHARGE_BYTES.saturating_sub(1), + ) + .try_collect::>(), + ); + assert!(matches!( + deflate_error, + Err(EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::DecoderMemory, + .. + }) + )); + assert_eq!(deflate_polls.get(), 0_usize); + } + + #[test] + fn decode_brotli_rejects_trailing_data() { + let mut encoded = brotli(b"one"); + encoded.extend_from_slice(b"trailing"); + let result = block_on( + decode_brotli_stream(source(vec![Ok(Bytes::from(encoded))]), 24, 1_u64 << 25) + .try_collect::>(), + ); + assert!(matches!( + result, + Err(EdgeError::BadGateway { + reason: BadGatewayReason::Decode(BadGatewayDecodeReason::Brotli), + .. + }) + )); + } + + #[test] + fn brotli_window_parser_covers_standard_and_large_forms() { + let standard = [ + (0x21, 10), + (0x31, 11), + (0x41, 12), + (0x51, 13), + (0x61, 14), + (0x71, 15), + (0x00, 16), + (0x01, 17), + (0x03, 18), + (0x05, 19), + (0x07, 20), + (0x09, 21), + (0x0b, 22), + (0x0d, 23), + (0x0f, 24), + ]; + for (byte, bits) in standard { + assert_eq!( + parse_brotli_prefix(&[byte]).unwrap(), + BrotliPrefix::WindowBits(bits) + ); + } + for bits in 10_u8..=30 { + assert_eq!( + parse_brotli_prefix(&[0x11, bits]).unwrap(), + BrotliPrefix::WindowBits(bits) + ); + } + assert_eq!( + parse_brotli_prefix(&[0x11]).unwrap(), + BrotliPrefix::NeedSecondByte + ); + parse_brotli_prefix(&[0x91, 24]).unwrap_err(); + parse_brotli_prefix(&[0x11, 9]).unwrap_err(); + parse_brotli_prefix(&[0x11, 31]).unwrap_err(); + } + + #[test] + fn brotli_window_rejects_before_decoder_allocation() { + let window_error = block_on( + decode_brotli_stream(source(vec![Ok(Bytes::from_static(&[0x0f]))]), 23, u64::MAX) + .try_collect::>(), + ); + assert!(matches!( + window_error, + Err(EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::BrotliWindow, + .. + }) + )); + + let memory_error = block_on( + decode_brotli_stream( + source(vec![Ok(Bytes::from_static(&[0x0f]))]), + 24, + brotli_decoder_memory_charge(24).unwrap() - 1, + ) + .try_collect::>(), + ); + assert!(matches!( + memory_error, + Err(EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::DecoderMemory, + .. + }) + )); } } diff --git a/crates/edgezero-core/src/config_store.rs b/crates/edgezero-core/src/config_store.rs index 670862339..3fe47e5e1 100644 --- a/crates/edgezero-core/src/config_store.rs +++ b/crates/edgezero-core/src/config_store.rs @@ -5,12 +5,17 @@ //! backends complete synchronously and resolve immediately. use std::fmt; +use std::ops::Deref; use std::sync::Arc; +use std::time::Duration; use anyhow::Error as AnyError; use async_trait::async_trait; use thiserror::Error; +use crate::error::EdgeError; +use crate::time::{DEADLINE_FAR_FUTURE, Deadline, MonotonicClock}; + // --------------------------------------------------------------------------- // Contract test macro // --------------------------------------------------------------------------- @@ -32,7 +37,7 @@ use thiserror::Error; /// AxumConfigStore::from_map([ /// ("contract.key.a".to_owned(), "value_a".to_owned()), /// ("contract.key.b".to_owned(), "value_b".to_owned()), -/// ]) +/// ], ConfigStoreLimits::default()).expect("bounded snapshot") /// }); /// ``` #[macro_export] @@ -52,7 +57,7 @@ macro_rules! config_store_contract_tests { run(async { assert_eq!( store.get("contract.key.a").await.expect("config value"), - Some("value_a".to_owned()) + Some($crate::ConfigValue::from("value_a")) ); }); } @@ -74,11 +79,11 @@ macro_rules! config_store_contract_tests { run(async { assert_eq!( store.get("contract.key.a").await.expect("first config value"), - Some("value_a".to_owned()) + Some($crate::ConfigValue::from("value_a")) ); assert_eq!( store.get("contract.key.b").await.expect("second config value"), - Some("value_b".to_owned()) + Some($crate::ConfigValue::from("value_b")) ); }); } @@ -119,7 +124,7 @@ macro_rules! config_store_contract_tests { run(async { assert_eq!( handle.get("contract.key.a").await.expect("handle value"), - Some("value_a".to_owned()) + Some($crate::ConfigValue::from("value_a")) ); assert_eq!( handle.get("contract.key.missing").await.expect("handle miss"), @@ -153,6 +158,76 @@ macro_rules! config_store_contract_tests { }; } +pub const DEFAULT_CONFIG_BACKEND_BYTES: u64 = 0x0100_0000; +pub const DEFAULT_CONFIG_BLOB_BYTES: u64 = 0x0080_0000; +pub const DEFAULT_CONFIG_EXTRACTION_BYTES: u64 = 0x0100_0000; +pub const DEFAULT_CONFIG_EXTRACTION_TIMEOUT: Duration = Duration::from_secs(30); +pub const DEFAULT_CONFIG_JSON_DEPTH: u32 = 64; +pub const DEFAULT_CONFIG_JSON_NODES: u32 = 8_192; +pub const DEFAULT_CONFIG_SECRET_BYTES: u64 = 0x0010_0000; + +/// Per-extraction limits shared by the root config blob and every referenced secret. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ConfigExtractionLimits { + pub max_backend_bytes: u64, + pub max_blob_bytes: u64, + pub max_json_depth: u32, + pub max_json_nodes: u32, + pub max_secret_bytes: u64, + pub max_total_bytes: u64, + pub timeout: Duration, +} + +impl ConfigExtractionLimits { + /// Validates this startup policy before an adapter begins serving. + /// + /// # Errors + /// Returns an internal policy error for zero, inconsistent, or unbounded values. + #[inline] + pub fn validate(self) -> Result { + if self.max_backend_bytes == 0 + || self.max_blob_bytes == 0 + || self.max_secret_bytes == 0 + || self.max_total_bytes == 0 + { + return Err(EdgeError::internal(anyhow::anyhow!( + "config extraction byte limits must be nonzero" + ))); + } + if self.max_total_bytes < self.max_blob_bytes.max(self.max_secret_bytes) { + return Err(EdgeError::internal(anyhow::anyhow!( + "config extraction total byte limit is below a per-value limit" + ))); + } + if self.max_json_depth == 0 || self.max_json_depth > 127 || self.max_json_nodes == 0 { + return Err(EdgeError::internal(anyhow::anyhow!( + "config extraction JSON structure limits must be finite and nonzero" + ))); + } + if self.timeout.is_zero() || self.timeout > DEADLINE_FAR_FUTURE { + return Err(EdgeError::internal(anyhow::anyhow!( + "config extraction timeout must be finite and nonzero" + ))); + } + Ok(self) + } +} + +impl Default for ConfigExtractionLimits { + #[inline] + fn default() -> Self { + Self { + max_backend_bytes: DEFAULT_CONFIG_BACKEND_BYTES, + max_blob_bytes: DEFAULT_CONFIG_BLOB_BYTES, + max_secret_bytes: DEFAULT_CONFIG_SECRET_BYTES, + max_total_bytes: DEFAULT_CONFIG_EXTRACTION_BYTES, + max_json_depth: DEFAULT_CONFIG_JSON_DEPTH, + max_json_nodes: DEFAULT_CONFIG_JSON_NODES, + timeout: DEFAULT_CONFIG_EXTRACTION_TIMEOUT, + } + } +} + // --------------------------------------------------------------------------- // Trait // --------------------------------------------------------------------------- @@ -163,6 +238,9 @@ macro_rules! config_store_contract_tests { #[derive(Debug, Error)] #[non_exhaustive] pub enum ConfigStoreError { + /// The absolute read deadline expired before a complete value was available. + #[error("config store read deadline exceeded")] + DeadlineExceeded, /// An unexpected backend or provider failure occurred. #[error("config store error: {source}")] Internal { source: AnyError }, @@ -172,6 +250,9 @@ pub enum ConfigStoreError { /// The configured backend cannot currently serve requests. #[error("config store unavailable: {message}")] Unavailable { message: String }, + /// The value or guest-visible backend read exceeded its supplied allowance. + #[error("config store value exceeds configured byte limit")] + ValueTooLarge, } impl ConfigStoreError { @@ -203,10 +284,107 @@ impl ConfigStoreError { } } +/// Classification retained when a declared config store cannot be opened. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConfigStoreOpenFailure { + /// The declared binding or store name is invalid or missing. + InvalidConfiguration, + /// The provider could not resolve or serve the declared binding. + Unavailable, +} + +/// An immutable UTF-8 configuration value. Cloning shares its allocation. +/// +/// Each value owns only its own payload, never a slice retaining a larger store file. +/// Debug output deliberately excludes configuration contents. +#[derive(Clone, Default, Eq, PartialEq)] +pub struct ConfigValue { + value: Arc, +} + +impl ConfigValue { + /// Borrow the UTF-8 payload without allocating. + #[inline] + #[must_use] + pub fn as_str(&self) -> &str { + &self.value + } +} + +impl AsRef for ConfigValue { + #[inline] + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Debug for ConfigValue { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ConfigValue") + .field("bytes", &self.len()) + .finish_non_exhaustive() + } +} + +impl Deref for ConfigValue { + type Target = str; + + #[inline] + fn deref(&self) -> &str { + self.as_str() + } +} + +impl From<&str> for ConfigValue { + #[inline] + fn from(value: &str) -> Self { + Self { + value: Arc::from(value), + } + } +} + +impl From for ConfigValue { + #[inline] + fn from(value: String) -> Self { + Self { + value: Arc::from(value), + } + } +} + +impl serde::Serialize for ConfigValue { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl ConfigStoreOpenFailure { + fn as_error(self) -> ConfigStoreError { + match self { + Self::Unavailable => { + ConfigStoreError::unavailable("declared config store is unavailable") + } + Self::InvalidConfiguration => ConfigStoreError::internal(anyhow::anyhow!( + "declared config store could not be opened" + )), + } + } +} + +/// Result of one bounded store lookup, including all bytes exposed to guest code. +#[derive(Debug)] +pub struct BoundedStoreRead { + pub backend_bytes: u64, + pub value: Option, +} + /// Object-safe interface for read-only configuration store backends. /// /// Implementations exist per adapter: -/// - `AxumConfigStore` (axum adapter) — env vars + in-memory defaults for dev +/// - `AxumConfigStore` (axum adapter) — bounded immutable local-file snapshots /// - `FastlyConfigStore` (fastly adapter) — Fastly Config Store /// - `CloudflareConfigStore` (cloudflare adapter) — Cloudflare KV namespace /// - `SpinConfigStore` (spin adapter) — Spin KV (`spin_sdk::key_value::Store`) @@ -216,9 +394,82 @@ pub trait ConfigStore: Send + Sync { /// /// # Errors /// Returns [`ConfigStoreError`] if `key` is invalid or the backend is unavailable. - async fn get(&self, key: &str) -> Result, ConfigStoreError>; + async fn get(&self, key: &str) -> Result, ConfigStoreError>; + + /// Retrieves one value under an absolute deadline and independent backend/value caps. + /// + /// Implementations must define their deadline and allocation behavior explicitly. There is + /// no compatibility default because awaiting an unbounded provider call can hide a + /// never-ready future. + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError>; +} + +#[cfg(test)] +macro_rules! ready_config_store_bounded_read { + () => { + fn get_bounded<'life0, 'life1, 'life2, 'async_trait>( + &'life0 self, + key: &'life1 str, + clock: &'life2 $crate::time::MonotonicClock, + deadline: $crate::time::Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> ::std::pin::Pin< + Box< + dyn ::std::future::Future< + Output = Result< + $crate::config_store::BoundedStoreRead<$crate::ConfigValue>, + $crate::config_store::ConfigStoreError, + >, + > + 'async_trait, + >, + > + where + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + if deadline.is_expired_at(clock.now()) { + return Err($crate::config_store::ConfigStoreError::DeadlineExceeded); + } + let Some(result) = ::futures_util::FutureExt::now_or_never(self.get(key)) else { + return Err($crate::config_store::ConfigStoreError::internal( + ::anyhow::anyhow!("ready config-store test fixture returned pending"), + )); + }; + if deadline.is_expired_at(clock.now()) { + return Err($crate::config_store::ConfigStoreError::DeadlineExceeded); + } + let value = result?; + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.len()).map_err(|_length_error| { + $crate::config_store::ConfigStoreError::ValueTooLarge + }) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + return Err($crate::config_store::ConfigStoreError::ValueTooLarge); + } + Ok($crate::config_store::BoundedStoreRead { + backend_bytes, + value, + }) + }) + } + }; } +#[cfg(test)] +pub(crate) use ready_config_store_bounded_read; + // --------------------------------------------------------------------------- // Handle // --------------------------------------------------------------------------- @@ -237,15 +488,42 @@ impl fmt::Debug for ConfigStoreHandle { } impl ConfigStoreHandle { + /// Keep a declared binding resolvable when its provider open fails. + /// + /// Provider diagnostics should be logged by the adapter, not stored in this handle. + #[inline] + #[must_use] + pub fn failed_open(failure: ConfigStoreOpenFailure) -> Self { + Self::new(Arc::new(FailedOpenConfigStore { failure })) + } + /// Get a config value by key. /// /// # Errors /// Returns [`ConfigStoreError`] if `key` is invalid or the backend is unavailable. #[inline] - pub async fn get(&self, key: &str) -> Result, ConfigStoreError> { + pub async fn get(&self, key: &str) -> Result, ConfigStoreError> { self.store.get(key).await } + /// Get a config value under one absolute deadline and two byte limits. + /// + /// # Errors + /// Preserves the provider's typed bounded-read error. + #[inline] + pub async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + self.store + .get_bounded(key, clock, deadline, max_backend_bytes, max_value_bytes) + .await + } + /// Create a new handle wrapping a config store implementation. #[inline] pub fn new(store: Arc) -> Self { @@ -253,6 +531,74 @@ impl ConfigStoreHandle { } } +struct FailedOpenConfigStore { + failure: ConfigStoreOpenFailure, +} + +#[async_trait(?Send)] +impl ConfigStore for FailedOpenConfigStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Err(self.failure.as_error()) + } + + async fn get_bounded( + &self, + _key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + finish_bounded_config_read::( + Err(self.failure.as_error()), + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + } +} + +/// Applies post-read deadline precedence and byte caps to one materialized config result. +/// +/// This helper does not dispatch, await, cancel, or time out the provider operation. A +/// [`ConfigStore::get_bounded`] implementation must still reject an already-expired deadline and +/// arrange any target-specific timer race before calling it. +/// +/// # Errors +/// Returns the provider error, [`ConfigStoreError::DeadlineExceeded`], or +/// [`ConfigStoreError::ValueTooLarge`] according to post-read precedence. +/// Conversion must preserve the UTF-8 bytes exposed by `AsRef`; `String` +/// becomes shared only after the caps pass. Conversion overlap is adapter-accounted. +#[inline] +pub fn finish_bounded_config_read + Into>( + result: Result, ConfigStoreError>, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, +) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + let value = result?; + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.as_ref().len()) + .map_err(|_length_error| ConfigStoreError::ValueTooLarge) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + return Err(ConfigStoreError::ValueTooLarge); + } + let shared_value = value.map(Into::into); + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + Ok(BoundedStoreRead { + backend_bytes, + value: shared_value, + }) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -266,27 +612,34 @@ mod tests { ); use super::*; + use crate::time::{Deadline, MonotonicClock, MonotonicInstant}; use futures::executor::block_on; use std::collections::HashMap; + use std::sync::Mutex; + use std::time::Duration; struct FailingConfigStore; struct TestConfigStore { - data: HashMap, + data: HashMap, } #[async_trait(?Send)] impl ConfigStore for FailingConfigStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Err(ConfigStoreError::unavailable("backend offline")) } + + ready_config_store_bounded_read!(); } #[async_trait(?Send)] impl ConfigStore for TestConfigStore { - async fn get(&self, key: &str) -> Result, ConfigStoreError> { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { Ok(self.data.get(key).cloned()) } + + ready_config_store_bounded_read!(); } impl TestConfigStore { @@ -294,7 +647,7 @@ mod tests { Self { data: entries .iter() - .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .map(|(key, value)| ((*key).to_owned(), ConfigValue::from(*value))) .collect(), } } @@ -304,6 +657,70 @@ mod tests { ConfigStoreHandle::new(Arc::new(TestConfigStore::new(entries))) } + #[test] + fn shared_config_value_clones_payload_without_revealing_it_in_debug() { + let value = ConfigValue::from("credential=private"); + let clone = value.clone(); + assert_eq!(clone.as_ptr(), value.as_ptr()); + assert_eq!(clone.as_str(), "credential=private"); + assert!(!format!("{value:?}").contains("credential=private")); + assert_eq!( + serde_json::to_value(&value).expect("serialize"), + serde_json::json!("credential=private") + ); + } + + #[test] + fn bounded_read_caps_precede_conversion_and_conversion_expiry_wins() { + use std::sync::atomic::{AtomicUsize, Ordering}; + struct ConversionProbe { + calls: Arc, + text: String, + } + impl AsRef for ConversionProbe { + fn as_ref(&self) -> &str { + &self.text + } + } + impl From for ConfigValue { + #[inline] + fn from(value: ConversionProbe) -> Self { + value.calls.fetch_add(1, Ordering::SeqCst); + Self::from(value.text) + } + } + let start = MonotonicInstant::now(); + let end = start.checked_add(Duration::from_secs(1)).expect("end"); + for (cap, expected_conversions) in [(4, 0), (5, 1)] { + let calls = Arc::new(AtomicUsize::new(0)); + let samples = Arc::clone(&calls); + let clock = MonotonicClock::new(move || { + if samples.load(Ordering::SeqCst) == 0 { + start + } else { + end + } + }); + let error = finish_bounded_config_read( + Ok(Some(ConversionProbe { + calls: Arc::clone(&calls), + text: "value".to_owned(), + })), + &clock, + Deadline::at_instant(end), + cap, + cap, + ) + .expect_err("bounded failure"); + if cap == 4 { + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + } else { + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + } + assert_eq!(calls.load(Ordering::SeqCst), expected_conversions); + } + } + #[test] fn config_store_get_returns_none_for_missing_key() { let store_handle = handle(&[]); @@ -318,10 +735,141 @@ mod tests { let store_handle = handle(&[("feature.checkout", "true")]); assert_eq!( block_on(store_handle.get("feature.checkout")).expect("config value"), - Some("true".to_owned()) + Some(ConfigValue::from("true")) ); } + #[test] + fn bounded_exact_cap_succeeds_and_over_cap_discards_value() { + let store_handle = handle(&[("feature.checkout", "true")]); + let clock = MonotonicClock::default(); + let exact = block_on(store_handle.get_bounded( + "feature.checkout", + &clock, + Deadline::after(Duration::from_secs(1)), + 4, + 4, + )) + .expect("exact bounded read"); + assert_eq!(exact.backend_bytes, 4); + assert_eq!(exact.value.as_deref(), Some("true")); + + let error = block_on(store_handle.get_bounded( + "feature.checkout", + &clock, + Deadline::after(Duration::from_secs(1)), + 3, + 4, + )) + .expect_err("backend cap"); + assert!(matches!(error, ConfigStoreError::ValueTooLarge)); + } + + #[test] + fn bounded_read_uses_injected_clock() { + let store_handle = handle(&[("feature.checkout", "true")]); + let process_now = MonotonicInstant::now(); + let injected_now = process_now + .checked_sub(Duration::from_mins(1)) + .expect("injected instant"); + let deadline = Deadline::at_instant( + injected_now + .checked_add(Duration::from_secs(1)) + .expect("deadline instant"), + ); + let clock = MonotonicClock::new(move || injected_now); + + let read = block_on(store_handle.get_bounded("feature.checkout", &clock, deadline, 4, 4)) + .expect("the application clock is still before its deadline"); + + assert_eq!(read.value.as_deref(), Some("true")); + } + + #[test] + fn bounded_read_deadline_wins_ready_provider_error() { + struct AdvancingErrorStore { + now: Arc>, + terminal: MonotonicInstant, + } + + #[async_trait(?Send)] + impl ConfigStore for AdvancingErrorStore { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + *self.now.lock().expect("clock lock") = self.terminal; + Err(ConfigStoreError::unavailable("provider failed at expiry")) + } + + ready_config_store_bounded_read!(); + } + + let start = MonotonicInstant::now(); + let terminal = start + .checked_add(Duration::from_secs(1)) + .expect("terminal instant"); + let now = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&now); + let clock = MonotonicClock::new(move || *clock_now.lock().expect("clock lock")); + let store_handle = ConfigStoreHandle::new(Arc::new(AdvancingErrorStore { now, terminal })); + + let error = block_on(store_handle.get_bounded( + "feature.checkout", + &clock, + Deadline::at_instant(terminal), + 4, + 4, + )) + .expect_err("equality expiry must beat a ready provider error"); + + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + } + + #[test] + fn bounded_limit_defaults_are_finite_and_validation_rejects_invalid_relationships() { + let limits = ConfigExtractionLimits::default(); + assert_eq!(limits.max_blob_bytes, DEFAULT_CONFIG_BLOB_BYTES); + assert_eq!(limits.max_backend_bytes, DEFAULT_CONFIG_BACKEND_BYTES); + assert_eq!(limits.max_secret_bytes, DEFAULT_CONFIG_SECRET_BYTES); + assert_eq!(limits.max_total_bytes, DEFAULT_CONFIG_EXTRACTION_BYTES); + assert_eq!(limits.max_json_depth, DEFAULT_CONFIG_JSON_DEPTH); + assert_eq!(limits.max_json_nodes, DEFAULT_CONFIG_JSON_NODES); + assert_eq!(limits.timeout, DEFAULT_CONFIG_EXTRACTION_TIMEOUT); + limits.validate().expect("valid defaults"); + + let invalid_total = ConfigExtractionLimits { + max_total_bytes: 1, + ..limits + }; + invalid_total + .validate() + .expect_err("total below per-value cap"); + + let invalid_timeout = ConfigExtractionLimits { + timeout: Duration::ZERO, + ..limits + }; + invalid_timeout.validate().expect_err("zero timeout"); + + for invalid in [ + ConfigExtractionLimits { + max_json_depth: 0, + ..limits + }, + ConfigExtractionLimits { + max_json_depth: 128, + ..limits + }, + ConfigExtractionLimits { + max_json_nodes: 0, + ..limits + }, + ] { + invalid.validate().expect_err("invalid JSON structure cap"); + } + } + #[test] fn config_store_handle_debug_output() { let store_handle = handle(&[]); @@ -345,7 +893,7 @@ mod tests { let store_handle = ConfigStoreHandle::new(store); assert_eq!( block_on(store_handle.get("a")).expect("arc-backed config"), - Some("1".to_owned()) + Some(ConfigValue::from("1")) ); } @@ -356,12 +904,109 @@ mod tests { assert!(matches!(err, ConfigStoreError::Unavailable { .. })); } + #[test] + fn declared_failed_open_remains_resolvable_and_typed() { + use crate::store_registry::{ConfigStoreBinding, StoreRegistry}; + + let binding = ConfigStoreBinding { + handle: ConfigStoreHandle::failed_open(ConfigStoreOpenFailure::Unavailable), + default_key: "config".to_owned(), + }; + let registry = StoreRegistry::from_parts( + [("primary".to_owned(), binding)].into(), + "primary".to_owned(), + ) + .expect("declared binding remains registered"); + + assert_eq!(registry.ids().collect::>(), vec!["primary"]); + let clock = MonotonicClock::default(); + let error = block_on( + registry + .default() + .expect("default handle") + .handle + .get_bounded( + "config", + &clock, + Deadline::after(Duration::from_secs(1)), + 1, + 1, + ), + ) + .expect_err("failed open is not a missing key"); + assert!(matches!(error, ConfigStoreError::Unavailable { .. })); + } + + #[test] + fn failed_named_open_does_not_disappear_beside_healthy_default() { + use crate::store_registry::{ConfigStoreBinding, StoreRegistry}; + + let registry = StoreRegistry::from_parts( + [ + ( + "default".to_owned(), + ConfigStoreBinding { + handle: handle(&[("config", "ready")]), + default_key: "config".to_owned(), + }, + ), + ( + "named".to_owned(), + ConfigStoreBinding { + handle: ConfigStoreHandle::failed_open( + ConfigStoreOpenFailure::InvalidConfiguration, + ), + default_key: "config".to_owned(), + }, + ), + ] + .into(), + "default".to_owned(), + ) + .expect("healthy default registry"); + + assert_eq!(registry.ids().collect::>(), vec!["default", "named"]); + let clock = MonotonicClock::default(); + let error = block_on( + registry + .named("named") + .expect("failed declared id remains present") + .handle + .get_bounded( + "config", + &clock, + Deadline::after(Duration::from_secs(1)), + 1, + 1, + ), + ) + .expect_err("named open failed"); + assert!(matches!(error, ConfigStoreError::Internal { .. })); + } + + #[test] + fn failed_open_bounded_read_preserves_deadline_precedence() { + let handle = ConfigStoreHandle::failed_open(ConfigStoreOpenFailure::InvalidConfiguration); + let clock = MonotonicClock::default(); + let deadline = Deadline::at_instant(clock.now()); + let error = block_on(handle.get_bounded("config", &clock, deadline, 1, 1)) + .expect_err("expired deadline wins"); + assert!(matches!(error, ConfigStoreError::DeadlineExceeded)); + } + + #[test] + fn failed_open_structural_failure_is_typed() { + let handle = ConfigStoreHandle::failed_open(ConfigStoreOpenFailure::InvalidConfiguration); + let error = block_on(handle.get("config")).expect_err("open failure"); + assert!(matches!(error, ConfigStoreError::Internal { .. })); + } + #[test] fn config_store_handle_wraps_and_delegates() { let store_handle = handle(&[("timeout_ms", "1500")]); assert_eq!( block_on(store_handle.get("timeout_ms")).expect("config value"), - Some("1500".to_owned()) + Some(ConfigValue::from("1500")) ); assert_eq!( block_on(store_handle.get("missing")).expect("missing config"), diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 4bde2deaf..65d356406 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -1,24 +1,271 @@ +use std::{cell::RefCell, collections::BTreeMap, env, mem}; + use crate::body::Body; -use crate::error::EdgeError; -use crate::http::Request; +use crate::config_store::ConfigExtractionLimits; +use crate::error::{ + BadGatewayReason, BudgetSource, EdgeError, ResponseLimitReason, StoreExtractionReason, +}; +use crate::http::{Extensions, HeaderMap, Method, Request, RequestParts, Uri, Version}; +use crate::ingress::{AdmittedIngress, IngressGrant}; +use crate::outbound::HttpClient; use crate::params::PathParams; -use crate::proxy::ProxyHandle; +use crate::router::RouteMetadata; use crate::store_registry::{ BoundConfigStore, BoundKvStore, BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, }; +use crate::time::{Deadline, MonotonicClock, MonotonicInstant}; +use futures_util::StreamExt as _; use serde::de::DeserializeOwned; +/// Default maximum body size accepted by JSON extractors (8 MiB). +pub const DEFAULT_INBOUND_JSON_BYTES: usize = 8 * 1024 * 1024; +/// Default maximum body size accepted by form extractors (1 MiB). +pub const DEFAULT_INBOUND_FORM_BYTES: usize = 1024 * 1024; + +/// Non-consuming snapshot of inbound body ownership. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum BodyKind { + Cached { len: usize }, + Draining, + Initial, + Poisoned, + Taken, +} + +/// Request-owned variable values supplied by an adapter that cannot expose process environment. +#[derive(Clone, Debug, Default)] +pub struct RuntimeVariables(BTreeMap); + +impl RuntimeVariables { + #[must_use] + #[inline] + pub fn from_vars>(vars: I) -> Self { + Self(vars.into_iter().collect()) + } + + #[must_use] + #[inline] + pub fn get(&self, name: &str) -> Option<&str> { + self.0.get(name).map(String::as_str) + } +} + +enum BodyState { + Cached(bytes::Bytes), + Draining, + Initial(Body), + Poisoned(StoredError), + Taken, +} + +pub(crate) enum FallbackDrainOutcome { + Complete, + Exceeded, + TimedOut, +} + +enum StoredError { + BadGateway(String, BadGatewayReason), + BadRequest(String), + ConfigOutOfDate(String, String), + GatewayTimeout(String, BudgetSource), + Internal(String), + MethodNotAllowed(Method, Vec), + NotFound(String), + NotImplemented(String), + RequestHeaderFieldsTooLarge(String), + RequestTimeout(String), + ResponseTooLarge(String, ResponseLimitReason), + ServiceUnavailable(String), + StoreExtraction(StoreExtractionReason, String, Option), + UriTooLong(String), + Validation(String), +} + +impl StoredError { + fn cancelled() -> Self { + Self::Internal("inbound body drain cancelled".to_owned()) + } + + fn capture(error: EdgeError) -> Self { + match error { + EdgeError::BadGateway { message, reason } => Self::BadGateway(message, reason), + EdgeError::BadRequest { message } => Self::BadRequest(message), + EdgeError::ConfigOutOfDate { + message, + field_path, + } => Self::ConfigOutOfDate(message, field_path), + EdgeError::GatewayTimeout { message, cause } => Self::GatewayTimeout(message, cause), + EdgeError::Internal { source } => Self::Internal(source.to_string()), + EdgeError::MethodNotAllowed { method, allowed } => { + Self::MethodNotAllowed(method, allowed) + } + EdgeError::NotFound { path } => Self::NotFound(path), + EdgeError::NotImplemented { message } => Self::NotImplemented(message), + EdgeError::RequestHeaderFieldsTooLarge { message } => { + Self::RequestHeaderFieldsTooLarge(message) + } + EdgeError::RequestTimeout { message } => Self::RequestTimeout(message), + EdgeError::ResponseTooLarge { message, reason } => { + Self::ResponseTooLarge(message, reason) + } + EdgeError::ServiceUnavailable { message } => Self::ServiceUnavailable(message), + EdgeError::StoreExtraction { + reason, + message, + field_path, + } => Self::StoreExtraction(reason, message, field_path), + EdgeError::UriTooLong { message } => Self::UriTooLong(message), + EdgeError::Validation { message } => Self::Validation(message), + } + } + + fn to_edge_error(&self) -> EdgeError { + match self { + Self::BadGateway(message, reason) => { + EdgeError::bad_gateway_with_reason(message.clone(), *reason) + } + Self::BadRequest(message) => EdgeError::bad_request(message.clone()), + Self::ConfigOutOfDate(message, field_path) => { + EdgeError::config_out_of_date(message.clone(), field_path.clone()) + } + Self::GatewayTimeout(message, cause) => { + EdgeError::gateway_timeout_caused(message.clone(), *cause) + } + Self::Internal(rendered) => EdgeError::internal(anyhow::anyhow!(rendered.clone())), + Self::MethodNotAllowed(method, allowed) => EdgeError::MethodNotAllowed { + method: method.clone(), + allowed: allowed.clone(), + }, + Self::NotFound(path) => EdgeError::not_found(path.clone()), + Self::NotImplemented(message) => EdgeError::not_implemented(message.clone()), + Self::RequestHeaderFieldsTooLarge(message) => { + EdgeError::request_header_fields_too_large(message.clone()) + } + Self::RequestTimeout(message) => EdgeError::request_timeout(message.clone()), + Self::ResponseTooLarge(message, reason) => { + EdgeError::response_too_large_with_reason(message.clone(), *reason) + } + Self::ServiceUnavailable(message) => EdgeError::service_unavailable(message.clone()), + Self::StoreExtraction(reason, message, field_path) => { + EdgeError::store_extraction(*reason, message.clone(), field_path.clone()) + } + Self::UriTooLong(message) => EdgeError::uri_too_long(message.clone()), + Self::Validation(message) => EdgeError::validation(message.clone()), + } + } +} + /// Request context exposed to handlers and middleware. pub struct RequestContext { + body: RefCell, + config_extraction_limits: ConfigExtractionLimits, + ingress_grant: RefCell>, + monotonic_clock: MonotonicClock, + parts: RequestParts, path_params: PathParams, - request: Request, + read_deadline: Option, + request_start: MonotonicInstant, + route_metadata: Option, +} + +struct DrainGuard<'cell> { + armed: bool, + body: &'cell RefCell, +} + +impl DrainGuard<'_> { + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for DrainGuard<'_> { + fn drop(&mut self) { + if self.armed + && let Ok(mut state) = self.body.try_borrow_mut() + { + *state = BodyState::Poisoned(StoredError::cancelled()); + } + } } impl RequestContext { + /// Drains and caches the inbound body under the caller's byte cap. + /// + /// # Errors + /// Returns 400 on overflow, 408 on an admitted read deadline, and preserves a sticky + /// source error for every later accessor. + #[inline] + pub async fn body_bytes(&self, max: usize) -> Result { + let body = { + let mut state = self.body.borrow_mut(); + match mem::replace(&mut *state, BodyState::Draining) { + BodyState::Cached(bytes) => { + let result = check_cached_body(&bytes, max); + *state = BodyState::Cached(bytes); + return result; + } + BodyState::Draining => { + *state = BodyState::Draining; + return Err(EdgeError::internal(anyhow::anyhow!( + "body read already in progress" + ))); + } + BodyState::Initial(body) => body, + BodyState::Poisoned(error) => { + let returned = error.to_edge_error(); + *state = BodyState::Poisoned(error); + return Err(returned); + } + BodyState::Taken => { + *state = BodyState::Taken; + return Err(EdgeError::internal(anyhow::anyhow!( + "body already consumed via take_body" + ))); + } + } + }; + + let mut guard = DrainGuard { + armed: true, + body: &self.body, + }; + let result = drain_body(body, max, self.read_deadline, &self.monotonic_clock).await; + match result { + Ok(bytes) => { + *self.body.borrow_mut() = BodyState::Cached(bytes.clone()); + guard.disarm(); + Ok(bytes) + } + Err(error) => { + let stored = StoredError::capture(error); + let returned = stored.to_edge_error(); + *self.body.borrow_mut() = BodyState::Poisoned(stored); + guard.disarm(); + Err(returned) + } + } + } + + #[must_use] #[inline] - pub fn body(&self) -> &Body { - self.request.body() + pub fn body_kind(&self) -> BodyKind { + match &*self.body.borrow() { + BodyState::Cached(bytes) => BodyKind::Cached { len: bytes.len() }, + BodyState::Draining => BodyKind::Draining, + BodyState::Initial(_) => BodyKind::Initial, + BodyState::Poisoned(_) => BodyKind::Poisoned, + BodyState::Taken => BodyKind::Taken, + } + } + + #[must_use] + #[inline] + pub fn config_extraction_limits(&self) -> ConfigExtractionLimits { + self.config_extraction_limits } /// Resolve the [`BoundConfigStore`] for `id`. Strict lookup: when a @@ -29,8 +276,8 @@ impl RequestContext { /// bug rather than a hand-wired single-handle adapter (spec hard-cutoff). #[inline] pub fn config_store(&self, id: &str) -> Option { - self.request - .extensions() + self.parts + .extensions .get::() .and_then(|registry| registry.named(id)) .map(|binding| binding.handle) @@ -40,10 +287,8 @@ impl RequestContext { #[must_use] #[inline] pub fn config_store_binding(&self, id: &str) -> Option<&ConfigStoreBinding> { - self.request - .extensions() - .get::() - .and_then(|registry| registry.named_ref(id)) + let registry = self.parts.extensions.get::()?; + registry.named_ref(id) } /// Resolve the default [`BoundConfigStore`] — the wired registry's @@ -51,8 +296,8 @@ impl RequestContext { /// See [`Self::config_store`] for the hard-cutoff rationale. #[inline] pub fn config_store_default(&self) -> Option { - self.request - .extensions() + self.parts + .extensions .get::() .and_then(StoreRegistry::default) .map(|binding| binding.handle) @@ -63,10 +308,8 @@ impl RequestContext { #[must_use] #[inline] pub fn config_store_default_binding(&self) -> Option<&ConfigStoreBinding> { - self.request - .extensions() - .get::() - .and_then(|registry| registry.default_ref()) + let registry = self.parts.extensions.get::()?; + registry.default_ref() } /// Clone a request extension of type `T`, if present. Used by the @@ -78,40 +321,82 @@ impl RequestContext { where T: Clone + Send + Sync + 'static, { - self.request.extensions().get::().cloned() + self.parts.extensions.get::().cloned() + } + + #[must_use] + #[inline] + pub fn extensions(&self) -> &Extensions { + &self.parts.extensions } + #[inline] + pub fn extensions_mut(&mut self) -> &mut Extensions { + &mut self.parts.extensions + } + + /// Buffers at most `max` bytes and deserializes form-urlencoded data. + /// /// # Errors - /// Returns [`EdgeError::bad_request`] if the body cannot be deserialized as form-urlencoded data into `T`, or the body is streaming. + /// Returns 400 when the body is oversized or malformed, and preserves body drain errors. #[inline] - pub fn form(&self) -> Result + pub async fn form_within(&self, max: usize) -> Result where T: DeserializeOwned, { - match self.request.body() { - Body::Once(bytes) => serde_urlencoded::from_bytes(bytes.as_ref()) - .map_err(|err| EdgeError::bad_request(format!("invalid form payload: {err}"))), - Body::Stream(_) => Err(EdgeError::bad_request( - "streaming bodies are not supported for form extraction", - )), - } + let bytes = self.body_bytes(max).await?; + serde_urlencoded::from_bytes(bytes.as_ref()) + .map_err(|err| EdgeError::bad_request(format!("invalid form payload: {err}"))) + } + + #[must_use] + #[inline] + pub fn headers(&self) -> &HeaderMap { + &self.parts.headers } #[inline] - pub fn into_request(self) -> Request { - self.request + pub fn headers_mut(&mut self) -> &mut HeaderMap { + &mut self.parts.headers } + #[must_use] + #[inline] + pub fn http_client(&self) -> Option { + self.parts.extensions.get::().cloned() + } + + /// Reassembles the request while preserving an initial or cached body. + /// /// # Errors - /// Returns [`EdgeError::bad_request`] if the body is not valid JSON for `T`. + /// Returns the sticky body error or an internal error if a drain is in progress. #[inline] - pub fn json(&self) -> Result + pub fn into_request(self) -> Result { + let body = match self.body.into_inner() { + BodyState::Cached(bytes) => Body::from(bytes), + BodyState::Draining => { + return Err(EdgeError::internal(anyhow::anyhow!( + "body read in progress" + ))); + } + BodyState::Initial(body) => body, + BodyState::Poisoned(error) => return Err(error.to_edge_error()), + BodyState::Taken => Body::empty(), + }; + Ok(Request::from_parts(self.parts, body)) + } + + /// Buffers at most `max` bytes and deserializes JSON. + /// + /// # Errors + /// Returns 400 when the body is oversized or malformed, and preserves body drain errors. + #[inline] + pub async fn json_within(&self, max: usize) -> Result where T: DeserializeOwned, { - self.request - .body() - .to_json() + let bytes = self.body_bytes(max).await?; + serde_json::from_slice(bytes.as_ref()) .map_err(|err| EdgeError::bad_request(format!("invalid JSON payload: {err}"))) } @@ -122,10 +407,8 @@ impl RequestContext { /// the conventional `"default"` id (spec hard-cutoff). #[inline] pub fn kv_store(&self, id: &str) -> Option { - self.request - .extensions() - .get::() - .and_then(|registry| registry.named(id)) + let registry = self.parts.extensions.get::()?; + registry.named(id) } /// Resolve the default [`BoundKvStore`] — the wired registry's @@ -133,20 +416,72 @@ impl RequestContext { /// See [`Self::kv_store`] for the hard-cutoff rationale. #[inline] pub fn kv_store_default(&self) -> Option { - self.request - .extensions() - .get::() - .and_then(StoreRegistry::default) + let registry = self.parts.extensions.get::()?; + registry.default() + } + + #[must_use] + #[inline] + pub fn method(&self) -> &Method { + &self.parts.method + } + + /// Returns the clock paired with this request's admitted ingress lifetime. + #[must_use] + #[inline] + pub fn monotonic_clock(&self) -> MonotonicClock { + self.monotonic_clock.clone() } #[inline] pub fn new(request: Request, params: PathParams) -> Self { + let (parts, body) = request.into_parts(); + Self { + body: RefCell::new(BodyState::Initial(body)), + config_extraction_limits: ConfigExtractionLimits::default(), + ingress_grant: RefCell::new(None), + monotonic_clock: MonotonicClock::default(), + parts, + path_params: params, + read_deadline: None, + request_start: MonotonicInstant::now(), + route_metadata: None, + } + } + + pub(crate) fn new_routed( + request: Request, + params: PathParams, + route_metadata: RouteMetadata, + ingress: AdmittedIngress, + ) -> Self { + let (request_start, read_deadline, grant, config_extraction_limits, monotonic_clock) = + ingress.into_parts(); + let (parts, body) = request.into_parts(); Self { + body: RefCell::new(BodyState::Initial(body)), + config_extraction_limits, + ingress_grant: RefCell::new(Some(grant)), + monotonic_clock, + parts, path_params: params, - request, + read_deadline: Some(read_deadline), + request_start, + route_metadata: Some(route_metadata), } } + #[must_use] + #[inline] + pub fn parts(&self) -> &RequestParts { + &self.parts + } + + #[inline] + pub fn parts_mut(&mut self) -> &mut RequestParts { + &mut self.parts + } + /// # Errors /// Returns [`EdgeError::bad_request`] if the path parameters cannot be deserialized into `T`. #[inline] @@ -164,11 +499,6 @@ impl RequestContext { &self.path_params } - #[inline] - pub fn proxy_handle(&self) -> Option { - self.request.extensions().get::().cloned() - } - /// # Errors /// Returns [`EdgeError::bad_request`] if the query string cannot be deserialized into `T`. #[inline] @@ -176,19 +506,31 @@ impl RequestContext { where T: DeserializeOwned, { - let query = self.request.uri().query().unwrap_or(""); + let query = self.parts.uri.query().unwrap_or(""); serde_urlencoded::from_str(query) .map_err(|err| EdgeError::bad_request(format!("invalid query string: {err}"))) } + /// Absolute deadline governing the admitted inbound body, if this context entered + /// through the adapter admission seam. + #[must_use] + #[inline] + pub fn read_deadline(&self) -> Option { + self.read_deadline + } + + /// Monotonic instant captured at ingress entry or low-level context construction. + #[must_use] #[inline] - pub fn request(&self) -> &Request { - &self.request + pub fn request_start(&self) -> MonotonicInstant { + self.request_start } + /// Canonical matched route metadata, absent for low-level contexts. + #[must_use] #[inline] - pub fn request_mut(&mut self) -> &mut Request { - &mut self.request + pub fn route_metadata(&self) -> Option<&RouteMetadata> { + self.route_metadata.as_ref() } /// Resolve the [`BoundSecretStore`] for `id`. Strict lookup: when a @@ -198,10 +540,8 @@ impl RequestContext { /// registry under the conventional `"default"` id (spec hard-cutoff). #[inline] pub fn secret_store(&self, id: &str) -> Option { - self.request - .extensions() - .get::() - .and_then(|registry| registry.named(id)) + let registry = self.parts.extensions.get::()?; + registry.named(id) } /// Resolve the default [`BoundSecretStore`] — the wired registry's @@ -209,27 +549,208 @@ impl RequestContext { /// See [`Self::secret_store`] for the hard-cutoff rationale. #[inline] pub fn secret_store_default(&self) -> Option { - self.request - .extensions() - .get::() - .and_then(StoreRegistry::default) + let registry = self.parts.extensions.get::()?; + registry.default() + } + + /// Consumes body ownership without buffering it. + /// + /// # Errors + /// Returns the sticky body error or an internal error if a drain is in progress. + #[inline] + pub fn take_body(&self) -> Result { + let mut state = self.body.borrow_mut(); + match mem::replace(&mut *state, BodyState::Taken) { + BodyState::Cached(bytes) => Ok(Body::from(bytes)), + BodyState::Draining => { + *state = BodyState::Draining; + Err(EdgeError::internal(anyhow::anyhow!( + "body read in progress" + ))) + } + BodyState::Initial(body) => Ok(body), + BodyState::Poisoned(error) => { + let returned = error.to_edge_error(); + *state = BodyState::Poisoned(error); + Err(returned) + } + BodyState::Taken => { + *state = BodyState::Taken; + Ok(Body::empty()) + } + } + } + + /// Takes the application-owned ingress grant at most once. + #[must_use] + #[inline] + pub fn take_ingress_grant(&self) -> Option { + self.ingress_grant.borrow_mut().take() + } + + #[must_use] + #[inline] + pub fn uri(&self) -> &Uri { + &self.parts.uri + } + + /// Reads a request-scoped variable, or the process environment when the adapter provides none. + #[must_use] + #[inline] + pub fn variable(&self, name: &str) -> Option { + if let Some(variables) = self.parts.extensions.get::() { + return variables.get(name).map(str::to_owned); + } + env::var(name).ok() + } + + #[must_use] + #[inline] + pub fn version(&self) -> Version { + self.parts.version + } +} + +fn check_cached_body(bytes: &bytes::Bytes, max: usize) -> Result { + if bytes.len() > max { + return Err(EdgeError::bad_request("request body too large")); + } + Ok(bytes.clone()) +} + +fn check_read_deadline( + deadline: Option, + monotonic_clock: &MonotonicClock, +) -> Result<(), EdgeError> { + if deadline.is_some_and(|candidate| candidate.is_expired_at(monotonic_clock.now())) { + return Err(EdgeError::request_timeout( + "inbound body read deadline exceeded", + )); + } + Ok(()) +} + +pub(crate) async fn drain_body( + body: Body, + max: usize, + deadline: Option, + monotonic_clock: &MonotonicClock, +) -> Result { + check_read_deadline(deadline, monotonic_clock)?; + match body { + Body::Once(bytes) => { + check_read_deadline(deadline, monotonic_clock)?; + check_cached_body(&bytes, max) + } + Body::Stream(mut stream) => { + let mut buffered = Vec::new(); + loop { + check_read_deadline(deadline, monotonic_clock)?; + let next = stream.next().await; + // Deadline wins a simultaneous body/error/EOF observation. + check_read_deadline(deadline, monotonic_clock)?; + let Some(result) = next else { + return Ok(bytes::Bytes::from(buffered)); + }; + let chunk = result?; + let next_len = buffered.len().checked_add(chunk.len()).ok_or_else(|| { + EdgeError::bad_request("request body size accounting overflow") + })?; + if next_len > max { + return Err(EdgeError::bad_request("request body too large")); + } + buffered.extend_from_slice(&chunk); + } + } + } +} + +fn fallback_deadline_outcome( + deadline: Deadline, + monotonic_clock: &MonotonicClock, +) -> Option { + deadline + .is_expired_at(monotonic_clock.now()) + .then_some(FallbackDrainOutcome::TimedOut) +} + +pub(crate) async fn drain_body_discard( + body: Body, + max: usize, + deadline: Deadline, + monotonic_clock: &MonotonicClock, +) -> Result { + if let Some(outcome) = fallback_deadline_outcome(deadline, monotonic_clock) { + return Ok(outcome); + } + match body { + Body::Once(bytes) => { + if let Some(outcome) = fallback_deadline_outcome(deadline, monotonic_clock) { + return Ok(outcome); + } + if bytes.len() > max { + return Ok(FallbackDrainOutcome::Exceeded); + } + Ok(FallbackDrainOutcome::Complete) + } + Body::Stream(mut stream) => { + let mut consumed = 0_usize; + loop { + if let Some(outcome) = fallback_deadline_outcome(deadline, monotonic_clock) { + return Ok(outcome); + } + let next = stream.next().await; + // Deadline wins a simultaneous body/error/EOF observation. + if let Some(outcome) = fallback_deadline_outcome(deadline, monotonic_clock) { + return Ok(outcome); + } + let Some(result) = next else { + return Ok(FallbackDrainOutcome::Complete); + }; + let chunk = match result { + Ok(chunk) => chunk, + Err(EdgeError::RequestTimeout { .. }) => { + return Ok(FallbackDrainOutcome::TimedOut); + } + Err(error) => return Err(error), + }; + let Some(next_consumed) = consumed.checked_add(chunk.len()) else { + return Ok(FallbackDrainOutcome::Exceeded); + }; + consumed = next_consumed; + if consumed > max { + return Ok(FallbackDrainOutcome::Exceeded); + } + } + } } } #[cfg(test)] mod tests { use super::*; - use crate::http::{HeaderValue, Method, StatusCode, Uri, request_builder}; + use crate::config_store::ready_config_store_bounded_read; + use crate::http::{HeaderMap, HeaderValue, Method, StatusCode, request_builder}; + use crate::outbound::{ + HttpClient, OutboundBatch, OutboundBatchDriverEvent, OutboundHttpClient, OutboundRequest, + OutboundResponse, + }; use crate::params::PathParams; - use crate::proxy::{ProxyClient, ProxyHandle, ProxyRequest, ProxyResponse}; use async_trait::async_trait; use bytes::Bytes; use futures::executor::block_on; use futures::stream; + use futures::task::noop_waker_ref; use serde::{Deserialize, Serialize}; + use std::cell::Cell; use std::collections::HashMap; + use std::future::Future as _; + use std::rc::Rc; + use std::sync::{Arc, Mutex}; + use std::task::{Context, Poll}; + use std::time::Duration; - struct DummyClient; + struct DummyOutboundClient; #[derive(Debug, PartialEq, Deserialize, Serialize)] struct PathData { @@ -237,9 +758,28 @@ mod tests { } #[async_trait(?Send)] - impl ProxyClient for DummyClient { - async fn send(&self, _request: ProxyRequest) -> Result { - Ok(ProxyResponse::new(StatusCode::OK, Body::empty())) + impl OutboundHttpClient for DummyOutboundClient { + async fn send(&self, request: OutboundRequest) -> Result { + Ok(OutboundResponse::new( + request.method().clone(), + StatusCode::OK, + HeaderMap::new(), + Body::empty(), + crate::MonotonicClock::default(), + crate::ContentTypeProvenance::Unknown, + )) + } + + fn start_batch_until( + &self, + requests: Vec, + _observation_cutoff: crate::Deadline, + ) -> OutboundBatch { + let slot_count = requests.len(); + OutboundBatch::from_driver( + slot_count, + stream::iter((slot_count != 0).then_some(OutboundBatchDriverEvent::Cutoff)), + ) } } @@ -260,6 +800,28 @@ mod tests { PathParams::new(inner) } + #[test] + fn variable_reads_request_scoped_values() { + let mut request = request_builder() + .method(Method::GET) + .uri("/variables") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(RuntimeVariables::from_vars([( + "API_BASE_URL".to_owned(), + "https://api.example.com".to_owned(), + )])); + let ctx = RequestContext::new(request, PathParams::default()); + + assert_eq!( + ctx.variable("API_BASE_URL").as_deref(), + Some("https://api.example.com") + ); + assert_eq!(ctx.variable("UNKNOWN_VARIABLE"), None); + } + // `RequestContext::config_handle()` was removed. The // present/absent behaviour is now covered by // `config_store_*` tests against a wired `ConfigRegistry`. @@ -272,7 +834,8 @@ mod tests { } let body = Body::from("name=demo"); let ctx = ctx("/submit", body, PathParams::default()); - let parsed: FormData = ctx.form().expect("form data"); + let parsed: FormData = + block_on(ctx.form_within(DEFAULT_INBOUND_FORM_BYTES)).expect("form data"); assert_eq!( parsed, FormData { @@ -284,15 +847,214 @@ mod tests { } #[test] - fn form_streaming_body_not_supported() { + fn body_bytes_drains_once_and_rechecks_each_callers_cap() { + let polls = Rc::new(Cell::new(0_u8)); + let observed_polls = Rc::clone(&polls); + let body = Body::from_stream(stream::poll_fn(move |_cx| { + let current = observed_polls.get(); + observed_polls.set(current.saturating_add(1)); + match current { + 0 => Poll::Ready(Some(Ok(Bytes::from_static(b"ok")))), + _ => Poll::Ready(None), + } + })); + let ctx = ctx("/body", body, PathParams::default()); + + assert_eq!( + block_on(ctx.body_bytes(2)).expect("exact cap"), + Bytes::from_static(b"ok") + ); + let stricter = block_on(ctx.body_bytes(1)).expect_err("stricter cached cap"); + assert_eq!(stricter.status(), StatusCode::BAD_REQUEST); + assert_eq!( + block_on(ctx.body_bytes(2)).expect("cached body remains usable"), + Bytes::from_static(b"ok") + ); + assert_eq!(polls.get(), 2, "the source is drained exactly once"); + } + + #[test] + fn initial_drain_overflow_is_sticky_and_never_retries_source() { + let polls = Rc::new(Cell::new(0_u8)); + let observed_polls = Rc::clone(&polls); + let body = Body::from_stream(stream::poll_fn(move |_cx| { + observed_polls.set(observed_polls.get().saturating_add(1)); + Poll::Ready(Some(Ok(Bytes::from_static(b"too large")))) + })); + let ctx = ctx("/body", body, PathParams::default()); + + let first = block_on(ctx.body_bytes(3)).expect_err("overflow"); + let second = block_on(ctx.body_bytes(usize::MAX)).expect_err("sticky overflow"); + assert_eq!(first.status(), StatusCode::BAD_REQUEST); + assert_eq!(second.status(), first.status()); + assert_eq!(second.message(), first.message()); + assert_eq!(ctx.body_kind(), BodyKind::Poisoned); + assert_eq!(polls.get(), 1, "a poisoned source must not be polled again"); + assert_eq!( + ctx.take_body().expect_err("poison survives take").status(), + StatusCode::BAD_REQUEST + ); + } + + #[test] + fn source_error_replays_typed_fields_without_repolling() { + let body = Body::from_stream(stream::iter([Err(EdgeError::bad_gateway_with_reason( + "upstream body failed", + BadGatewayReason::Transport, + ))])); + let ctx = ctx("/body", body, PathParams::default()); + + for _ in 0_u8..2_u8 { + let error = block_on(ctx.body_bytes(32)).expect_err("sticky source error"); + assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Transport, + .. + } + )); + assert_eq!(error.message(), "upstream body failed"); + } + } + + #[test] + fn cancelled_drain_poison_is_sticky() { + let body = Body::from_stream(stream::poll_fn(|_cx| Poll::Pending)); + let ctx = ctx("/body", body, PathParams::default()); + let mut first = Box::pin(ctx.body_bytes(32)); + let mut task = Context::from_waker(noop_waker_ref()); + assert!(matches!(first.as_mut().poll(&mut task), Poll::Pending)); + assert_eq!(ctx.body_kind(), BodyKind::Draining); + + drop(first); + assert_eq!(ctx.body_kind(), BodyKind::Poisoned); + let error = block_on(ctx.body_bytes(32)).expect_err("cancel poison"); + assert_eq!( + error.message(), + "internal error: inbound body drain cancelled" + ); + } + + #[test] + fn reentrant_read_errors_without_disrupting_first_drain() { + let step = Rc::new(Cell::new(0_u8)); + let observed_step = Rc::clone(&step); + let body = Body::from_stream(stream::poll_fn(move |_cx| match observed_step.get() { + 0 => { + observed_step.set(1); + Poll::Pending + } + 1 => { + observed_step.set(2); + Poll::Ready(Some(Ok(Bytes::from_static(b"ok")))) + } + _ => Poll::Ready(None), + })); + let ctx = ctx("/body", body, PathParams::default()); + let mut first = Box::pin(ctx.body_bytes(2)); + let mut task = Context::from_waker(noop_waker_ref()); + assert!(matches!(first.as_mut().poll(&mut task), Poll::Pending)); + + let reentrant = block_on(ctx.body_bytes(2)).expect_err("reentrant read"); + assert_eq!( + reentrant.message(), + "internal error: body read already in progress" + ); + let Poll::Ready(first_result) = first.as_mut().poll(&mut task) else { + panic!("scripted drain should complete"); + }; + assert_eq!(first_result.expect("first read"), Bytes::from_static(b"ok")); + drop(first); + assert_eq!(ctx.body_kind(), BodyKind::Cached { len: 2 }); + } + + #[test] + fn expired_read_deadline_wins_and_poison_is_request_timeout() { + let mut ctx = ctx("/body", Body::from("available"), PathParams::default()); + ctx.read_deadline = Some(Deadline::after(Duration::ZERO)); + + let first = block_on(ctx.body_bytes(32)).expect_err("expired"); + let second = block_on(ctx.body_bytes(32)).expect_err("sticky expired"); + assert_eq!(first.status(), StatusCode::REQUEST_TIMEOUT); + assert_eq!(second.status(), StatusCode::REQUEST_TIMEOUT); + assert!(matches!(second, EdgeError::RequestTimeout { .. })); + } + + #[test] + fn injected_clock_controls_read_deadline_and_sticky_poison() { + let start = MonotonicInstant::now(); + let now = Arc::new(Mutex::new(start)); + let observed_now = Arc::clone(&now); + let mut ctx = ctx("/body", Body::from("available"), PathParams::default()); + ctx.monotonic_clock = + MonotonicClock::new(move || *observed_now.lock().expect("clock lock")); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + ctx.read_deadline = Some(Deadline::at_instant(deadline)); + *now.lock().expect("clock lock") = deadline; + + let first = block_on(ctx.body_bytes(32)).expect_err("expired"); + let second = block_on(ctx.body_bytes(32)).expect_err("sticky expired"); + assert_eq!(first.status(), StatusCode::REQUEST_TIMEOUT); + assert_eq!(second.status(), StatusCode::REQUEST_TIMEOUT); + assert_eq!(ctx.body_kind(), BodyKind::Poisoned); + } + + #[test] + fn take_body_and_into_request_cover_initial_cached_and_taken() { + let initial_stream = Body::from_stream(stream::iter([Ok(Bytes::from_static(b"stream"))])); + let initial = ctx("/initial", initial_stream, PathParams::default()); + assert!(initial.take_body().expect("initial body").is_stream()); + assert!( + initial + .take_body() + .expect("taken becomes empty") + .into_bytes() + .is_some_and(|body_bytes| body_bytes.is_empty()) + ); + assert_eq!(initial.body_kind(), BodyKind::Taken); + + let cached = ctx("/cached", Body::from("cached"), PathParams::default()); + assert_eq!( + block_on(cached.body_bytes(6)).expect("cache"), + Bytes::from_static(b"cached") + ); + assert_eq!( + cached.take_body().expect("cached body").into_bytes(), + Some(Bytes::from_static(b"cached")) + ); + + let reassembled = ctx("/request", Body::from("body"), PathParams::default()); + assert_eq!( + block_on(reassembled.body_bytes(4)).expect("cache"), + Bytes::from_static(b"body") + ); + let request = reassembled.into_request().expect("reassembled request"); + assert_eq!(request.uri().path(), "/request"); + assert_eq!( + request.into_body().into_bytes(), + Some(Bytes::from_static(b"body")) + ); + } + + #[test] + fn poisoned_into_request_returns_the_stored_error() { + let ctx = ctx("/request", Body::from("oversized"), PathParams::default()); + let first = block_on(ctx.body_bytes(1)).expect_err("overflow"); + let later = ctx.into_request().expect_err("poisoned request"); + assert_eq!(later.status(), first.status()); + assert_eq!(later.message(), first.message()); + } + + #[test] + fn form_streaming_body_is_bounded_and_supported() { let stream = stream::iter(vec![Ok::(Bytes::from("name=demo"))]); - let body = Body::from_stream(stream); + let body = Body::from_external_stream(stream); let ctx = ctx("/submit", body, PathParams::default()); - let err = ctx.form::().expect_err("expected error"); - assert_eq!(err.status(), StatusCode::BAD_REQUEST); - assert!( - err.message() - .contains("streaming bodies are not supported for form extraction") + let parsed: serde_json::Value = + block_on(ctx.form_within(DEFAULT_INBOUND_FORM_BYTES)).expect("form data"); + assert_eq!( + parsed.get("name").and_then(|value| value.as_str()), + Some("demo") ); } @@ -300,7 +1062,8 @@ mod tests { fn form_value_deserialises_successfully() { let body = Body::from("name=demo"); let ctx = ctx("/submit", body, PathParams::default()); - let parsed: serde_json::Value = ctx.form().expect("form data"); + let parsed: serde_json::Value = + block_on(ctx.form_within(DEFAULT_INBOUND_FORM_BYTES)).expect("form data"); assert_eq!( parsed.get("name").and_then(|value| value.as_str()), Some("demo") @@ -310,13 +1073,14 @@ mod tests { #[test] fn invalid_form_returns_bad_request() { #[expect(dead_code, reason = "field exercised only via Deserialize")] - #[derive(Deserialize)] + #[derive(Debug, Deserialize)] struct FormData { age: u8, } let body = Body::from("age=not-a-number"); let ctx = ctx("/submit", body, PathParams::default()); - let err = ctx.form::().err().expect("expected error"); + let err = block_on(ctx.form_within::(DEFAULT_INBOUND_FORM_BYTES)) + .expect_err("expected error"); assert_eq!(err.status(), StatusCode::BAD_REQUEST); assert!(err.message().contains("invalid form payload")); } @@ -325,7 +1089,8 @@ mod tests { fn invalid_json_returns_bad_request() { let body = Body::from(&b"not json"[..]); let ctx = ctx("/echo", body, PathParams::default()); - let err = ctx.json::().expect_err("expected error"); + let err = block_on(ctx.json_within::(DEFAULT_INBOUND_JSON_BYTES)) + .expect_err("expected error"); assert_eq!(err.status(), StatusCode::BAD_REQUEST); assert!(err.message().contains("invalid JSON payload")); } @@ -371,7 +1136,8 @@ mod tests { }) .expect("json body"); let ctx = ctx("/echo", body, PathParams::default()); - let parsed: Payload = ctx.json().expect("json payload"); + let parsed: Payload = + block_on(ctx.json_within(DEFAULT_INBOUND_JSON_BYTES)).expect("json payload"); assert_eq!( parsed, Payload { @@ -380,6 +1146,21 @@ mod tests { ); } + #[test] + fn http_client_is_retrieved_when_present() { + let mut request = request_builder() + .method(Method::GET) + .uri("/outbound") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(HttpClient::with_client(DummyOutboundClient)); + + let ctx = RequestContext::new(request, PathParams::default()); + assert!(ctx.http_client().is_some()); + } + // `RequestContext::kv_handle()` was removed. The // present/absent behaviour is now covered by `kv_store_*` // tests against a wired `KvRegistry`. @@ -393,29 +1174,6 @@ mod tests { assert!(serialized.contains("42")); } - #[test] - fn proxy_handle_forwards_with_dummy_client() { - let handle = ProxyHandle::with_client(DummyClient); - let request = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - let response = block_on(handle.forward(request)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); - } - - #[test] - fn proxy_handle_is_retrieved_when_present() { - let mut request = request_builder() - .method(Method::GET) - .uri("/proxy") - .body(Body::empty()) - .expect("request"); - request - .extensions_mut() - .insert(ProxyHandle::with_client(DummyClient)); - - let ctx = RequestContext::new(request, PathParams::default()); - assert!(ctx.proxy_handle().is_some()); - } - #[test] fn query_defaults_to_empty_when_missing() { #[derive(Debug, Deserialize, PartialEq)] @@ -445,21 +1203,25 @@ mod tests { Body::from("payload"), params(&[("id", "123")]), ); - assert_eq!(ctx.request().uri().path(), "/items/123"); - ctx.request_mut() - .headers_mut() + assert_eq!(ctx.uri().path(), "/items/123"); + ctx.headers_mut() .insert("x-test", HeaderValue::from_static("value")); assert_eq!( - ctx.request() - .headers() + ctx.headers() .get("x-test") .and_then(|value| value.to_str().ok()), Some("value") ); assert_eq!(ctx.path_params().get("id"), Some("123")); - assert_eq!(ctx.body().as_bytes().expect("buffered"), b"payload"); + assert_eq!( + block_on(ctx.body_bytes(7)).expect("buffered"), + Bytes::from_static(b"payload") + ); + assert!(ctx.route_metadata().is_none()); + assert!(ctx.read_deadline().is_none()); + assert!(ctx.take_ingress_grant().is_none()); - let request = ctx.into_request(); + let request = ctx.into_request().expect("request"); assert_eq!(request.uri().path(), "/items/123"); } @@ -545,9 +1307,14 @@ mod tests { struct FixedStore(&'static str); #[async_trait(?Send)] impl ConfigStore for FixedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.to_owned())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.into())) } + + ready_config_store_bounded_read!(); } let primary_handle = ConfigStoreHandle::new(Arc::new(FixedStore("primary"))); @@ -583,13 +1350,13 @@ mod tests { let resolved = ctx.config_store("analytics").expect("analytics handle"); assert_eq!( block_on(resolved.get("key")).expect("config value"), - Some("analytics".to_owned()) + Some(crate::ConfigValue::from("analytics")) ); assert!(ctx.config_store("unknown").is_none()); let default = ctx.config_store_default().expect("default handle"); assert_eq!( block_on(default.get("key")).expect("default config value"), - Some("primary".to_owned()) + Some(crate::ConfigValue::from("primary")) ); } @@ -663,9 +1430,14 @@ mod tests { struct AnyStore; #[async_trait(?Send)] impl ConfigStore for AnyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } + + ready_config_store_bounded_read!(); } let binding = ConfigStoreBinding { @@ -710,9 +1482,14 @@ mod tests { struct AnyStore; #[async_trait(?Send)] impl ConfigStore for AnyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } + + ready_config_store_bounded_read!(); } let registry: ConfigRegistry = StoreRegistry::new( diff --git a/crates/edgezero-core/src/env_config.rs b/crates/edgezero-core/src/env_config.rs index c3f2673c2..3e4f2df03 100644 --- a/crates/edgezero-core/src/env_config.rs +++ b/crates/edgezero-core/src/env_config.rs @@ -9,6 +9,8 @@ //! Every segment is lower-cased on parse, and lookup arguments are lower-cased //! before matching — callers pass lower-case logical ids and get a //! case-insensitive match against the upper-case env-var convention. +//! On Windows the prefix also follows the process environment's case-insensitive +//! key identity; Unix prefixes remain case-sensitive. use std::collections::BTreeMap; use std::env; @@ -66,7 +68,7 @@ impl EnvConfig { { let mut entries = BTreeMap::new(); for (key, value) in vars { - let Some(rest) = key.as_ref().strip_prefix(PREFIX) else { + let Some(rest) = strip_config_prefix(key.as_ref(), cfg!(windows)) else { continue; }; let segments: Vec = @@ -254,7 +256,7 @@ where } fn normalized_merge_key(key: &str) -> String { - if key.starts_with(PREFIX) { + if strip_config_prefix(key, cfg!(windows)).is_some() { key.to_ascii_uppercase() } else { key.to_owned() @@ -271,6 +273,18 @@ fn is_blank_or_control(value: &str) -> bool { || value.chars().any(char::is_control) } +fn strip_config_prefix(key: &str, case_insensitive: bool) -> Option<&str> { + if case_insensitive { + let prefix = key.get(..PREFIX.len())?; + if !prefix.eq_ignore_ascii_case(PREFIX) { + return None; + } + key.get(PREFIX.len()..) + } else { + key.strip_prefix(PREFIX) + } +} + #[cfg(test)] mod tests { use super::*; @@ -329,6 +343,31 @@ mod tests { ]) } + #[test] + fn prefix_matching_preserves_platform_key_identity() { + for (key, exact, folded) in [ + ( + "EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS", + Some("ADAPTER__INGRESS__MAX_CONNECTIONS"), + Some("ADAPTER__INGRESS__MAX_CONNECTIONS"), + ), + ( + "edgezero__ADAPTER__INGRESS__MAX_CONNECTIONS", + None, + Some("ADAPTER__INGRESS__MAX_CONNECTIONS"), + ), + ("EdGeZeRo__adapter__port", None, Some("adapter__port")), + ("EDGEZERO_ADAPTER", None, None), + ("short", None, None), + ("EDGEZERO\u{e9}__adapter", None, None), + ] { + assert_eq!(strip_config_prefix(key, false), exact); + assert_eq!(strip_config_prefix(key, true), folded); + } + let config = EnvConfig::from_vars([("edgezero__ADAPTER__PORT", "9090")]); + assert_eq!(config.adapter_port(), cfg!(windows).then_some("9090")); + } + #[test] fn parses_and_lower_cases_segments() { let cfg = sample(); diff --git a/crates/edgezero-core/src/error.rs b/crates/edgezero-core/src/error.rs index 3928f9abb..66ead0991 100644 --- a/crates/edgezero-core/src/error.rs +++ b/crates/edgezero-core/src/error.rs @@ -7,15 +7,135 @@ use thiserror::Error; use crate::body::Body; use crate::config_store::ConfigStoreError; use crate::http::{ - HeaderValue, Method, Response, StatusCode, - header::{CONTENT_TYPE, RETRY_AFTER}, + HeaderMap, HeaderValue, Method, Response, StatusCode, + header::{ALLOW, CONTENT_TYPE, RETRY_AFTER}, }; use crate::response::{IntoResponse, response_with_body}; +/// Stable identity for an EdgeZero-owned upstream decode failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BadGatewayDecodeReason { + Brotli, + Deflate, + Gzip, + Json, +} + +/// Stable classification for upstream failures that map to HTTP 502. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BadGatewayReason { + Decode(BadGatewayDecodeReason), + Protocol, + Transport, + Unreachable, + Unspecified, +} + +/// Configured budget input that selected an effective deadline. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BudgetSource { + BatchCutoff, + Default, + PerCallTimeout, + RequestDeadline, + Unspecified, +} + +/// Stable identity for a response resource limit enforced by `EdgeZero`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ResponseLimitReason { + BrotliWindow, + BufferedBody, + DecodedBody, + DecoderMemory, + EncodedBody, + HeaderBytes, + HeaderCount, + Unspecified, +} + +/// Stable classification for typed configuration extraction failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum StoreExtractionReason { + BackendFailure, + BackendUnavailable, + DeadlineExceeded, + Deserialization, + IntegrityMismatch, + InvalidKey, + InvalidSecretValue, + MalformedEnvelope, + MissingBlob, + MissingRegistry, + MissingSecret, + SecretBackendUnavailable, + StructuralLimit, + UnknownStore, + UnsupportedVersion, + Validation, + ValueTooLarge, +} + +impl StoreExtractionReason { + fn wire_kind(self) -> &'static str { + match self { + Self::BackendFailure + | Self::IntegrityMismatch + | Self::InvalidSecretValue + | Self::MalformedEnvelope + | Self::MissingRegistry + | Self::StructuralLimit + | Self::UnsupportedVersion + | Self::UnknownStore + | Self::ValueTooLarge => "internal", + Self::BackendUnavailable | Self::DeadlineExceeded | Self::SecretBackendUnavailable => { + "service_unavailable" + } + Self::InvalidKey => "bad_request", + Self::Deserialization | Self::MissingBlob | Self::MissingSecret | Self::Validation => { + "config_out_of_date" + } + } + } + + fn wire_status(self) -> StatusCode { + match self { + Self::BackendFailure + | Self::IntegrityMismatch + | Self::InvalidSecretValue + | Self::MalformedEnvelope + | Self::MissingRegistry + | Self::StructuralLimit + | Self::UnsupportedVersion + | Self::UnknownStore + | Self::ValueTooLarge => StatusCode::INTERNAL_SERVER_ERROR, + Self::BackendUnavailable + | Self::DeadlineExceeded + | Self::Deserialization + | Self::MissingBlob + | Self::MissingSecret + | Self::SecretBackendUnavailable + | Self::Validation => StatusCode::SERVICE_UNAVAILABLE, + Self::InvalidKey => StatusCode::BAD_REQUEST, + } + } +} + /// Application-level error that carries an HTTP status code. #[derive(Debug, Error)] #[non_exhaustive] pub enum EdgeError { + /// Upstream or transport failure. HTTP 502. + #[error("{message}")] + BadGateway { + message: String, + reason: BadGatewayReason, + }, #[error("{message}")] BadRequest { message: String }, /// The blob's `data` shape disagrees with the deployed `C` @@ -24,24 +144,71 @@ pub enum EdgeError { /// `"config_out_of_date"`, carries `Retry-After: 60`. #[error("config out of date: {message}")] ConfigOutOfDate { message: String, field_path: String }, + /// A wall-clock deadline or per-request timeout fired. HTTP 504. + #[error("{message}")] + GatewayTimeout { + message: String, + cause: BudgetSource, + }, #[error("internal error: {source}")] Internal { #[from] source: AnyError, }, - #[error("method {method} not allowed; allowed: {allowed}")] - MethodNotAllowed { method: Method, allowed: String }, + #[error( + "method {method} not allowed; allowed: {}", + allowed_method_names(.allowed) + )] + #[non_exhaustive] + MethodNotAllowed { + method: Method, + allowed: Vec, + }, #[error("no route matched path: {path}")] NotFound { path: String }, #[error("not implemented: {message}")] NotImplemented { message: String }, + #[error("{message}")] + RequestHeaderFieldsTooLarge { message: String }, + #[error("{message}")] + RequestTimeout { message: String }, + /// An upstream response exceeded an EdgeZero-owned resource policy. HTTP 502. + #[error("{message}")] + ResponseTooLarge { + message: String, + reason: ResponseLimitReason, + }, #[error("service unavailable: {message}")] ServiceUnavailable { message: String }, + #[error("{message}")] + StoreExtraction { + reason: StoreExtractionReason, + message: String, + field_path: Option, + }, + #[error("{message}")] + UriTooLong { message: String }, #[error("validation error: {message}")] Validation { message: String }, } impl EdgeError { + #[inline] + pub fn bad_gateway>(message: S) -> Self { + EdgeError::BadGateway { + message: message.into(), + reason: BadGatewayReason::Unspecified, + } + } + + #[inline] + pub fn bad_gateway_with_reason>(message: S, reason: BadGatewayReason) -> Self { + EdgeError::BadGateway { + message: message.into(), + reason, + } + } + #[inline] pub fn bad_request>(message: S) -> Self { EdgeError::BadRequest { @@ -65,34 +232,19 @@ impl EdgeError { } } - /// Construct from a `serde_path_to_error` error returned by - /// the deserialise wrapper around the blob's `data` field. - #[must_use] #[inline] - pub fn config_out_of_date_from_serde(serde_err: &SerdePathError) -> Self { - // The serde message embeds the offending stored VALUE (e.g. `invalid - // type: string "hunter2", expected u32`), and this message is serialised - // into the HTTP error body. The config blob may hold secrets, so the - // VALUE must not escape — report only the category. - // - // The `field_path` STRING segments are redacted (structure kept): a map - // key is indistinguishable from a struct field here and may be a secret, - // and the redaction invariant forbids a stored string on any path. The - // exact path is available from a local `config validate`. See - // `redact_serde_path`. - use serde_json::error::Category; - let category = match serde_err.inner().classify() { - Category::Data => "wrong type or invalid value", - Category::Syntax => "malformed JSON", - Category::Eof => "unexpected end of input", - Category::Io => "i/o error while reading", - }; - Self::ConfigOutOfDate { - message: format!( - "typed app-config is out of date ({category}; value redacted) — \ - run ` config push` for this deploy" - ), - field_path: redact_serde_path(serde_err.path()), + pub fn gateway_timeout>(message: S) -> Self { + EdgeError::GatewayTimeout { + message: message.into(), + cause: BudgetSource::Unspecified, + } + } + + #[inline] + pub fn gateway_timeout_caused>(message: S, cause: BudgetSource) -> Self { + EdgeError::GatewayTimeout { + message: message.into(), + cause, } } @@ -107,11 +259,18 @@ impl EdgeError { pub fn inner(&self) -> Option<&AnyError> { match self { EdgeError::Internal { source } => Some(source), - EdgeError::BadRequest { .. } + EdgeError::BadGateway { .. } + | EdgeError::BadRequest { .. } | EdgeError::ConfigOutOfDate { .. } + | EdgeError::GatewayTimeout { .. } | EdgeError::NotFound { .. } | EdgeError::NotImplemented { .. } + | EdgeError::RequestHeaderFieldsTooLarge { .. } + | EdgeError::RequestTimeout { .. } + | EdgeError::ResponseTooLarge { .. } | EdgeError::MethodNotAllowed { .. } + | EdgeError::StoreExtraction { .. } + | EdgeError::UriTooLong { .. } | EdgeError::Validation { .. } | EdgeError::ServiceUnavailable { .. } => None, } @@ -127,15 +286,25 @@ impl EdgeError { } } - fn kind_str(&self) -> &'static str { + /// Returns the stable public category without provider or application diagnostics. + #[must_use] + #[inline] + pub fn kind(&self) -> &'static str { match self { + EdgeError::BadGateway { .. } => "bad_gateway", EdgeError::BadRequest { .. } => "bad_request", EdgeError::ConfigOutOfDate { .. } => "config_out_of_date", + EdgeError::GatewayTimeout { .. } => "gateway_timeout", EdgeError::Internal { .. } => "internal", EdgeError::MethodNotAllowed { .. } => "method_not_allowed", EdgeError::NotFound { .. } => "not_found", EdgeError::NotImplemented { .. } => "not_implemented", + EdgeError::RequestHeaderFieldsTooLarge { .. } => "request_header_fields_too_large", + EdgeError::RequestTimeout { .. } => "request_timeout", + EdgeError::ResponseTooLarge { .. } => "response_too_large", EdgeError::ServiceUnavailable { .. } => "service_unavailable", + EdgeError::StoreExtraction { reason, .. } => reason.wire_kind(), + EdgeError::UriTooLong { .. } => "uri_too_long", EdgeError::Validation { .. } => "validation", } } @@ -144,14 +313,24 @@ impl EdgeError { #[inline] pub fn message(&self) -> String { match self { - EdgeError::BadRequest { message } + EdgeError::BadGateway { message, .. } + | EdgeError::BadRequest { message } | EdgeError::ConfigOutOfDate { message, .. } + | EdgeError::GatewayTimeout { message, .. } + | EdgeError::RequestHeaderFieldsTooLarge { message } + | EdgeError::RequestTimeout { message } + | EdgeError::ResponseTooLarge { message, .. } + | EdgeError::StoreExtraction { message, .. } + | EdgeError::UriTooLong { message } | EdgeError::Validation { message } | EdgeError::NotImplemented { message } | EdgeError::ServiceUnavailable { message } => message.clone(), EdgeError::NotFound { path } => format!("no route matched path: {path}"), EdgeError::MethodNotAllowed { method, allowed } => { - format!("method {method} not allowed; allowed: {allowed}") + format!( + "method {method} not allowed; allowed: {}", + allowed_method_names(allowed) + ) } EdgeError::Internal { source } => format!("internal error: {source}"), } @@ -160,19 +339,12 @@ impl EdgeError { #[must_use] #[inline] pub fn method_not_allowed(method: &Method, allowed: &[Method]) -> Self { - let mut names = allowed - .iter() - .map(|name| name.as_str().to_owned()) - .collect::>(); - names.sort(); - let allowed_list = if names.is_empty() { - "(none)".to_owned() - } else { - names.join(", ") - }; + let mut sorted_allowed = allowed.to_vec(); + sorted_allowed.sort_by(|left, right| left.as_str().cmp(right.as_str())); + sorted_allowed.dedup(); EdgeError::MethodNotAllowed { method: method.clone(), - allowed: allowed_list, + allowed: sorted_allowed, } } @@ -188,6 +360,52 @@ impl EdgeError { } } + #[inline] + pub fn request_header_fields_too_large>(message: S) -> Self { + Self::RequestHeaderFieldsTooLarge { + message: message.into(), + } + } + + #[inline] + pub fn request_timeout>(message: S) -> Self { + Self::RequestTimeout { + message: message.into(), + } + } + + pub(crate) fn required_response_headers(&self) -> Result { + let mut headers = HeaderMap::new(); + if let EdgeError::MethodNotAllowed { allowed, .. } = self { + let value = HeaderValue::from_str(&allowed_header_value(allowed)) + .map_err(EdgeError::internal)?; + headers.insert(ALLOW, value); + } + if self.kind() == "config_out_of_date" { + headers.insert(RETRY_AFTER, HeaderValue::from_static("60")); + } + Ok(headers) + } + + #[inline] + pub fn response_too_large>(message: S) -> Self { + EdgeError::ResponseTooLarge { + message: message.into(), + reason: ResponseLimitReason::Unspecified, + } + } + + #[inline] + pub fn response_too_large_with_reason>( + message: S, + reason: ResponseLimitReason, + ) -> Self { + EdgeError::ResponseTooLarge { + message: message.into(), + reason, + } + } + #[inline] pub fn service_unavailable>(message: S) -> Self { EdgeError::ServiceUnavailable { @@ -199,32 +417,137 @@ impl EdgeError { #[inline] pub fn status(&self) -> StatusCode { match self { + EdgeError::BadGateway { .. } | EdgeError::ResponseTooLarge { .. } => { + StatusCode::BAD_GATEWAY + } EdgeError::BadRequest { .. } => StatusCode::BAD_REQUEST, EdgeError::ConfigOutOfDate { .. } | EdgeError::ServiceUnavailable { .. } => { StatusCode::SERVICE_UNAVAILABLE } + EdgeError::GatewayTimeout { .. } => StatusCode::GATEWAY_TIMEOUT, + EdgeError::RequestHeaderFieldsTooLarge { .. } => { + StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE + } + EdgeError::RequestTimeout { .. } => StatusCode::REQUEST_TIMEOUT, EdgeError::Validation { .. } => StatusCode::UNPROCESSABLE_ENTITY, EdgeError::NotFound { .. } => StatusCode::NOT_FOUND, EdgeError::MethodNotAllowed { .. } => StatusCode::METHOD_NOT_ALLOWED, EdgeError::NotImplemented { .. } => StatusCode::NOT_IMPLEMENTED, + EdgeError::StoreExtraction { reason, .. } => reason.wire_status(), + EdgeError::UriTooLong { .. } => StatusCode::URI_TOO_LONG, EdgeError::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR, } } + /// Constructs a redacted deserialization failure from a serde path error. + #[must_use] + #[inline] + pub fn store_deserialization_from_serde(serde_err: &SerdePathError) -> Self { + use serde_json::error::Category; + + let category = match serde_err.inner().classify() { + Category::Data => "wrong type or invalid value", + Category::Syntax => "malformed JSON", + Category::Eof => "unexpected end of input", + Category::Io => "i/o error while reading", + }; + let path = redact_serde_path(serde_err.path()); + Self::store_extraction( + StoreExtractionReason::Deserialization, + format!("typed app-config is out of date ({category}; value redacted)"), + Some(path), + ) + } + + #[must_use] + #[inline] + pub fn store_extraction>( + reason: StoreExtractionReason, + message: Msg, + field_path: Option, + ) -> Self { + Self::StoreExtraction { + reason, + message: message.into(), + field_path: field_path.filter(|path| !path.is_empty()), + } + } + + #[must_use] + #[inline] + pub fn store_extraction_reason(&self) -> Option { + match self { + Self::StoreExtraction { reason, .. } => Some(*reason), + Self::BadGateway { .. } + | Self::BadRequest { .. } + | Self::ConfigOutOfDate { .. } + | Self::GatewayTimeout { .. } + | Self::Internal { .. } + | Self::MethodNotAllowed { .. } + | Self::NotFound { .. } + | Self::NotImplemented { .. } + | Self::RequestHeaderFieldsTooLarge { .. } + | Self::RequestTimeout { .. } + | Self::ResponseTooLarge { .. } + | Self::ServiceUnavailable { .. } + | Self::UriTooLong { .. } + | Self::Validation { .. } => None, + } + } + + #[inline] + pub fn uri_too_long>(message: S) -> Self { + Self::UriTooLong { + message: message.into(), + } + } + #[inline] pub fn validation>(message: S) -> Self { EdgeError::Validation { message: message.into(), } } + + fn wire_message(&self) -> String { + match self { + EdgeError::BadGateway { .. } => "bad gateway".to_owned(), + EdgeError::GatewayTimeout { .. } => "gateway timeout".to_owned(), + EdgeError::Internal { .. } => "internal server error".to_owned(), + EdgeError::ResponseTooLarge { .. } => { + "upstream response exceeded configured limits".to_owned() + } + EdgeError::BadRequest { .. } + | EdgeError::ConfigOutOfDate { .. } + | EdgeError::MethodNotAllowed { .. } + | EdgeError::NotFound { .. } + | EdgeError::NotImplemented { .. } + | EdgeError::RequestHeaderFieldsTooLarge { .. } + | EdgeError::RequestTimeout { .. } + | EdgeError::ServiceUnavailable { .. } + | EdgeError::StoreExtraction { .. } + | EdgeError::UriTooLong { .. } + | EdgeError::Validation { .. } => self.message(), + } + } } impl From for EdgeError { #[inline] fn from(err: ConfigStoreError) -> Self { match err { - ConfigStoreError::InvalidKey { message } => EdgeError::bad_request(message), - ConfigStoreError::Unavailable { message } => EdgeError::service_unavailable(message), + ConfigStoreError::DeadlineExceeded => { + EdgeError::service_unavailable("config store read deadline exceeded") + } + ConfigStoreError::InvalidKey { .. } => { + EdgeError::bad_request("config store rejected the requested key") + } + ConfigStoreError::Unavailable { .. } => { + EdgeError::service_unavailable("config store is unavailable") + } + ConfigStoreError::ValueTooLarge => { + EdgeError::internal(anyhow::anyhow!("config store value too large")) + } ConfigStoreError::Internal { source } => EdgeError::internal(source), } } @@ -233,8 +556,8 @@ impl From for EdgeError { impl IntoResponse for EdgeError { #[inline] fn into_response(self) -> Result { - let kind = self.kind_str(); - let is_config_out_of_date = matches!(self, EdgeError::ConfigOutOfDate { .. }); + let required_headers = self.required_response_headers()?; + let kind = self.kind(); // `ConfigOutOfDate { field_path: String::new(), .. }` (the missing-blob // path) must OMIT the `field_path` JSON key entirely, not emit // `"field_path": ""`. Per spec 6.3.1. @@ -242,41 +565,70 @@ impl IntoResponse for EdgeError { EdgeError::ConfigOutOfDate { field_path, .. } if !field_path.is_empty() => { Some(field_path.as_str()) } - EdgeError::BadRequest { .. } + EdgeError::StoreExtraction { + field_path: Some(field_path), + .. + } => Some(field_path.as_str()), + EdgeError::BadGateway { .. } + | EdgeError::BadRequest { .. } | EdgeError::ConfigOutOfDate { .. } + | EdgeError::GatewayTimeout { .. } | EdgeError::Internal { .. } | EdgeError::MethodNotAllowed { .. } | EdgeError::NotFound { .. } | EdgeError::NotImplemented { .. } + | EdgeError::RequestHeaderFieldsTooLarge { .. } + | EdgeError::RequestTimeout { .. } + | EdgeError::ResponseTooLarge { .. } | EdgeError::ServiceUnavailable { .. } + | EdgeError::StoreExtraction { .. } + | EdgeError::UriTooLong { .. } | EdgeError::Validation { .. } => None, }; let status = self.status(); - let message = self.message(); + let message = self.wire_message(); - let mut error_obj = serde_json::Map::new(); - error_obj.insert("status".into(), serde_json::Value::from(status.as_u16())); - error_obj.insert("kind".into(), serde_json::Value::from(kind)); - error_obj.insert("message".into(), serde_json::Value::from(message)); - if let Some(field_path) = field_path_opt { - error_obj.insert("field_path".into(), serde_json::Value::from(field_path)); + let mut payload = json!({ + "error": { + "status": status.as_u16(), + "kind": kind, + "message": message, + }, + }); + if let Some(field_path) = field_path_opt + && let Some(error_obj) = payload + .get_mut("error") + .and_then(serde_json::Value::as_object_mut) + { + error_obj.insert("field_path".into(), json!(field_path)); } - let payload = json!({ "error": serde_json::Value::Object(error_obj) }); let body = json_or_text(&payload); let mut response = response_with_body(status, body)?; response .headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - if is_config_out_of_date { - response - .headers_mut() - .insert(RETRY_AFTER, HeaderValue::from_static("60")); - } + response.headers_mut().extend(required_headers); Ok(response) } } +fn allowed_header_value(allowed: &[Method]) -> String { + allowed + .iter() + .map(Method::as_str) + .collect::>() + .join(", ") +} + +fn allowed_method_names(allowed: &[Method]) -> String { + if allowed.is_empty() { + "(none)".to_owned() + } else { + allowed_header_value(allowed) + } +} + /// Render a `serde_path_to_error` path with its STRING segments redacted while /// preserving structure (dots and sequence indices). /// @@ -331,6 +683,229 @@ mod tests { use serde::ser; use std::str; + #[test] + fn bad_gateway_and_gateway_timeout_surface() { + for (err, code, msg) in [ + ( + EdgeError::bad_gateway("upstream refused"), + StatusCode::BAD_GATEWAY, + "upstream refused", + ), + ( + EdgeError::gateway_timeout("deadline expired"), + StatusCode::GATEWAY_TIMEOUT, + "deadline expired", + ), + ] { + assert_eq!(err.status(), code); + assert_eq!(err.message(), msg); + assert!(err.inner().is_none()); + assert!(err.to_string().contains(msg)); + } + } + + #[test] + fn bad_gateway_and_gateway_timeout_json_shape() { + for (err, code, kind, msg) in [ + ( + EdgeError::bad_gateway("nope"), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::bad_gateway_with_reason("nope", BadGatewayReason::Protocol), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::bad_gateway_with_reason("nope", BadGatewayReason::Unreachable), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::bad_gateway_with_reason("nope", BadGatewayReason::Transport), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::gateway_timeout("late"), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + ( + EdgeError::gateway_timeout_caused("late", BudgetSource::PerCallTimeout), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + ( + EdgeError::gateway_timeout_caused("late", BudgetSource::RequestDeadline), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + ( + EdgeError::gateway_timeout_caused("late", BudgetSource::Default), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + ] { + let response = err.into_response().expect("response"); + assert_eq!(response.status().as_u16(), code); + let body_json = parse_body(response); + assert_eq!(body_json["error"]["status"], code); + assert_eq!(body_json["error"]["kind"], serde_json::Value::from(kind)); + assert_eq!(body_json["error"]["message"], serde_json::Value::from(msg)); + assert!( + body_json["error"].get("field_path").is_none(), + "502/504 carry no field_path" + ); + assert!( + body_json["error"].get("reason").is_none(), + "reason is not part of the wire shape" + ); + assert!( + body_json["error"].get("cause").is_none(), + "cause is not part of the wire shape" + ); + } + } + + #[test] + fn bad_gateway_decode_reason_is_not_serialized() { + for reason in [ + BadGatewayDecodeReason::Brotli, + BadGatewayDecodeReason::Deflate, + BadGatewayDecodeReason::Gzip, + BadGatewayDecodeReason::Json, + ] { + let err = EdgeError::bad_gateway_with_reason("nope", BadGatewayReason::Decode(reason)); + let response = err.into_response().expect("response"); + assert_eq!(response.status().as_u16(), 502); + let body_json = parse_body(response); + assert_eq!(body_json["error"]["status"], 502_u16); + assert_eq!(body_json["error"]["kind"], "bad_gateway"); + assert_eq!(body_json["error"]["message"], "bad gateway"); + assert!(body_json["error"].get("reason").is_none()); + } + } + + #[test] + fn server_error_wire_messages_do_not_expose_internal_diagnostics() { + const TOKEN: &str = "super-secret-token"; + let diagnostic = format!( + "provider failed for https://example.com/bid?access_token={TOKEN}: connection refused" + ); + let cases = [ + ( + EdgeError::bad_gateway_with_reason( + diagnostic.clone(), + BadGatewayReason::Unreachable, + ), + "bad gateway", + ), + ( + EdgeError::gateway_timeout_caused(diagnostic.clone(), BudgetSource::PerCallTimeout), + "gateway timeout", + ), + ( + EdgeError::internal(anyhow::anyhow!(diagnostic.clone())), + "internal server error", + ), + ( + EdgeError::response_too_large_with_reason( + diagnostic, + ResponseLimitReason::EncodedBody, + ), + "upstream response exceeded configured limits", + ), + ]; + + for (error, public_message) in cases { + assert!( + error.message().contains(TOKEN), + "the diagnostic remains available before wire conversion" + ); + let response = error.into_response().expect("response"); + let body = parse_body(response); + assert_eq!(body["error"]["message"], public_message); + let encoded = body.to_string(); + assert!(!encoded.contains(TOKEN), "token escaped in {encoded}"); + assert!( + !encoded.contains("access_token"), + "query escaped in {encoded}" + ); + assert!( + !encoded.contains("connection refused"), + "provider diagnostic escaped in {encoded}" + ); + } + } + + #[test] + fn config_store_provider_diagnostics_do_not_reach_wire() { + const TOKEN: &str = "provider-secret-token"; + let cases = [ + ( + ConfigStoreError::InvalidKey { + message: format!("invalid key includes {TOKEN}"), + }, + "config store rejected the requested key", + ), + ( + ConfigStoreError::Unavailable { + message: format!("provider unavailable at https://user:{TOKEN}@config.invalid"), + }, + "config store is unavailable", + ), + ]; + + for (provider_error, public_message) in cases { + let response = EdgeError::from(provider_error) + .into_response() + .expect("response"); + let body = parse_body(response); + assert_eq!(body["error"]["message"], public_message); + assert!(!body.to_string().contains(TOKEN)); + } + } + + #[test] + fn bad_gateway_reason_is_typed() { + let EdgeError::BadGateway { + reason: default_reason, + .. + } = EdgeError::bad_gateway("x") + else { + panic!("expected BadGateway"); + }; + assert_eq!(default_reason, BadGatewayReason::Unspecified); + + for expected in [ + BadGatewayReason::Decode(BadGatewayDecodeReason::Brotli), + BadGatewayReason::Decode(BadGatewayDecodeReason::Deflate), + BadGatewayReason::Decode(BadGatewayDecodeReason::Gzip), + BadGatewayReason::Decode(BadGatewayDecodeReason::Json), + BadGatewayReason::Protocol, + BadGatewayReason::Transport, + BadGatewayReason::Unreachable, + BadGatewayReason::Unspecified, + ] { + let EdgeError::BadGateway { reason, .. } = + EdgeError::bad_gateway_with_reason("x", expected) + else { + panic!("expected BadGateway"); + }; + assert_eq!(reason, expected); + } + } + #[test] fn bad_request_sets_status_and_message() { let err = EdgeError::bad_request("oops"); @@ -338,6 +913,14 @@ mod tests { assert_eq!(err.message(), "oops"); } + #[test] + fn bare_gateway_timeout_is_unspecified() { + let EdgeError::GatewayTimeout { cause, .. } = EdgeError::gateway_timeout("x") else { + panic!("expected GatewayTimeout"); + }; + assert_eq!(cause, BudgetSource::Unspecified); + } + #[test] fn config_out_of_date_constructor_round_trips() { let err = EdgeError::config_out_of_date("missing field", "feature.new_checkout"); @@ -349,12 +932,19 @@ mod tests { assert_eq!(message, "missing field"); assert_eq!(field_path, "feature.new_checkout"); } - EdgeError::BadRequest { .. } + EdgeError::BadGateway { .. } + | EdgeError::BadRequest { .. } + | EdgeError::GatewayTimeout { .. } | EdgeError::Internal { .. } | EdgeError::MethodNotAllowed { .. } | EdgeError::NotFound { .. } | EdgeError::NotImplemented { .. } + | EdgeError::RequestHeaderFieldsTooLarge { .. } + | EdgeError::RequestTimeout { .. } + | EdgeError::ResponseTooLarge { .. } | EdgeError::ServiceUnavailable { .. } + | EdgeError::StoreExtraction { .. } + | EdgeError::UriTooLong { .. } | EdgeError::Validation { .. } => panic!("expected ConfigOutOfDate"), } } @@ -368,7 +958,7 @@ mod tests { } #[test] - fn config_out_of_date_from_serde_extracts_path_and_message() { + fn store_deserialization_from_serde_extracts_path_and_message() { use serde::Deserialize; #[derive(Debug, Deserialize)] @@ -385,32 +975,29 @@ mod tests { // Feed JSON that puts a string where u32 is expected to force a // type-mismatch error at path `service.timeout_ms`. - let json = r#"{"service": {"timeout_ms": "not-a-number"}}"#; - let de = &mut serde_json::Deserializer::from_str(json); + let json = json!({"service": {"timeout_ms": "not-a-number"}}).to_string(); + let de = &mut serde_json::Deserializer::from_str(&json); let result: Result = serde_path_to_error::deserialize(de); let serde_err = result.expect_err("expected deserialization error"); - let err = EdgeError::config_out_of_date_from_serde(&serde_err); + let err = EdgeError::store_deserialization_from_serde(&serde_err); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); assert!(!err.message().is_empty()); - match err { - EdgeError::ConfigOutOfDate { field_path, .. } => { - // String segments redacted, structure preserved. - assert_eq!(field_path, "."); - } - EdgeError::BadRequest { .. } - | EdgeError::Internal { .. } - | EdgeError::MethodNotAllowed { .. } - | EdgeError::NotFound { .. } - | EdgeError::NotImplemented { .. } - | EdgeError::ServiceUnavailable { .. } - | EdgeError::Validation { .. } => panic!("expected ConfigOutOfDate"), - } + let EdgeError::StoreExtraction { + field_path: Some(field_path), + reason: StoreExtractionReason::Deserialization, + .. + } = err + else { + panic!("expected deserialization store extraction"); + }; + // String segments redacted, structure preserved. + assert_eq!(field_path, "."); } #[test] - fn config_out_of_date_from_serde_redacts_map_key_from_path_and_message() { + fn store_deserialization_from_serde_redacts_map_key_from_path_and_message() { use serde::Deserialize; use std::collections::BTreeMap; @@ -431,40 +1018,34 @@ mod tests { // field in the serde path, so every string segment is redacted (structure // kept). const SENTINEL: &str = "SUPER_SECRET_MAP_KEY"; - let json = format!(r#"{{"items": {{"{SENTINEL}": {{"port": "nope"}}}}}}"#); + let json = json!({"items": {(SENTINEL): {"port": "nope"}}}).to_string(); let de = &mut serde_json::Deserializer::from_str(&json); let result: Result = serde_path_to_error::deserialize(de); let serde_err = result.expect_err("expected deserialization error"); - let err = EdgeError::config_out_of_date_from_serde(&serde_err); - match err { - EdgeError::ConfigOutOfDate { - field_path, - message, - } => { - assert!( - !message.contains(SENTINEL), - "a stored map key must never reach the message: {message}" - ); - assert!( - !field_path.contains(SENTINEL), - "a stored map key must never reach the field_path: {field_path}" - ); - // Structure is preserved (items..port -> redacted, dotted). - assert_eq!(field_path, ".."); - } - EdgeError::BadRequest { .. } - | EdgeError::Internal { .. } - | EdgeError::MethodNotAllowed { .. } - | EdgeError::NotFound { .. } - | EdgeError::NotImplemented { .. } - | EdgeError::ServiceUnavailable { .. } - | EdgeError::Validation { .. } => panic!("expected ConfigOutOfDate"), - } + let err = EdgeError::store_deserialization_from_serde(&serde_err); + let EdgeError::StoreExtraction { + field_path: Some(field_path), + message, + reason: StoreExtractionReason::Deserialization, + } = err + else { + panic!("expected deserialization store extraction"); + }; + assert!( + !message.contains(SENTINEL), + "a stored map key must never reach the message: {message}" + ); + assert!( + !field_path.contains(SENTINEL), + "a stored map key must never reach the field_path: {field_path}" + ); + // Structure is preserved (items..port -> redacted, dotted). + assert_eq!(field_path, ".."); } #[test] - fn config_out_of_date_from_serde_root_error_passes_through_sentinel() { + fn store_deserialization_from_serde_root_error_passes_through_sentinel() { use serde::Deserialize; #[derive(Debug, Deserialize)] @@ -473,33 +1054,30 @@ mod tests { value: u32, } - // A completely invalid JSON causes a root-level error. serde_path_to_error + // A valid JSON string instead of an object causes a root-level error. serde_path_to_error // returns "." as the path sentinel in this case. - let json = r#""not-an-object""#; - let de = &mut serde_json::Deserializer::from_str(json); + let json = json!("not-an-object").to_string(); + let de = &mut serde_json::Deserializer::from_str(&json); let result: Result = serde_path_to_error::deserialize(de); let serde_err = result.expect_err("expected deserialization error"); let expected_path = serde_err.path().to_string(); - let err = EdgeError::config_out_of_date_from_serde(&serde_err); + let err = EdgeError::store_deserialization_from_serde(&serde_err); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); - match err { - EdgeError::ConfigOutOfDate { field_path, .. } => { - // The from_serde constructor passes the library's path through - // verbatim; for root-level errors that is ".". - assert_eq!( - field_path, expected_path, - "field_path should match serde_path_to_error sentinel" - ); - } - EdgeError::BadRequest { .. } - | EdgeError::Internal { .. } - | EdgeError::MethodNotAllowed { .. } - | EdgeError::NotFound { .. } - | EdgeError::NotImplemented { .. } - | EdgeError::ServiceUnavailable { .. } - | EdgeError::Validation { .. } => panic!("expected ConfigOutOfDate"), - } + let EdgeError::StoreExtraction { + field_path: Some(field_path), + reason: StoreExtractionReason::Deserialization, + .. + } = err + else { + panic!("expected deserialization store extraction"); + }; + // The serde constructor passes the library's path through verbatim; + // for root-level errors that is ".". + assert_eq!( + field_path, expected_path, + "field_path should match serde_path_to_error sentinel" + ); } #[test] @@ -513,14 +1091,32 @@ mod tests { fn config_store_error_invalid_key_maps_to_bad_request() { let err = EdgeError::from(ConfigStoreError::invalid_key("invalid config key")); assert_eq!(err.status(), StatusCode::BAD_REQUEST); - assert_eq!(err.message(), "invalid config key"); + assert_eq!(err.message(), "config store rejected the requested key"); } #[test] fn config_store_error_unavailable_maps_to_service_unavailable() { let err = EdgeError::from(ConfigStoreError::unavailable("backend offline")); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(err.message(), "backend offline"); + assert_eq!(err.message(), "config store is unavailable"); + } + + #[test] + fn gateway_timeout_caused_preserves_cause() { + for expected in [ + BudgetSource::BatchCutoff, + BudgetSource::Default, + BudgetSource::PerCallTimeout, + BudgetSource::RequestDeadline, + BudgetSource::Unspecified, + ] { + let EdgeError::GatewayTimeout { cause, .. } = + EdgeError::gateway_timeout_caused("x", expected) + else { + panic!("expected GatewayTimeout"); + }; + assert_eq!(cause, expected); + } } #[test] @@ -582,9 +1178,36 @@ mod tests { #[test] fn method_not_allowed_lists_methods_sorted() { - let err = EdgeError::method_not_allowed(&Method::POST, &[Method::GET, Method::DELETE]); + let err = EdgeError::method_not_allowed( + &Method::POST, + &[Method::GET, Method::DELETE, Method::GET], + ); assert_eq!(err.status(), StatusCode::METHOD_NOT_ALLOWED); assert!(err.message().contains("allowed: DELETE, GET")); + assert_eq!( + err.to_string(), + "method POST not allowed; allowed: DELETE, GET" + ); + let EdgeError::MethodNotAllowed { allowed, .. } = err else { + panic!("expected MethodNotAllowed"); + }; + assert_eq!(allowed, vec![Method::DELETE, Method::GET]); + } + + #[test] + fn method_not_allowed_response_emits_sorted_deduplicated_allow_header() { + let response = EdgeError::method_not_allowed( + &Method::POST, + &[Method::GET, Method::DELETE, Method::GET], + ) + .into_response() + .expect("response"); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!( + response.headers().get(ALLOW).expect("Allow header"), + "DELETE, GET" + ); } #[test] @@ -636,6 +1259,7 @@ mod tests { }}; } + assert_kind!(EdgeError::bad_gateway("x"), "bad_gateway", 502_u16); assert_kind!(EdgeError::bad_request("x"), "bad_request", 400_u16); assert_kind!( EdgeError::config_out_of_date("x", "f"), @@ -654,6 +1278,12 @@ mod tests { ); assert_kind!(EdgeError::not_found("/x"), "not_found", 404_u16); assert_kind!(EdgeError::not_implemented("x"), "not_implemented", 501_u16); + assert_kind!( + EdgeError::response_too_large("x"), + "response_too_large", + 502_u16 + ); + assert_kind!(EdgeError::gateway_timeout("x"), "gateway_timeout", 504_u16); assert_kind!( EdgeError::service_unavailable("x"), "service_unavailable", @@ -676,15 +1306,239 @@ mod tests { }}; } + assert_retry_after!(EdgeError::bad_gateway("x"), false); assert_retry_after!(EdgeError::bad_request("x"), false); + assert_retry_after!(EdgeError::gateway_timeout("x"), false); assert_retry_after!(EdgeError::internal(anyhow::anyhow!("x")), false); + assert_retry_after!(EdgeError::response_too_large("x"), false); // ServiceUnavailable is also 503 but must NOT carry Retry-After assert_retry_after!(EdgeError::service_unavailable("x"), false); assert_retry_after!(EdgeError::config_out_of_date("x", "f"), true); } + #[test] + fn response_too_large_constructors_preserve_unspecified_and_specific_reason() { + let EdgeError::ResponseTooLarge { reason, .. } = EdgeError::response_too_large("large") + else { + panic!("expected ResponseTooLarge"); + }; + assert_eq!(reason, ResponseLimitReason::Unspecified); + + let EdgeError::ResponseTooLarge { + reason: specific_reason, + .. + } = EdgeError::response_too_large_with_reason("large", ResponseLimitReason::EncodedBody) + else { + panic!("expected ResponseTooLarge"); + }; + assert_eq!(specific_reason, ResponseLimitReason::EncodedBody); + } + + #[test] + fn response_too_large_preserves_every_reason_without_serializing_it() { + for reason in [ + ResponseLimitReason::BrotliWindow, + ResponseLimitReason::BufferedBody, + ResponseLimitReason::DecodedBody, + ResponseLimitReason::DecoderMemory, + ResponseLimitReason::EncodedBody, + ResponseLimitReason::HeaderBytes, + ResponseLimitReason::HeaderCount, + ResponseLimitReason::Unspecified, + ] { + let error = EdgeError::response_too_large_with_reason("response limit", reason); + assert_eq!(error.status(), StatusCode::BAD_GATEWAY); + let EdgeError::ResponseTooLarge { + reason: stored_reason, + .. + } = &error + else { + panic!("expected ResponseTooLarge"); + }; + assert_eq!(*stored_reason, reason); + + let response = error.into_response().expect("response"); + assert!(response.headers().get(RETRY_AFTER).is_none()); + let body = parse_body(response); + assert_eq!(body["error"]["status"], 502_u16); + assert_eq!(body["error"]["kind"], "response_too_large"); + assert_eq!( + body["error"]["message"], + "upstream response exceeded configured limits" + ); + assert!(body["error"].get("reason").is_none()); + assert!(body["error"].get("field_path").is_none()); + } + } + + #[test] + #[expect( + clippy::too_many_lines, + reason = "the exhaustive extraction-reason wire-policy table is clearer in one test" + )] + fn store_extraction_reason_wire_policy_table() { + let cases = [ + ( + StoreExtractionReason::BackendFailure, + 500_u16, + "internal", + false, + ), + ( + StoreExtractionReason::BackendUnavailable, + 503_u16, + "service_unavailable", + false, + ), + ( + StoreExtractionReason::DeadlineExceeded, + 503_u16, + "service_unavailable", + false, + ), + ( + StoreExtractionReason::IntegrityMismatch, + 500_u16, + "internal", + false, + ), + ( + StoreExtractionReason::MalformedEnvelope, + 500_u16, + "internal", + false, + ), + ( + StoreExtractionReason::InvalidKey, + 400_u16, + "bad_request", + false, + ), + ( + StoreExtractionReason::InvalidSecretValue, + 500_u16, + "internal", + false, + ), + ( + StoreExtractionReason::MissingBlob, + 503_u16, + "config_out_of_date", + true, + ), + ( + StoreExtractionReason::MissingRegistry, + 500_u16, + "internal", + false, + ), + ( + StoreExtractionReason::MissingSecret, + 503_u16, + "config_out_of_date", + true, + ), + ( + StoreExtractionReason::Deserialization, + 503_u16, + "config_out_of_date", + true, + ), + ( + StoreExtractionReason::SecretBackendUnavailable, + 503_u16, + "service_unavailable", + false, + ), + ( + StoreExtractionReason::StructuralLimit, + 500_u16, + "internal", + false, + ), + ( + StoreExtractionReason::UnsupportedVersion, + 500_u16, + "internal", + false, + ), + ( + StoreExtractionReason::UnknownStore, + 500_u16, + "internal", + false, + ), + ( + StoreExtractionReason::Validation, + 503_u16, + "config_out_of_date", + true, + ), + ( + StoreExtractionReason::ValueTooLarge, + 500_u16, + "internal", + false, + ), + ]; + + for (reason, status, kind, retry_after) in cases { + let error = EdgeError::store_extraction( + reason, + "safe extraction diagnostic", + Some(String::from("field")), + ); + assert_eq!(error.store_extraction_reason(), Some(reason)); + let response = error.into_response().expect("response"); + assert_eq!(response.status().as_u16(), status); + assert_eq!(response.headers().contains_key(RETRY_AFTER), retry_after); + let body = parse_body(response); + assert_eq!(body["error"]["kind"], kind); + assert_eq!(body["error"]["field_path"], "field"); + assert!(body["error"].get("reason").is_none()); + } + + let error = EdgeError::store_extraction( + StoreExtractionReason::MissingBlob, + "missing", + Some(String::new()), + ); + let body = parse_body(error.into_response().expect("response")); + assert!(body["error"].get("field_path").is_none()); + } + + #[test] + fn inbound_boundary_errors_have_distinct_status_and_kind() { + for (error, status, kind) in [ + ( + EdgeError::request_header_fields_too_large("headers"), + 431_u16, + "request_header_fields_too_large", + ), + ( + EdgeError::request_timeout("request body deadline exceeded"), + 408_u16, + "request_timeout", + ), + (EdgeError::uri_too_long("target"), 414_u16, "uri_too_long"), + ] { + assert_eq!(error.status().as_u16(), status); + let body = parse_body(error.into_response().expect("response")); + assert_eq!(body["error"]["kind"], kind); + assert!(body["error"].get("field_path").is_none()); + } + } + #[test] fn field_path_only_on_config_out_of_date() { + for err in [EdgeError::bad_gateway("x"), EdgeError::gateway_timeout("x")] { + let body = parse_body(err.into_response().expect("response")); + assert!( + body["error"].get("field_path").is_none(), + "field_path should be absent for gateway errors" + ); + } + let bad_req_err = EdgeError::bad_request("x"); let bad_req_body = parse_body(bad_req_err.into_response().expect("response")); assert!( diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 11902205d..0d5de5bcd 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -1,24 +1,28 @@ use std::any; +use std::fmt; use std::future::Future; use std::ops::{Deref, DerefMut}; use std::pin::Pin; use async_trait::async_trait; use http::header; -use serde::de::DeserializeOwned; +use serde::de::{ + self, DeserializeOwned, DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor, +}; use validator::Validate; use crate::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; use crate::blob_envelope::{BlobEnvelope, BlobEnvelopeError}; -use crate::config_store::ConfigStoreHandle; -use crate::context::RequestContext; -use crate::error::EdgeError; +use crate::config_store::{ConfigExtractionLimits, ConfigStoreError, ConfigStoreHandle}; +use crate::context::{DEFAULT_INBOUND_FORM_BYTES, DEFAULT_INBOUND_JSON_BYTES, RequestContext}; +use crate::error::{EdgeError, StoreExtractionReason}; use crate::http::HeaderMap; use crate::secret_store::SecretError; use crate::store_registry::{ BoundConfigStore, BoundKvStore, BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, }; +use crate::time::{Deadline, MonotonicClock}; use serde::de::IntoDeserializer as _; #[async_trait(?Send)] @@ -35,7 +39,7 @@ where { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - ctx.json().map(Json) + ctx.json_within(DEFAULT_INBOUND_JSON_BYTES).await.map(Json) } } @@ -79,6 +83,47 @@ where } } +/// Validated JSON extractor with a compile-time byte cap. +pub struct ValidatedJsonWithin(pub T); + +#[async_trait(?Send)] +impl FromRequest for ValidatedJsonWithin +where + T: DeserializeOwned + Validate + Send + 'static, +{ + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + let value: T = ctx.json_within(MAX).await?; + value + .validate() + .map_err(|err| EdgeError::validation(err.to_string()))?; + Ok(Self(value)) + } +} + +impl Deref for ValidatedJsonWithin { + type Target = T; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ValidatedJsonWithin { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl ValidatedJsonWithin { + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +} + impl Deref for ValidatedJson { type Target = T; @@ -108,7 +153,7 @@ pub struct Headers(pub HeaderMap); impl FromRequest for Headers { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - Ok(Headers(ctx.request().headers().clone())) + Ok(Headers(ctx.headers().clone())) } } @@ -153,7 +198,7 @@ pub struct Host(pub String); impl FromRequest for Host { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - let headers = ctx.request().headers(); + let headers = ctx.headers(); let host = headers .get(header::HOST) .and_then(|value| value.to_str().ok()) @@ -202,7 +247,7 @@ pub struct ForwardedHost(pub String); impl FromRequest for ForwardedHost { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - let headers = ctx.request().headers(); + let headers = ctx.headers(); let host = headers .get("x-forwarded-host") .or_else(|| headers.get(header::HOST)) @@ -391,7 +436,7 @@ where { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - ctx.form().map(Form) + ctx.form_within(DEFAULT_INBOUND_FORM_BYTES).await.map(Form) } } @@ -458,6 +503,47 @@ impl ValidatedForm { } } +/// Validated form extractor with a compile-time byte cap. +pub struct ValidatedFormWithin(pub T); + +#[async_trait(?Send)] +impl FromRequest for ValidatedFormWithin +where + T: DeserializeOwned + Validate + Send + 'static, +{ + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + let value: T = ctx.form_within(MAX).await?; + value + .validate() + .map_err(|err| EdgeError::validation(err.to_string()))?; + Ok(Self(value)) + } +} + +impl Deref for ValidatedFormWithin { + type Target = T; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ValidatedFormWithin { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl ValidatedFormWithin { + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +} + /// Extractor that yields the per-request [`KvRegistry`]. /// /// Handlers pick a bound store by id at the call site: @@ -493,8 +579,7 @@ impl FromRequest for Kv { // legacy bare-handle inputs to single-id registries at the // dispatch boundary, so this path no longer needs a // fallback — a missing registry is a real bug. - ctx.request() - .extensions() + ctx.extensions() .get::() .cloned() .map(Kv) @@ -615,8 +700,7 @@ impl FromRequest for Secrets { // Hard-cutoff: see `impl FromRequest for Kv`. Adapter // dispatchers normalise legacy bare-handle inputs to // single-id `SecretRegistry`s at the dispatch boundary. - ctx.request() - .extensions() + ctx.extensions() .get::() .cloned() .map(Secrets) @@ -672,8 +756,7 @@ impl FromRequest for Config { // Hard-cutoff: see `impl FromRequest for Kv`. Adapter // dispatchers normalise legacy bare-handle inputs to // single-id `ConfigRegistry`s at the dispatch boundary. - ctx.request() - .extensions() + ctx.extensions() .get::() .cloned() .map(Config) @@ -755,9 +838,11 @@ where #[inline] async fn from_request(ctx: &RequestContext) -> Result { let binding = ctx.config_store_default_binding().ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no default config store registered \u{2014} check [stores.config] in edgezero.toml" - )) + EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, + "no default config store registered; check [stores.config] in edgezero.toml", + None, + ) })?; let key = binding.default_key.clone(); extract_from_handle::(ctx, &binding.handle, &key) @@ -783,9 +868,11 @@ where key: Option<&str>, ) -> Result { let binding = ctx.config_store_binding(store_id).ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no config store registered for id `{store_id}`" - )) + EdgeError::store_extraction( + StoreExtractionReason::UnknownStore, + "no config store is registered for the requested id", + None, + ) })?; let resolved_key = key.unwrap_or(&binding.default_key).to_owned(); extract_from_handle::(ctx, &binding.handle, &resolved_key).await @@ -803,14 +890,270 @@ where #[inline] pub async fn named(ctx: &RequestContext, key: &str) -> Result { let binding = ctx.config_store_default_binding().ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no default config store registered \u{2014} check [stores.config] in edgezero.toml" - )) + EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, + "no default config store registered; check [stores.config] in edgezero.toml", + None, + ) })?; extract_from_handle::(ctx, &binding.handle, key).await } } +/// Extraction-scoped accounting. One instance is created for each public +/// `AppConfig` call and shared by the root blob and all secret reads. +struct ConfigExtractionBudget { + clock: MonotonicClock, + deadline: Deadline, + max_blob_bytes: u64, + max_json_depth: u32, + max_json_nodes: u32, + max_secret_bytes: u64, + remaining_backend_bytes: u64, + remaining_total_bytes: u64, +} + +impl ConfigExtractionBudget { + fn accept_read( + &mut self, + backend_bytes: u64, + value_length: Option, + max_value_bytes: u64, + ) -> Result<(), EdgeError> { + self.check_deadline()?; + + let converted_value_bytes = value_length + .map(u64::try_from) + .transpose() + .map_err(|_length_error| backend_contract_error())?; + if backend_bytes > self.remaining_backend_bytes + || converted_value_bytes + .is_some_and(|bytes| bytes > max_value_bytes || backend_bytes < bytes) + { + return Err(backend_contract_error()); + } + + self.remaining_backend_bytes = self + .remaining_backend_bytes + .checked_sub(backend_bytes) + .ok_or_else(backend_contract_error)?; + if let Some(retained_bytes) = converted_value_bytes { + self.remaining_total_bytes = self + .remaining_total_bytes + .checked_sub(retained_bytes) + .ok_or_else(|| { + store_extraction_error( + StoreExtractionReason::ValueTooLarge, + "typed app-config extraction exceeded its cumulative byte limit", + None, + ) + })?; + } + Ok(()) + } + + fn check_deadline(&self) -> Result<(), EdgeError> { + if self.deadline.is_expired_at(self.clock.now()) { + return Err(store_extraction_error( + StoreExtractionReason::DeadlineExceeded, + "typed app-config extraction deadline exceeded", + None, + )); + } + Ok(()) + } + + fn clock(&self) -> &MonotonicClock { + &self.clock + } + + fn deadline(&self) -> Deadline { + self.deadline + } + + fn max_blob_bytes(&self) -> u64 { + self.max_blob_bytes + } + + fn max_secret_bytes(&self) -> u64 { + self.max_secret_bytes + } + + fn remaining_backend_bytes(&self) -> u64 { + self.remaining_backend_bytes + } + + fn start( + configured_limits: ConfigExtractionLimits, + clock: MonotonicClock, + ) -> Result { + let validated_limits = configured_limits.validate()?; + let started_at = clock.now(); + let deadline = started_at + .checked_add(validated_limits.timeout) + .ok_or_else(|| { + EdgeError::store_extraction( + StoreExtractionReason::BackendFailure, + "config extraction deadline could not be represented", + None, + ) + })?; + Ok(Self { + clock, + deadline: Deadline::at_instant(deadline), + max_blob_bytes: validated_limits.max_blob_bytes, + max_json_depth: validated_limits.max_json_depth, + max_json_nodes: validated_limits.max_json_nodes, + max_secret_bytes: validated_limits.max_secret_bytes, + remaining_backend_bytes: validated_limits.max_backend_bytes, + remaining_total_bytes: validated_limits.max_total_bytes, + }) + } +} + +struct JsonStructureBudget { + exceeded: bool, + max_depth: u32, + max_nodes: u32, + nodes: u32, +} + +struct JsonStructureSeed<'budget> { + budget: &'budget mut JsonStructureBudget, + depth: u32, +} + +impl<'de> DeserializeSeed<'de> for JsonStructureSeed<'_> { + type Value = (); + + fn deserialize>(self, deserializer: D) -> Result<(), D::Error> { + if self.depth > self.budget.max_depth || self.budget.nodes >= self.budget.max_nodes { + self.budget.exceeded = true; + return Err(de::Error::custom("config JSON structure limit exceeded")); + } + self.budget.nodes = self.budget.nodes.saturating_add(1); + deserializer.deserialize_any(JsonStructureVisitor { + budget: self.budget, + depth: self.depth, + }) + } +} + +struct JsonStructureVisitor<'budget> { + budget: &'budget mut JsonStructureBudget, + depth: u32, +} + +#[expect( + clippy::missing_trait_methods, + reason = "serde_json only calls the JSON value visitor methods implemented below" +)] +impl<'de> Visitor<'de> for JsonStructureVisitor<'_> { + type Value = (); + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value") + } + + fn visit_bool(self, _value: bool) -> Result<(), E> { + Ok(()) + } + + fn visit_f64(self, _value: f64) -> Result<(), E> { + Ok(()) + } + + fn visit_i64(self, _value: i64) -> Result<(), E> { + Ok(()) + } + + fn visit_map>(self, mut map: A) -> Result<(), A::Error> { + while map.next_key::()?.is_some() { + map.next_value_seed(JsonStructureSeed { + budget: self.budget, + depth: self.depth.saturating_add(1), + })?; + } + Ok(()) + } + + fn visit_none(self) -> Result<(), E> { + Ok(()) + } + + fn visit_seq>(self, mut seq: A) -> Result<(), A::Error> { + while seq + .next_element_seed(JsonStructureSeed { + budget: self.budget, + depth: self.depth.saturating_add(1), + })? + .is_some() + {} + Ok(()) + } + + fn visit_str(self, _value: &str) -> Result<(), E> { + Ok(()) + } + + fn visit_string(self, _value: String) -> Result<(), E> { + Ok(()) + } + + fn visit_u64(self, _value: u64) -> Result<(), E> { + Ok(()) + } + + fn visit_unit(self) -> Result<(), E> { + Ok(()) + } +} + +fn backend_contract_error() -> EdgeError { + store_extraction_error( + StoreExtractionReason::BackendFailure, + "a store backend violated the bounded-read contract", + None, + ) +} + +fn store_extraction_error( + reason: StoreExtractionReason, + message: impl Into, + field_path: Option, +) -> EdgeError { + EdgeError::store_extraction(reason, message, field_path) +} + +fn check_json_structure(raw: &str, max_depth: u32, max_nodes: u32) -> Result<(), EdgeError> { + let mut budget = JsonStructureBudget { + exceeded: false, + max_depth, + max_nodes, + nodes: 0, + }; + let mut deserializer = serde_json::Deserializer::from_str(raw); + let result = JsonStructureSeed { + budget: &mut budget, + depth: 1, + } + .deserialize(&mut deserializer) + .and_then(|()| deserializer.end()); + match result { + Ok(()) => Ok(()), + Err(_) if budget.exceeded => Err(store_extraction_error( + StoreExtractionReason::StructuralLimit, + "typed app-config blob exceeded its JSON structure limit", + None, + )), + Err(_) => Err(store_extraction_error( + StoreExtractionReason::MalformedEnvelope, + "typed app-config blob is not a valid envelope (details redacted)", + None, + )), + } +} + /// A redacted reason when `raw` is a NEWER format this build must not apply, or /// `None` when it is not (that stays ordinary corruption). /// @@ -855,14 +1198,8 @@ fn future_format_reason(raw: &str) -> Option { /// /// # Errors /// -/// - [`EdgeError::ConfigOutOfDate`] — missing blob, missing secret key, -/// deserialise failure, or validation failure on a non-secret field. -/// - [`EdgeError::Internal`] — envelope parse failure or SHA mismatch -/// (envelope integrity failures indicate a corrupt/tampered store entry, -/// not a stale config — they surface as 500 Internal). -/// - [`EdgeError::ServiceUnavailable`] — config-store backend temporarily down -/// (`ConfigStoreError::Unavailable`). -/// - [`EdgeError::BadRequest`] — malformed key (`ConfigStoreError::InvalidKey`). +/// Returns [`EdgeError::StoreExtraction`] with the stable reason corresponding +/// to store, envelope, secret, or schema failure. async fn extract_from_handle( ctx: &RequestContext, handle: &ConfigStoreHandle, @@ -871,107 +1208,137 @@ async fn extract_from_handle( where C: DeserializeOwned + AppConfigMeta + Validate + Send + 'static, { - // ConfigStoreError → EdgeError uses the existing `impl - // From for EdgeError` at - // `crates/edgezero-core/src/error.rs`, which maps: - // Unavailable → ServiceUnavailable (503) - // InvalidKey → BadRequest (400) - // Internal → Internal (500) - // NEVER `map_err(EdgeError::internal)` here — that collapses - // backpressure / bad-key signals into 500s. - let raw = handle - .get(key) + let mut budget = + ConfigExtractionBudget::start(ctx.config_extraction_limits(), ctx.monotonic_clock())?; + let read = handle + .get_bounded( + key, + budget.clock(), + budget.deadline(), + budget.remaining_backend_bytes(), + budget.max_blob_bytes(), + ) .await - .map_err(EdgeError::from)? - .ok_or_else(|| { - EdgeError::config_out_of_date( + .map_err(|store_error| map_config_store_error(&store_error))?; + budget.accept_read( + read.backend_bytes, + read.value.as_ref().map(|value| value.len()), + budget.max_blob_bytes(), + )?; + let raw = read.value.ok_or_else(|| { + store_extraction_error( + StoreExtractionReason::MissingBlob, + format!( + "missing typed app-config blob at key `{key}`; run ` config push` for this deploy" + ), + None, + ) + })?; + + // Neither parsing nor verification diagnostics may echo stored values. + let parsed = (|| { + check_json_structure(&raw, budget.max_json_depth, budget.max_json_nodes)?; + if let Some(reason) = future_format_reason(&raw) { + return Err(store_extraction_error( + StoreExtractionReason::UnsupportedVersion, format!( - "missing typed app-config blob at key `{key}` — \ - run ` config push` for this deploy" + "typed app-config blob uses {reason}, which this build does not understand; redeploy this service with an updated build" ), - String::new(), + None, + )); + } + let envelope: BlobEnvelope = serde_json::from_str(&raw).map_err(|_err| { + store_extraction_error( + StoreExtractionReason::MalformedEnvelope, + "typed app-config blob is not a valid envelope (details redacted)", + None, ) })?; - // Neither the parse error nor the verify error is echoed into the - // client-facing message: a serde error embeds the offending input, and a - // `BlobEnvelope` integrity failure names the stored hashes — both are - // config-store values that may hold secrets, and this message reaches the - // HTTP body. Report a category only. - // - // A value written by a NEWER format (a bumped envelope version OR an unknown - // `edgezero_kind` discriminator) needs a DIFFERENT remediation than - // corruption: re-pushing the same config cannot help a build older than its - // config; the deployed build must be UPGRADED. The version case is already - // caught by `verify()` below (`UnknownVersion`); the two it does NOT catch are - // an `edgezero_kind` on a v1-shaped value (serde ignores the unknown field) - // and a schema-changed envelope that fails the v1 deserialize outright. A - // cheap substring gate keeps the second full JSON parse (`future_format_reason`, - // which reparses the whole blob as a `Value`) OFF the happy path -- this runs - // per request in the edge guest, over a blob that is by definition large - // wherever chunking matters. The reason names only the version/field, never a - // value, so surfacing it is safe. - let kind_tagged = raw.contains("edgezero_kind"); - let envelope: BlobEnvelope = match serde_json::from_str::(&raw) { - Ok(envelope) if !kind_tagged => envelope, - parsed => { - if let Some(reason) = future_format_reason(&raw) { - return Err(EdgeError::internal(anyhow::anyhow!( - "typed app-config blob uses {reason}, which this build does not understand; \ - redeploy this service with an updated build (re-pushing will not help)" - ))); - } - parsed.map_err(|_err| { - EdgeError::internal(anyhow::anyhow!( - "typed app-config blob is not a valid envelope (details redacted)" - )) - })? - } - }; - envelope.verify().map_err(|err| match err { - BlobEnvelopeError::UnknownVersion(version) => EdgeError::internal(anyhow::anyhow!( - "typed app-config blob uses envelope version {version}, which this build does not \ - understand; redeploy this service with an updated build (re-pushing will not help)" - )), - BlobEnvelopeError::ShaMismatch { .. } => EdgeError::internal(anyhow::anyhow!( - "typed app-config blob failed its integrity check (details redacted)" - )), - })?; - let mut data = envelope.into_data(); - // Secret walk per spec 3.3.3. - secret_walk::(ctx, &mut data).await?; - // Deserialise via serde_path_to_error so failures carry a dotted - // field path for ConfigOutOfDate per spec 4.3. - let cfg: C = serde_path_to_error::deserialize(data.into_deserializer()) - .map_err(|err| EdgeError::config_out_of_date_from_serde(&err))?; - // RUNTIME uses cfg.validate(): after secret_walk the fields hold - // RESOLVED values, so every validator rule — including those on - // secret fields (e.g. length/regex on the actual token value) — - // MUST run. Spec 3.3.8 split: PUSH skips secret-field validators - // (via validate_excluding_secrets) because the value at push time - // is a key NAME; RUNTIME runs cfg.validate() because the value is - // now the resolved secret. - cfg.validate().map_err(|err| { - // SECURITY: `secret_walk` has replaced `#[secret]` fields with their - // RESOLVED values, and `validator`'s error params echo the rejected - // value — so `err.to_string()` here can leak a secret into the HTTP - // response body and logs. Keep the field path (structural, not secret) - // but drop the formatted validator details on this runtime path. - let field = first_violating_field(&err).unwrap_or_default(); - let message = if field.is_empty() { - "app config failed validation".to_owned() - } else { - format!("app config failed validation for field `{field}`") - }; - EdgeError::config_out_of_date(message, field) - })?; - Ok(cfg) + envelope.verify().map_err(|err| match err { + BlobEnvelopeError::UnknownVersion(version) => store_extraction_error( + StoreExtractionReason::UnsupportedVersion, + format!( + "typed app-config blob uses envelope version {version}, which this build does not understand; redeploy this service with an updated build" + ), + None, + ), + BlobEnvelopeError::ShaMismatch { .. } => store_extraction_error( + StoreExtractionReason::IntegrityMismatch, + "typed app-config blob failed its integrity check (details redacted)", + None, + ), + })?; + Ok::<_, EdgeError>(envelope.into_data()) + })(); + budget.check_deadline()?; + let mut data = parsed?; + drop(raw); + + let resolved = secret_walk::(ctx, &mut budget, &mut data).await; + budget.check_deadline()?; + resolved?; + + let configured = (|| { + let cfg: C = serde_path_to_error::deserialize(data.into_deserializer()) + .map_err(|serde_error| EdgeError::store_deserialization_from_serde(&serde_error))?; + cfg.validate().map_err(|err| { + let field = first_violating_field(&err).unwrap_or_default(); + let message = if field.is_empty() { + "app config failed validation".to_owned() + } else { + format!("app config failed validation for field `{field}`") + }; + store_extraction_error( + StoreExtractionReason::Validation, + message, + (!field.is_empty()).then_some(field), + ) + })?; + Ok::<_, EdgeError>(cfg) + })(); + budget.check_deadline()?; + configured +} + +fn map_config_store_error(err: &ConfigStoreError) -> EdgeError { + match err { + ConfigStoreError::DeadlineExceeded => store_extraction_error( + StoreExtractionReason::DeadlineExceeded, + "typed app-config store read deadline exceeded", + None, + ), + ConfigStoreError::Internal { .. } => store_extraction_error( + StoreExtractionReason::BackendFailure, + "typed app-config store read failed (details redacted)", + None, + ), + ConfigStoreError::InvalidKey { .. } => store_extraction_error( + StoreExtractionReason::InvalidKey, + "typed app-config store rejected the requested key (details redacted)", + None, + ), + ConfigStoreError::Unavailable { .. } => store_extraction_error( + StoreExtractionReason::BackendUnavailable, + "typed app-config store is unavailable", + None, + ), + ConfigStoreError::ValueTooLarge => store_extraction_error( + StoreExtractionReason::ValueTooLarge, + "typed app-config store value exceeded its read limit", + None, + ), + } } /// Walk `C::secret_fields()` and replace each `#[secret]` key NAME in `data` /// with the resolved secret VALUE from the appropriate secret store. /// /// `StoreRef` fields are skipped — their value is a store id, not a key. -async fn secret_walk(ctx: &RequestContext, data: &mut serde_json::Value) -> Result<(), EdgeError> +async fn secret_walk( + ctx: &RequestContext, + budget: &mut ConfigExtractionBudget, + data: &mut serde_json::Value, +) -> Result<(), EdgeError> where C: AppConfigMeta, { @@ -982,7 +1349,7 @@ where if matches!(field.kind, SecretKind::StoreRef) { continue; } - resolve_secret_field(ctx, data, &field, &field.path, String::new()).await?; + resolve_secret_field(ctx, budget, data, &field, &field.path, String::new()).await?; } Ok(()) } @@ -992,6 +1359,7 @@ where /// indices) for error hints. fn resolve_secret_field<'walk>( ctx: &'walk RequestContext, + budget: &'walk mut ConfigExtractionBudget, node: &'walk mut serde_json::Value, field: &'walk SecretField, remaining: &'walk [SecretPathSegment], @@ -1001,7 +1369,7 @@ fn resolve_secret_field<'walk>( match remaining.split_first() { // Leaf reached: `node` is the PARENT object; the last field is the key. Some((SecretPathSegment::Field(name), [])) => { - resolve_leaf(ctx, node, field, name.as_ref(), &rendered).await + resolve_leaf(ctx, budget, node, field, name.as_ref(), &rendered).await } Some((SecretPathSegment::OptionalField(name), [])) => { if node.as_object().is_some_and(|parent| { @@ -1012,7 +1380,7 @@ fn resolve_secret_field<'walk>( }) { return Ok(()); } - resolve_leaf(ctx, node, field, name.as_ref(), &rendered).await + resolve_leaf(ctx, budget, node, field, name.as_ref(), &rendered).await } // Required intermediates still reject stale blobs. Optional // intermediates are represented explicitly below rather than by the @@ -1020,27 +1388,29 @@ fn resolve_secret_field<'walk>( Some((SecretPathSegment::Field(name), rest)) => { let next_rendered = join_field(&rendered, name.as_ref()); match node.get_mut(name.as_ref()) { - None | Some(serde_json::Value::Null) => Err(EdgeError::config_out_of_date( + None | Some(serde_json::Value::Null) => Err(store_extraction_error( + StoreExtractionReason::Deserialization, format!("missing or null value at `{next_rendered}`"), - next_rendered, + Some(next_rendered), )), Some(child) => { - resolve_secret_field(ctx, child, field, rest, next_rendered).await + resolve_secret_field(ctx, budget, child, field, rest, next_rendered).await } } } Some((SecretPathSegment::OptionalField(name), rest)) => { let Some(parent) = node.as_object_mut() else { - return Err(EdgeError::config_out_of_date( + return Err(store_extraction_error( + StoreExtractionReason::Deserialization, format!("expected an object at `{rendered}`"), - rendered, + Some(rendered), )); }; let next_rendered = join_field(&rendered, name.as_ref()); match parent.get_mut(name.as_ref()) { None | Some(serde_json::Value::Null) => Ok(()), Some(child) => { - resolve_secret_field(ctx, child, field, rest, next_rendered).await + resolve_secret_field(ctx, budget, child, field, rest, next_rendered).await } } } @@ -1048,14 +1418,15 @@ fn resolve_secret_field<'walk>( // intermediate unless its containing field was optional above. Some((SecretPathSegment::ArrayEach, rest)) => { let Some(items) = node.as_array_mut() else { - return Err(EdgeError::config_out_of_date( + return Err(store_extraction_error( + StoreExtractionReason::Deserialization, format!("expected an array at `{rendered}`"), - rendered, + Some(rendered), )); }; for (idx, item) in items.iter_mut().enumerate() { let indexed = format!("{rendered}[{idx}]"); - resolve_secret_field(ctx, item, field, rest, indexed).await?; + resolve_secret_field(ctx, budget, item, field, rest, indexed).await?; } Ok(()) } @@ -1077,6 +1448,7 @@ fn join_field(prefix: &str, name: &str) -> String { /// within `parent`. async fn resolve_leaf( ctx: &RequestContext, + budget: &mut ConfigExtractionBudget, parent: &mut serde_json::Value, field: &SecretField, key: &str, @@ -1089,9 +1461,10 @@ async fn resolve_leaf( let leaf_path = join_field(rendered_parent, key); let Some(parent_obj) = parent.as_object_mut() else { - return Err(EdgeError::config_out_of_date( + return Err(store_extraction_error( + StoreExtractionReason::Deserialization, format!("expected an object containing `{key}` at `{rendered_parent}`"), - leaf_path, + Some(leaf_path), )); }; @@ -1103,9 +1476,10 @@ async fn resolve_leaf( // cases must skip — not just the missing-key case. None | Some(serde_json::Value::Null) if field.optional => return Ok(()), _ => { - return Err(EdgeError::config_out_of_date( + return Err(store_extraction_error( + StoreExtractionReason::Deserialization, format!("missing or non-string value at `{leaf_path}`"), - leaf_path, + Some(leaf_path), )); } }; @@ -1113,12 +1487,13 @@ async fn resolve_leaf( let (bound, resolved_store_id) = match field.kind { SecretKind::KeyInDefault => { let bound = ctx.secret_store_default().ok_or_else(|| { - EdgeError::config_out_of_date( + store_extraction_error( + StoreExtractionReason::MissingRegistry, format!( "secret field `{leaf_path}` has kind KeyInDefault but no default secret \ store is registered" ), - leaf_path.clone(), + Some(leaf_path.clone()), ) })?; let id = bound.store_name().to_owned(); @@ -1130,11 +1505,12 @@ async fn resolve_leaf( .get(store_ref_field) .and_then(|val| val.as_str()) .ok_or_else(|| { - EdgeError::config_out_of_date( + store_extraction_error( + StoreExtractionReason::Deserialization, format!( "missing store_ref `{store_ref_field}` for secret field `{leaf_path}`" ), - leaf_path.clone(), + Some(leaf_path.clone()), ) })? .to_owned(); @@ -1142,22 +1518,50 @@ async fn resolve_leaf( // `store_id_str` is the blob's store_ref VALUE — config data that // may be sensitive — and this message reaches the HTTP body. Name // the field, not the stored id. - EdgeError::config_out_of_date( + store_extraction_error( + StoreExtractionReason::UnknownStore, format!( "secret field `{leaf_path}` names a store_ref that is not declared in \ [stores.secrets] (id redacted)" ), - leaf_path.clone(), + Some(leaf_path.clone()), ) })?; (bound, store_id_str) } }; - let secret = bound - .require_str(&key_name) + let read = bound + .get_bytes_bounded( + &key_name, + budget.clock(), + budget.deadline(), + budget.remaining_backend_bytes(), + budget.max_secret_bytes(), + ) .await .map_err(|err| map_secret_error(err, &leaf_path, &resolved_store_id, &key_name))?; + budget.accept_read( + read.backend_bytes, + read.value.as_ref().map(bytes::Bytes::len), + budget.max_secret_bytes(), + )?; + let secret_bytes = read.value.ok_or_else(|| { + store_extraction_error( + StoreExtractionReason::MissingSecret, + format!( + "the secret referenced by `{leaf_path}` was not found in its store (identifier redacted)" + ), + Some(leaf_path.clone()), + ) + })?; + let secret = String::from_utf8(secret_bytes.to_vec()).map_err(|_utf8_error| { + store_extraction_error( + StoreExtractionReason::InvalidSecretValue, + format!("secret for field `{leaf_path}` is not valid UTF-8"), + Some(leaf_path.clone()), + ) + })?; parent_obj.insert(key.to_owned(), serde_json::Value::String(secret)); Ok(()) } @@ -1175,24 +1579,40 @@ fn map_secret_error( _key_name: &str, ) -> EdgeError { match err { - SecretError::NotFound { .. } => EdgeError::config_out_of_date( + SecretError::DeadlineExceeded => EdgeError::store_extraction( + StoreExtractionReason::DeadlineExceeded, + format!("secret resolution for `{field_name}` exceeded its deadline"), + Some(field_name.to_owned()), + ), + SecretError::Internal(_source) => EdgeError::store_extraction( + StoreExtractionReason::BackendFailure, + format!("secret resolution for `{field_name}` failed (details redacted)"), + Some(field_name.to_owned()), + ), + SecretError::NotFound { .. } => EdgeError::store_extraction( + StoreExtractionReason::MissingSecret, format!( "the secret referenced by `{field_name}` was not found in its store (identifier redacted)" ), - field_name.to_owned(), + Some(field_name.to_owned()), ), - SecretError::Validation(_msg) => EdgeError::config_out_of_date( + SecretError::Unavailable => EdgeError::store_extraction( + StoreExtractionReason::SecretBackendUnavailable, + format!("the secret store for `{field_name}` is unreachable"), + Some(field_name.to_owned()), + ), + SecretError::Validation(_msg) => EdgeError::store_extraction( + StoreExtractionReason::InvalidKey, format!( "the secret referenced by `{field_name}` was rejected by its store (details redacted)" ), - field_name.to_owned(), + Some(field_name.to_owned()), + ), + SecretError::ValueTooLarge => EdgeError::store_extraction( + StoreExtractionReason::ValueTooLarge, + format!("the secret referenced by `{field_name}` exceeds its configured byte limit"), + Some(field_name.to_owned()), ), - SecretError::Unavailable => EdgeError::service_unavailable(format!( - "the secret store for `{field_name}` is unreachable" - )), - SecretError::Internal(_source) => EdgeError::internal(anyhow::anyhow!( - "secret resolution for `{field_name}` failed (details redacted)" - )), } } @@ -1258,17 +1678,20 @@ mod tests { use crate::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; use crate::blob_envelope::BlobEnvelope; use crate::body::Body; - use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use crate::config_store::{ + ConfigStore, ConfigStoreError, ConfigStoreHandle, ready_config_store_bounded_read, + }; use crate::context::RequestContext; use crate::http::{HeaderValue, Method, StatusCode, request_builder}; use crate::params::PathParams; use crate::secret_store::{InMemorySecretStore, NoopSecretStore, SecretHandle, SecretStore}; use crate::store_registry::StoreRegistry; + use crate::time::{MonotonicClock, MonotonicInstant}; use futures::executor::block_on; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::collections::HashMap; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use validator::Validate; #[derive(Clone, Debug, PartialEq)] @@ -1531,8 +1954,7 @@ mod tests { #[test] fn headers_extractor_clones_request_headers() { let mut ctx = ctx(Body::empty(), PathParams::default()); - ctx.request_mut() - .headers_mut() + ctx.headers_mut() .insert("x-test", HeaderValue::from_static("value")); let headers = block_on(Headers::from_request(&ctx)).expect("headers"); assert_eq!( @@ -2079,9 +2501,14 @@ mod tests { struct FixedStore(&'static str); #[async_trait(?Send)] impl ConfigStore for FixedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.to_owned())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.into())) } + + ready_config_store_bounded_read!(); } let registry: ConfigRegistry = StoreRegistry::new( @@ -2119,7 +2546,7 @@ mod tests { let analytics = config.named("analytics").expect("analytics handle"); assert_eq!( block_on(analytics.get("any")).expect("config value"), - Some("analytics".to_owned()) + Some(crate::ConfigValue::from("analytics")) ); assert!(config.named("missing").is_none()); assert!(config.default().is_some()); @@ -2135,9 +2562,14 @@ mod tests { struct AnyStore; #[async_trait(?Send)] impl ConfigStore for AnyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some("legacy".to_owned())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some("legacy".into())) } + + ready_config_store_bounded_read!(); } let mut request = request_builder() @@ -2181,9 +2613,14 @@ mod tests { struct AnyStore; #[async_trait(?Send)] impl ConfigStore for AnyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } + + ready_config_store_bounded_read!(); } let binding = ConfigStoreBinding { @@ -2230,9 +2667,14 @@ mod tests { struct AnyStore; #[async_trait(?Send)] impl ConfigStore for AnyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } + + ready_config_store_bounded_read!(); } let registry: ConfigRegistry = StoreRegistry::new( @@ -2333,14 +2775,67 @@ mod tests { serde_json::to_string(&envelope).expect("serialise envelope") } + async fn test_secret_walk( + ctx: &RequestContext, + data: &mut serde_json::Value, + ) -> Result<(), EdgeError> + where + C: AppConfigMeta, + { + let mut budget = + ConfigExtractionBudget::start(ConfigExtractionLimits::default(), ctx.monotonic_clock()) + .expect("test budget"); + secret_walk::(ctx, &mut budget, data).await + } + + #[test] + fn config_extraction_budget_uses_the_request_clock() { + let start = MonotonicInstant::now(); + let observed = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&observed); + let clock = MonotonicClock::new(move || *clock_now.lock().expect("clock lock")); + let limits = ConfigExtractionLimits::default(); + let deadline = start.checked_add(limits.timeout).expect("deadline"); + let mut budget = ConfigExtractionBudget::start(limits, clock).expect("test budget"); + *observed.lock().expect("clock lock") = deadline; + + let error = budget + .accept_read(0, Some(0), 1) + .expect_err("injected clock reached deadline"); + + assert!(matches!( + error, + EdgeError::StoreExtraction { + reason: StoreExtractionReason::DeadlineExceeded, + .. + } + )); + + let deadline_error = budget + .check_deadline() + .expect_err("CPU work also observes the same deadline"); + assert!(matches!( + deadline_error, + EdgeError::StoreExtraction { + reason: StoreExtractionReason::DeadlineExceeded, + .. + } + )); + } + #[test] fn app_config_extractor_happy_path() { struct FixedStore(String); #[async_trait(?Send)] impl ConfigStore for FixedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } + + ready_config_store_bounded_read!(); } let data = serde_json::json!({ "greeting": "hello", "timeout_ms": 500_u32 }); @@ -2353,21 +2848,201 @@ mod tests { } #[test] - fn app_config_extractor_returns_config_out_of_date_on_missing_blob() { + fn app_config_extractor_uses_bounded_root_read() { + use std::sync::Mutex; + + use crate::config_store::{BoundedStoreRead, ConfigExtractionLimits}; + use crate::time::Deadline; + + struct BoundedOnlyStore { + blob: String, + observed: Arc>>, + } + + #[async_trait(?Send)] + impl ConfigStore for BoundedOnlyStore { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + panic!("typed extraction must not call the unbounded config-store API"); + } + + async fn get_bounded( + &self, + _key: &str, + _clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + *self.observed.lock().expect("record bounded read") = + Some((deadline, max_backend_bytes, max_value_bytes)); + Ok(BoundedStoreRead { + backend_bytes: u64::try_from(self.blob.len()).expect("fixture length"), + value: Some(self.blob.clone().into()), + }) + } + } + + let observed = Arc::new(Mutex::new(None)); + let blob = + make_envelope(serde_json::json!({ "greeting": "bounded", "timeout_ms": 500_u32 })); + let ctx = ctx_with_config_store( + BoundedOnlyStore { + blob, + observed: Arc::clone(&observed), + }, + "the_key", + ); + + let AppConfig(cfg) = + block_on(AppConfig::::from_request(&ctx)).expect("bounded extraction"); + assert_eq!(cfg.greeting, "bounded"); + + let (_, max_backend_bytes, max_value_bytes) = observed + .lock() + .expect("read observation") + .expect("bounded read called"); + let limits = ConfigExtractionLimits::default(); + assert_eq!(max_backend_bytes, limits.max_backend_bytes); + assert_eq!(max_value_bytes, limits.max_blob_bytes); + } + + #[test] + fn app_config_extractor_shares_deadline_and_budget_with_secret_reads() { + use crate::ConfigValue; + use crate::config_store::BoundedStoreRead; + use crate::time::{Deadline, MonotonicInstant}; + use bytes::Bytes; + use std::sync::Mutex; + + type Observation = (MonotonicInstant, u64, u64); + + struct RecordingConfigStore { + blob: String, + observed: Arc>>, + } + + #[async_trait(?Send)] + impl ConfigStore for RecordingConfigStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + panic!("typed extraction must not call unbounded config reads"); + } + + async fn get_bounded( + &self, + _key: &str, + _clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + self.observed.lock().expect("config observation").push(( + deadline.instant(), + max_backend_bytes, + max_value_bytes, + )); + Ok(BoundedStoreRead { + backend_bytes: u64::try_from(self.blob.len()).expect("fixture length"), + value: Some(self.blob.clone().into()), + }) + } + } + + struct RecordingSecretStore { + observed: Arc>>, + } + + #[async_trait(?Send)] + impl SecretStore for RecordingSecretStore { + async fn get_bytes( + &self, + _store_name: &str, + _key: &str, + ) -> Result, SecretError> { + panic!("typed extraction must not call unbounded secret reads"); + } + + async fn get_bytes_bounded( + &self, + _store_name: &str, + _key: &str, + _clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + self.observed.lock().expect("secret observation").push(( + deadline.instant(), + max_backend_bytes, + max_value_bytes, + )); + Ok(BoundedStoreRead { + backend_bytes: 6, + value: Some(Bytes::from_static(b"secret")), + }) + } + } + + let observed = Arc::new(Mutex::new(Vec::new())); + let blob = + make_envelope(serde_json::json!({ "greeting": "bounded", "api_token": "token-key" })); + let blob_bytes = u64::try_from(blob.len()).expect("fixture length"); + let ctx = ctx_with_config_and_secrets( + RecordingConfigStore { + blob, + observed: Arc::clone(&observed), + }, + "the_key", + RecordingSecretStore { + observed: Arc::clone(&observed), + }, + "vault", + ); + + let AppConfig(cfg) = + block_on(AppConfig::::from_request(&ctx)).expect("bounded extraction"); + assert_eq!(cfg.api_token, "secret"); + + let locked_observations = observed.lock().expect("observations"); + assert_eq!(locked_observations.len(), 2); + assert_eq!( + locked_observations[0].0, locked_observations[1].0, + "deadline must not reset" + ); + assert_eq!( + locked_observations[1].1, + locked_observations[0].1 - blob_bytes, + "secret read receives the remaining backend allowance" + ); + assert_eq!( + locked_observations[1].2, + ConfigExtractionLimits::default().max_secret_bytes + ); + } + + #[test] + fn app_config_extractor_returns_typed_missing_blob() { struct EmptyStore; #[async_trait(?Send)] impl ConfigStore for EmptyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Ok(None) } + + ready_config_store_bounded_read!(); } let ctx = ctx_with_config_store(EmptyStore, "the_key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("missing blob must error"); - assert!( - matches!(err, EdgeError::ConfigOutOfDate { .. }), - "expected ConfigOutOfDate, got {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::MissingBlob) ); assert!( err.message().contains("missing typed app-config blob"), @@ -2380,69 +3055,87 @@ mod tests { } #[test] - fn app_config_extractor_maps_config_store_unavailable_to_service_unavailable() { + fn app_config_extractor_maps_config_store_unavailable() { struct DownStore; #[async_trait(?Send)] impl ConfigStore for DownStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Err(ConfigStoreError::unavailable("backend offline")) } + + ready_config_store_bounded_read!(); } let ctx = ctx_with_config_store(DownStore, "the_key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("unavailable store must error"); - assert!( - matches!(err, EdgeError::ServiceUnavailable { .. }), - "ConfigStoreError::Unavailable must map to ServiceUnavailable (not Internal): {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::BackendUnavailable) ); } #[test] - fn app_config_extractor_maps_config_store_invalid_key_to_bad_request() { + fn app_config_extractor_maps_config_store_invalid_key() { struct BadKeyStore; #[async_trait(?Send)] impl ConfigStore for BadKeyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Err(ConfigStoreError::invalid_key("key is malformed")) } + + ready_config_store_bounded_read!(); } let ctx = ctx_with_config_store(BadKeyStore, "the_key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("invalid key must error"); - assert!( - matches!(err, EdgeError::BadRequest { .. }), - "ConfigStoreError::InvalidKey must map to BadRequest (not Internal): {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::InvalidKey) ); } #[test] - fn app_config_extractor_maps_config_store_internal_to_internal() { + fn app_config_extractor_maps_config_store_internal() { struct BrokenStore; #[async_trait(?Send)] impl ConfigStore for BrokenStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Err(ConfigStoreError::internal(anyhow::anyhow!("disk on fire"))) } + + ready_config_store_bounded_read!(); } let ctx = ctx_with_config_store(BrokenStore, "the_key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("internal store error must error"); - assert!( - matches!(err, EdgeError::Internal { .. }), - "ConfigStoreError::Internal must map to Internal: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::BackendFailure) ); } #[test] - fn app_config_extractor_returns_internal_on_sha_mismatch() { + fn app_config_extractor_returns_integrity_mismatch_on_sha_mismatch() { const SENTINEL: &str = "SUPER_SECRET_STORED_HASH"; struct TamperedStore; #[async_trait(?Send)] impl ConfigStore for TamperedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { // A valid envelope whose stored sha is a SENTINEL secret. The // stored hash is attacker-influenced (it comes from the config // store), and this error becomes the HTTP 500 body — so the @@ -2452,17 +3145,18 @@ mod tests { "2026-01-01T00:00:00Z".into(), ); env.sha256 = SENTINEL.to_owned(); - Ok(Some(serde_json::to_string(&env).unwrap())) + Ok(Some(serde_json::to_string(&env).unwrap().into())) } + + ready_config_store_bounded_read!(); } let ctx = ctx_with_config_store(TamperedStore, "key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("sha mismatch must error"); - // SHA mismatch → internal error (envelope integrity failure). - assert!( - matches!(err, EdgeError::Internal { .. }), - "SHA mismatch must surface as Internal: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::IntegrityMismatch) ); // The client-facing message (the HTTP body) must not carry the stored // hash, only a redacted category. @@ -2485,7 +3179,10 @@ mod tests { struct FutureVersionStore; #[async_trait(?Send)] impl ConfigStore for FutureVersionStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { // A v1-shaped envelope with the version bumped to 2. let env = BlobEnvelope::new( serde_json::json!({ "greeting": "hi", "timeout_ms": 100_u32 }), @@ -2494,16 +3191,18 @@ mod tests { let mut value: serde_json::Value = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); value["version"] = serde_json::json!(2_u32); - Ok(Some(value.to_string())) + Ok(Some(value.to_string().into())) } + + ready_config_store_bounded_read!(); } let ctx = ctx_with_config_store(FutureVersionStore, "key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("a future envelope version must error"); - assert!( - matches!(err, EdgeError::Internal { .. }), - "a future version is Internal, not transient: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::UnsupportedVersion) ); let message = err.message().to_lowercase(); assert!( @@ -2525,7 +3224,10 @@ mod tests { struct KindTaggedStore; #[async_trait(?Send)] impl ConfigStore for KindTaggedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { let env = BlobEnvelope::new( serde_json::json!({ "greeting": "hi", "timeout_ms": 100_u32 }), "2026-01-01T00:00:00Z".into(), @@ -2533,8 +3235,10 @@ mod tests { let mut value: serde_json::Value = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); value["edgezero_kind"] = serde_json::json!("new_format"); - Ok(Some(value.to_string())) + Ok(Some(value.to_string().into())) } + + ready_config_store_bounded_read!(); } let ctx = ctx_with_config_store(KindTaggedStore, "key"); @@ -2542,20 +3246,56 @@ mod tests { .expect_err("an unknown edgezero_kind must error"); let message = err.message().to_lowercase(); assert!( - matches!(err, EdgeError::Internal { .. }) + err.store_extraction_reason() == Some(StoreExtractionReason::UnsupportedVersion) && message.contains("redeploy") && message.contains("edgezero_kind"), "an unknown discriminator must ask to redeploy: {err:?}" ); } + #[test] + fn app_config_extractor_rejects_an_escaped_edgezero_kind_key() { + struct EscapedKindTaggedStore; + #[async_trait(?Send)] + impl ConfigStore for EscapedKindTaggedStore { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + let env = BlobEnvelope::new( + serde_json::json!({ "greeting": "hi", "timeout_ms": 100_u32 }), + "2026-01-01T00:00:00Z".into(), + ); + let serialized = serde_json::to_string(&env).expect("envelope"); + let serialized_body = serialized.strip_prefix('{').expect("JSON object"); + Ok(Some( + format!("{{\"edgezero_\\u006bind\":\"new_format\",{serialized_body}").into(), + )) + } + + ready_config_store_bounded_read!(); + } + + let ctx = ctx_with_config_store(EscapedKindTaggedStore, "key"); + let err = block_on(AppConfig::::from_request(&ctx)) + .expect_err("an escaped edgezero_kind must error"); + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::UnsupportedVersion) + ); + assert!(err.message().contains("edgezero_kind")); + } + #[test] fn app_config_extractor_does_not_leak_data_value_in_deserialize_error() { const SENTINEL: &str = "SUPER_SECRET_FIELD_VALUE"; struct TypeErrorStore; #[async_trait(?Send)] impl ConfigStore for TypeErrorStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { // A VALID envelope (verify passes) whose data has the wrong type // for `timeout_ms` — a string sentinel where u32 is expected. // The deserialize error names that value; it must not reach the @@ -2564,16 +3304,18 @@ mod tests { serde_json::json!({ "greeting": "hi", "timeout_ms": SENTINEL }), "2026-01-01T00:00:00Z".into(), ); - Ok(Some(serde_json::to_string(&env).unwrap())) + Ok(Some(serde_json::to_string(&env).unwrap().into())) } + + ready_config_store_bounded_read!(); } let ctx = ctx_with_config_store(TypeErrorStore, "key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("a type mismatch in the typed config must error"); - assert!( - matches!(err, EdgeError::ConfigOutOfDate { .. }), - "a typed deserialize failure must be ConfigOutOfDate: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::Deserialization) ); assert!( !err.message().contains(SENTINEL), @@ -2581,30 +3323,37 @@ mod tests { ); // The field path segments are redacted (a map key is indistinguishable // from a struct field and may be a secret); structure is kept. - if let EdgeError::ConfigOutOfDate { field_path, .. } = &err { + if let EdgeError::StoreExtraction { field_path, .. } = &err { assert!( - !field_path.contains("timeout_ms") && field_path.contains(""), - "the field path must be redacted: {field_path}" + field_path.as_deref().is_some_and(|path| { + !path.contains("timeout_ms") && path.contains("") + }), + "the field path must be redacted: {field_path:?}" ); } } #[test] - fn app_config_extractor_returns_internal_on_bad_envelope_json() { + fn app_config_extractor_returns_malformed_envelope_on_bad_json() { struct GarbageStore; #[async_trait(?Send)] impl ConfigStore for GarbageStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some("not-json-at-all".to_owned())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some("not-json-at-all".into())) } + + ready_config_store_bounded_read!(); } let ctx = ctx_with_config_store(GarbageStore, "key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("bad envelope JSON must error"); - assert!( - matches!(err, EdgeError::Internal { .. }), - "Envelope parse failure must be Internal: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::MalformedEnvelope) ); assert!( err.message().contains("not a valid envelope"), @@ -2619,62 +3368,136 @@ mod tests { } #[test] - fn app_config_extractor_returns_config_out_of_date_on_deserialise_failure() { + fn app_config_extractor_rejects_structural_amplification_before_materializing() { + struct FixedStore(String); + #[async_trait(?Send)] + impl ConfigStore for FixedStore { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) + } + + ready_config_store_bounded_read!(); + } + + let mut nested = serde_json::json!("SECRET_SENTINEL"); + for _ in 0_u32..70_u32 { + nested = serde_json::json!({"": nested}); + } + let wide = serde_json::json!(vec![(); 8_193]); + for data in [nested, wide] { + let raw = serde_json::json!({ + "data": data, + "generated_at": "2026-01-01", + "sha256": "invalid", + "version": 1_u32, + }) + .to_string(); + let ctx = ctx_with_config_store(FixedStore(raw), "key"); + let err = block_on(AppConfig::::from_request(&ctx)) + .expect_err("structural cap must reject before envelope materialization"); + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::StructuralLimit) + ); + assert!(err.message().contains("JSON structure"), "{err:?}"); + assert!(!err.message().contains("SECRET_SENTINEL")); + } + } + + #[test] + fn config_json_structure_limits_are_inclusive_and_keep_malformed_distinct() { + let raw = serde_json::json!({"data": [null]}).to_string(); + check_json_structure(&raw, 3, 3).expect("exact node and depth caps accept"); + for (depth, nodes) in [(2, 3), (3, 2)] { + let err = check_json_structure(&raw, depth, nodes).expect_err("cap exceeded"); + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::StructuralLimit) + ); + } + for malformed in [r#"{"data":}"#, "{} true"] { + let err = check_json_structure(malformed, 3, 3).expect_err("invalid JSON"); + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::MalformedEnvelope) + ); + } + check_json_structure(r#"{"edgezero_\u006bind":"future"}"#, 2, 2) + .expect("escaped map keys are scanned without changing their semantics"); + } + + #[test] + fn app_config_extractor_returns_deserialization_on_deserialise_failure() { use crate::config_store::{ConfigStore, ConfigStoreError}; // Blob has wrong type for `timeout_ms` (string instead of u32). struct BadDataStore; #[async_trait(?Send)] impl ConfigStore for BadDataStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { let data = serde_json::json!({ "greeting": "hi", "timeout_ms": "not-a-number", }); - Ok(Some(make_envelope(data))) + Ok(Some(make_envelope(data).into())) } + + ready_config_store_bounded_read!(); } let ctx = ctx_with_config_store(BadDataStore, "key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("type mismatch must error"); - assert!( - matches!(err, EdgeError::ConfigOutOfDate { .. }), - "deserialise failure must be ConfigOutOfDate: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::Deserialization) ); // The field path segment is redacted (indistinguishable from a map key). - if let EdgeError::ConfigOutOfDate { field_path, .. } = &err { + if let EdgeError::StoreExtraction { field_path, .. } = &err { assert_eq!( - field_path, "", + field_path.as_deref(), + Some(""), "the field path must be redacted: {err:?}" ); } } #[test] - fn app_config_extractor_returns_config_out_of_date_on_validation_failure() { + fn app_config_extractor_returns_validation_on_validation_failure() { use crate::config_store::{ConfigStore, ConfigStoreError}; // `timeout_ms = 0` violates `range(min = 1)`. struct ZeroTimeoutStore; #[async_trait(?Send)] impl ConfigStore for ZeroTimeoutStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { let data = serde_json::json!({ "greeting": "hi", "timeout_ms": 0_u32 }); - Ok(Some(make_envelope(data))) + Ok(Some(make_envelope(data).into())) } + + ready_config_store_bounded_read!(); } let ctx = ctx_with_config_store(ZeroTimeoutStore, "key"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("validation failure must error"); - assert!( - matches!(err, EdgeError::ConfigOutOfDate { .. }), - "validation failure must be ConfigOutOfDate: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::Validation) ); - if let EdgeError::ConfigOutOfDate { field_path, .. } = &err { + if let EdgeError::StoreExtraction { field_path, .. } = &err { assert_eq!( - field_path, "timeout_ms", + field_path.as_deref(), + Some("timeout_ms"), "field_path names the violator: {err:?}" ); } @@ -2686,9 +3509,14 @@ mod tests { struct BlobStore(String); #[async_trait(?Send)] impl ConfigStore for BlobStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } + + ready_config_store_bounded_read!(); } // Blob has `api_token = "my_key_name"` (a key name, not the secret). @@ -2709,14 +3537,19 @@ mod tests { } #[test] - fn app_config_secret_walk_missing_key_in_default_store_is_config_out_of_date() { + fn app_config_secret_walk_missing_key_has_typed_reason() { use crate::config_store::{ConfigStore, ConfigStoreError}; struct BlobStore(String); #[async_trait(?Send)] impl ConfigStore for BlobStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } + + ready_config_store_bounded_read!(); } // Blob references a key that doesn't exist in the secret store. @@ -2726,13 +3559,14 @@ mod tests { let ctx = ctx_with_config_and_secrets(BlobStore(blob), "key", NoopSecretStore, "vault"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("missing secret must error"); - assert!( - matches!(err, EdgeError::ConfigOutOfDate { .. }), - "missing secret must be ConfigOutOfDate: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::MissingSecret) ); - if let EdgeError::ConfigOutOfDate { field_path, .. } = &err { + if let EdgeError::StoreExtraction { field_path, .. } = &err { assert_eq!( - field_path, "api_token", + field_path.as_deref(), + Some("api_token"), "field_path names the secret field: {err:?}" ); } @@ -2744,9 +3578,14 @@ mod tests { struct BlobStore(String); #[async_trait(?Send)] impl ConfigStore for BlobStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } + + ready_config_store_bounded_read!(); } // The blob's secret field holds the secret's KEY NAME — config data that @@ -2813,7 +3652,7 @@ mod tests { let mut data = serde_json::json!({ "integrations": { "datadome": { "server_side_key": "dd_key" } } }); - block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + block_on(test_secret_walk::(&ctx, &mut data)).expect("walk"); assert_eq!( data["integrations"]["datadome"]["server_side_key"], serde_json::json!("resolved-dd") @@ -2826,7 +3665,7 @@ mod tests { let mut data = serde_json::json!({ "partners": [ { "api_key": "k0" }, { "api_key": "k1" } ] }); - block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + block_on(test_secret_walk::(&ctx, &mut data)).expect("walk"); assert_eq!(data["partners"][0]["api_key"], serde_json::json!("v0")); assert_eq!(data["partners"][1]["api_key"], serde_json::json!("v1")); } @@ -2837,7 +3676,7 @@ mod tests { let mut data = serde_json::json!({ "vaulted": { "token": "tok_key", "vault": "named" } }); - block_on(secret_walk::(&ctx, &mut data)).expect("walk"); + block_on(test_secret_walk::(&ctx, &mut data)).expect("walk"); assert_eq!(data["vaulted"]["token"], serde_json::json!("TOK")); // The store_ref sibling is left intact (it names a store, not a secret). assert_eq!(data["vaulted"]["vault"], serde_json::json!("named")); @@ -2847,7 +3686,7 @@ mod tests { fn secret_walk_nested_named_store_missing_sibling_errors_with_dotted_path() { let ctx = ctx_with_named_secret_store("named", "tok_key", "TOK"); let mut data = serde_json::json!({ "vaulted": { "token": "tok_key" } }); // no `vault` - let err = block_on(secret_walk::(&ctx, &mut data)) + let err = block_on(test_secret_walk::(&ctx, &mut data)) .expect_err("missing store_ref sibling"); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); assert!(err.to_string().contains("vaulted.token")); @@ -2857,7 +3696,8 @@ mod tests { fn secret_walk_skips_absent_optional_leaf() { let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "greeting": "hi" }); // no maybe_key - block_on(secret_walk::(&ctx, &mut data)).expect("absent optional is fine"); + block_on(test_secret_walk::(&ctx, &mut data)) + .expect("absent optional is fine"); assert!(data.get("maybe_key").is_none()); } @@ -2867,7 +3707,7 @@ mod tests { // not omitted). The walk must skip a null optional leaf, not error it. let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "maybe_key": null }); - block_on(secret_walk::(&ctx, &mut data)) + block_on(test_secret_walk::(&ctx, &mut data)) .expect("null optional is skipped, not treated as non-string"); assert_eq!(data["maybe_key"], serde_json::json!(null)); // left untouched } @@ -2876,7 +3716,7 @@ mod tests { fn secret_walk_missing_required_nested_leaf_errors_with_dotted_path() { let ctx = ctx_with_default_secret_store("dd_key", "resolved-dd"); let mut data = serde_json::json!({ "integrations": { "datadome": {} } }); - let err = block_on(secret_walk::(&ctx, &mut data)) + let err = block_on(test_secret_walk::(&ctx, &mut data)) .expect_err("missing required nested leaf"); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); assert!( @@ -2893,7 +3733,7 @@ mod tests { // vaguer serde error downstream. let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "greeting": "hi" }); // no `integrations` - let err = block_on(secret_walk::(&ctx, &mut data)) + let err = block_on(test_secret_walk::(&ctx, &mut data)) .expect_err("missing required intermediate"); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); assert!( @@ -2906,7 +3746,7 @@ mod tests { fn secret_walk_skips_absent_optional_intermediate() { let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "integrations": {} }); - block_on(secret_walk::(&ctx, &mut data)) + block_on(test_secret_walk::(&ctx, &mut data)) .expect("absent optional intermediate is fine"); } @@ -2914,7 +3754,7 @@ mod tests { fn secret_walk_skips_null_optional_intermediate() { let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "integrations": { "datadome": null } }); - block_on(secret_walk::(&ctx, &mut data)) + block_on(test_secret_walk::(&ctx, &mut data)) .expect("null optional intermediate is fine"); } @@ -2922,7 +3762,7 @@ mod tests { fn secret_walk_rejects_scalar_parent_of_optional_intermediate() { let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "integrations": "not-an-object" }); - let err = block_on(secret_walk::(&ctx, &mut data)) + let err = block_on(test_secret_walk::(&ctx, &mut data)) .expect_err("a present optional intermediate must have an object parent"); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); @@ -2930,31 +3770,39 @@ mod tests { err.to_string().contains("integrations"), "error names the malformed parent: {err}" ); - let EdgeError::ConfigOutOfDate { field_path, .. } = &err else { - panic!("malformed optional parent must be ConfigOutOfDate: {err:?}"); + let EdgeError::StoreExtraction { + reason, field_path, .. + } = &err + else { + panic!("malformed optional parent must be typed: {err:?}"); }; - assert_eq!(field_path, "integrations"); + assert_eq!(*reason, StoreExtractionReason::Deserialization); + assert_eq!(field_path.as_deref(), Some("integrations")); } #[test] fn secret_walk_rejects_scalar_parent_of_terminal_optional_field() { let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "integrations": "not-an-object" }); - let err = block_on(secret_walk::(&ctx, &mut data)) + let err = block_on(test_secret_walk::(&ctx, &mut data)) .expect_err("a terminal optional field must have an object parent"); assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); - let EdgeError::ConfigOutOfDate { field_path, .. } = &err else { - panic!("malformed optional parent must be ConfigOutOfDate: {err:?}"); + let EdgeError::StoreExtraction { + reason, field_path, .. + } = &err + else { + panic!("malformed optional parent must be typed: {err:?}"); }; - assert_eq!(field_path, "integrations.webhook_key"); + assert_eq!(*reason, StoreExtractionReason::Deserialization); + assert_eq!(field_path.as_deref(), Some("integrations.webhook_key")); } #[test] fn secret_walk_present_intermediate_absent_optional_leaf_is_ok() { let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "integrations": { "datadome": {} } }); - block_on(secret_walk::(&ctx, &mut data)) + block_on(test_secret_walk::(&ctx, &mut data)) .expect("absent optional leaf under present intermediates is fine"); } @@ -2963,11 +3811,13 @@ mod tests { struct KeyEchoStore; #[async_trait(?Send)] impl ConfigStore for KeyEchoStore { - async fn get(&self, key: &str) -> Result, ConfigStoreError> { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { // Return a blob whose `greeting` equals the key being looked up. let data = serde_json::json!({ "greeting": key, "timeout_ms": 200_u32 }); - Ok(Some(make_envelope(data))) + Ok(Some(make_envelope(data).into())) } + + ready_config_store_bounded_read!(); } let ctx = ctx_with_config_store(KeyEchoStore, "default_key"); @@ -2984,10 +3834,15 @@ mod tests { struct NamedStore(&'static str); #[async_trait(?Send)] impl ConfigStore for NamedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { let data = serde_json::json!({ "greeting": self.0, "timeout_ms": 300_u32 }); - Ok(Some(make_envelope(data))) + Ok(Some(make_envelope(data).into())) } + + ready_config_store_bounded_read!(); } // Wire two config stores: "primary" (default) and "secondary". @@ -3023,8 +3878,7 @@ mod tests { } #[test] - fn app_config_no_registry_returns_internal_error() { - // No ConfigRegistry in extensions → Internal error. + fn app_config_no_registry_returns_typed_error() { let request = request_builder() .method(Method::GET) .uri("/cfg") @@ -3033,9 +3887,9 @@ mod tests { let ctx = RequestContext::new(request, PathParams::default()); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("no registry must error"); - assert!( - matches!(err, EdgeError::Internal { .. }), - "no registry must surface as Internal: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::MissingRegistry) ); assert!( err.message().contains("no default config store registered"), @@ -3073,9 +3927,14 @@ mod tests { struct BlobStore(String); #[async_trait(?Send)] impl ConfigStore for BlobStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } + + ready_config_store_bounded_read!(); } // Blob carries the key name "short" (5 chars) — push skipped the @@ -3092,7 +3951,7 @@ mod tests { } /// Spec 3.3.8: a resolved secret that FAILS a validator on the secret - /// field must produce `ConfigOutOfDate` — the runtime runs the full validator. + /// field must produce a typed schema mismatch; runtime runs the full validator. #[test] fn runtime_rejects_resolved_secret_failing_validator() { use crate::config_store::{ConfigStore, ConfigStoreError}; @@ -3117,9 +3976,14 @@ mod tests { struct BlobStore(String); #[async_trait(?Send)] impl ConfigStore for BlobStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } + + ready_config_store_bounded_read!(); } // The secret store resolves the key to a 5-char value — too short. @@ -3129,13 +3993,14 @@ mod tests { let ctx = ctx_with_config_and_secrets(BlobStore(blob), "key", secret_store, "vault"); let err = block_on(AppConfig::::from_request(&ctx)) .expect_err("short resolved secret must fail validator"); - assert!( - matches!(err, EdgeError::ConfigOutOfDate { .. }), - "validator failure on resolved secret must be ConfigOutOfDate: {err:?}" + assert_eq!( + err.store_extraction_reason(), + Some(StoreExtractionReason::Validation) ); - if let EdgeError::ConfigOutOfDate { field_path, .. } = &err { + if let EdgeError::StoreExtraction { field_path, .. } = &err { assert_eq!( - field_path, "api_token", + field_path.as_deref(), + Some("api_token"), "field_path names the violating secret field: {err:?}" ); } @@ -3167,9 +4032,14 @@ mod tests { struct BlobStore(String); #[async_trait(?Send)] impl ConfigStore for BlobStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) } + + ready_config_store_bounded_read!(); } // A distinctive resolved secret that fails the `length(min = 100)` rule. diff --git a/crates/edgezero-core/src/http.rs b/crates/edgezero-core/src/http.rs index 60ead491f..34d0b39fb 100644 --- a/crates/edgezero-core/src/http.rs +++ b/crates/edgezero-core/src/http.rs @@ -12,8 +12,9 @@ pub mod header { use std::future::Future; use std::pin::Pin; -use http::request::Builder as HttpRequestBuilder; +use http::request::{Builder as HttpRequestBuilder, Parts as HttpRequestParts}; use http::response::Builder as HttpResponseBuilder; +use http::uri::Authority as HttpAuthority; use crate::body::Body; use crate::error::EdgeError; @@ -23,6 +24,7 @@ use crate::error::EdgeError; // `Builder` types are exposed via `pub type` aliases (not `pub use`) so // only the `header` re-export remains, scoped to its own child module. pub type Extensions = http::Extensions; +pub type Authority = HttpAuthority; pub type HandlerFuture = Pin> + 'static>>; pub type HeaderMap = http::HeaderMap; pub type HeaderName = header::HeaderName; @@ -30,6 +32,7 @@ pub type HeaderValue = http::HeaderValue; pub type Method = http::Method; pub type Request = http::Request; pub type RequestBuilder = HttpRequestBuilder; +pub type RequestParts = HttpRequestParts; pub type Response = http::Response; pub type ResponseBuilder = HttpResponseBuilder; pub type StatusCode = http::StatusCode; diff --git a/crates/edgezero-core/src/ingress.rs b/crates/edgezero-core/src/ingress.rs new file mode 100644 index 000000000..855825966 --- /dev/null +++ b/crates/edgezero-core/src/ingress.rs @@ -0,0 +1,1180 @@ +use std::any::Any; +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; + +use crate::body::Body; +use crate::config_store::ConfigExtractionLimits; +use crate::error::EdgeError; +use crate::http::{ + Extensions, HeaderMap, HeaderValue, Method, Request, RequestParts, Response, StatusCode, Uri, + Version, + header::{CONTENT_LENGTH, CONTENT_TYPE, TRANSFER_ENCODING}, +}; +use crate::response_egress::{ResponseEgressCompletion, ResponseEgressEnvelope}; +use crate::router::{ResolvedDispatch, RouteMetadata, RouteResolution}; +use crate::time::{DEADLINE_FAR_FUTURE, Deadline, MonotonicClock, MonotonicInstant}; + +pub const DEFAULT_INBOUND_READ_BUDGET: Duration = Duration::from_secs(30); +pub const DEFAULT_MAX_REQUEST_HEADER_BYTES: u64 = 0x0001_0000; +pub const DEFAULT_MAX_REQUEST_HEADER_COUNT: u64 = 100; +pub const DEFAULT_MAX_REQUEST_TARGET_BYTES: u64 = 8_192; + +/// Opaque application-owned lease transferred to one admitted request. +pub struct IngressGrant { + value: Option>, +} + +impl IngressGrant { + /// Extracts the application-owned value without exposing its type metadata. + /// + /// # Errors + /// Returns the unchanged grant when it is empty or stores a different type. + #[inline] + pub fn downcast(mut self) -> Result + where + T: Send + Sync + 'static, + { + let Some(boxed) = self.value.take() else { + return Err(self); + }; + match boxed.downcast::() { + Ok(typed) => Ok(*typed), + Err(original) => { + self.value = Some(original); + Err(self) + } + } + } + + #[must_use] + #[inline] + pub fn empty() -> Self { + Self { value: None } + } + + #[must_use] + #[inline] + pub fn new(value: T) -> Self + where + T: Send + Sync + 'static, + { + Self { + value: Some(Box::new(value)), + } + } +} + +impl fmt::Debug for IngressGrant { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("IngressGrant") + .field("occupied", &self.value.is_some()) + .finish() + } +} + +/// Cloneable application response restricted to finite buffered body bytes. +#[derive(Clone, Debug)] +pub struct BufferedIngressResponse { + body: Bytes, + headers: HeaderMap, + status: StatusCode, +} + +impl BufferedIngressResponse { + /// Returns the finite buffered response bytes. + #[must_use] + #[inline] + pub fn body(&self) -> &[u8] { + &self.body + } + + /// Returns the application-selected response headers. + #[must_use] + #[inline] + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + pub(crate) fn into_response(self) -> Response { + let mut response = Response::new(Body::from_bytes(self.body)); + *response.status_mut() = self.status; + *response.headers_mut() = self.headers; + response + } + + /// Constructs an exact buffered response from application-selected parts. + #[must_use] + #[inline] + pub fn new(status: StatusCode, headers: HeaderMap, body: B) -> Self + where + B: Into, + { + Self { + body: body.into(), + headers, + status, + } + } + + /// Returns the application-selected response status. + #[must_use] + #[inline] + pub fn status(&self) -> StatusCode { + self.status + } + + /// Constructs a buffered UTF-8 plain-text response with an exact content length. + #[must_use] + #[inline] + pub fn text(status: StatusCode, text: S) -> Self + where + S: Into, + { + let body = Bytes::from(text.into()); + let mut headers = HeaderMap::new(); + headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + headers.insert(CONTENT_LENGTH, HeaderValue::from(body.len())); + Self::new(status, headers, body) + } +} + +/// Application decision made before an inbound body can be consumed. +#[non_exhaustive] +pub enum AdmissionDecision { + /// Closes or resets the native request without producing an HTTP response. + Abort, + Admit { + completion: ResponseEgressCompletion, + grant: IngressGrant, + read_deadline: Deadline, + }, + /// Drains an unmatched or wrong-method request body before returning the canonical + /// pre-resolved 404/405 response. + ReadBodyBeforeFallback { + completion: ResponseEgressCompletion, + grant: IngressGrant, + max_body_bytes: usize, + read_deadline: Deadline, + on_exceeded: BufferedIngressResponse, + on_timeout: BufferedIngressResponse, + }, + Refuse { + completion: ResponseEgressCompletion, + response: Response, + }, +} + +struct FallbackDispatch { + max_body_bytes: usize, + on_exceeded: BufferedIngressResponse, + on_timeout: BufferedIngressResponse, +} + +enum IngressDispatchDisposition { + Dispatch, + ReadBodyBeforeFallback(Box), +} + +pub(crate) struct FallbackIngress { + dispatch: Box, + grant: IngressGrant, + monotonic_clock: MonotonicClock, + read_deadline: Deadline, +} + +impl FallbackIngress { + pub(crate) fn into_parts( + self, + ) -> ( + IngressGrant, + usize, + Deadline, + MonotonicClock, + BufferedIngressResponse, + BufferedIngressResponse, + ) { + let FallbackDispatch { + max_body_bytes, + on_exceeded, + on_timeout, + } = *self.dispatch; + ( + self.grant, + max_body_bytes, + self.read_deadline, + self.monotonic_clock, + on_exceeded, + on_timeout, + ) + } +} + +/// Validated policy outcome consumed by an adapter before native body ownership moves. +/// +/// `Admitted` includes both normal routed dispatch and an opt-in bounded fallback drain. +#[non_exhaustive] +pub enum IngressAdmissionOutcome { + Aborted, + Admitted { + completion: ResponseEgressCompletion, + ingress: AdmittedIngress, + }, + Refused { + completion: ResponseEgressCompletion, + response: Response, + }, +} + +/// Result of route resolution plus application admission before native body ownership moves. +#[non_exhaustive] +pub enum IngressBeginOutcome { + Aborted, + Admitted(PreparedIngress), + Refused(ResponseEgressEnvelope), +} + +/// Result of complete ingress dispatch, preserving abort as a non-response outcome. +#[non_exhaustive] +pub enum IngressDispatchOutcome { + Aborted, + Response(Box), +} + +/// Proof that the application selected one request disposition with a finite read deadline. +pub struct AdmittedIngress { + config_extraction_limits: ConfigExtractionLimits, + dispatch_disposition: IngressDispatchDisposition, + grant: IngressGrant, + monotonic_clock: MonotonicClock, + read_deadline: Deadline, + request_start: MonotonicInstant, +} + +impl AdmittedIngress { + pub(crate) fn into_fallback(self) -> Option { + let Self { + dispatch_disposition, + grant, + monotonic_clock, + read_deadline, + .. + } = self; + let IngressDispatchDisposition::ReadBodyBeforeFallback(dispatch) = dispatch_disposition + else { + return None; + }; + Some(FallbackIngress { + dispatch, + grant, + monotonic_clock, + read_deadline, + }) + } + + pub(crate) fn into_parts( + self, + ) -> ( + MonotonicInstant, + Deadline, + IngressGrant, + ConfigExtractionLimits, + MonotonicClock, + ) { + ( + self.request_start, + self.read_deadline, + self.grant, + self.config_extraction_limits, + self.monotonic_clock, + ) + } + + pub(crate) fn is_fallback(&self) -> bool { + matches!( + self.dispatch_disposition, + IngressDispatchDisposition::ReadBodyBeforeFallback(_) + ) + } + + #[must_use] + #[inline] + pub fn monotonic_clock(&self) -> MonotonicClock { + self.monotonic_clock.clone() + } + + #[must_use] + #[inline] + pub fn read_deadline(&self) -> Deadline { + self.read_deadline + } + + #[must_use] + #[inline] + pub fn request_start(&self) -> MonotonicInstant { + self.request_start + } + + pub(crate) fn with_config_extraction_limits(mut self, limits: ConfigExtractionLimits) -> Self { + self.config_extraction_limits = limits; + self + } +} + +/// Whether request-head accounting was enforced before normalized request construction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum IngressHeadAccounting { + HostManaged, + RawValidated { + request_header_bytes: u64, + request_header_count: u64, + request_target_bytes: u64, + }, +} + +/// Trusted framing result, or an explicit marker that the host owns validation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum IngressFraming { + Chunked, + ContentLength(u64), + HostManaged, + NoBody, + ProtocolManaged, +} + +/// Finite request-head limits installed before an adapter begins serving. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct IngressHeadLimits { + header_bytes: u64, + header_count: u64, + target_bytes: u64, +} + +impl IngressHeadLimits { + #[must_use] + #[inline] + pub fn max_request_header_bytes(&self) -> u64 { + self.header_bytes + } + + #[must_use] + #[inline] + pub fn max_request_header_count(&self) -> u64 { + self.header_count + } + + #[must_use] + #[inline] + pub fn max_request_target_bytes(&self) -> u64 { + self.target_bytes + } + + /// # Errors + /// Returns an internal startup-policy error when `max` is zero. + #[inline] + pub fn with_max_request_header_bytes(mut self, max: u64) -> Result { + require_nonzero("max_request_header_bytes", max)?; + self.header_bytes = max; + Ok(self) + } + + /// # Errors + /// Returns an internal startup-policy error when `max` is zero. + #[inline] + pub fn with_max_request_header_count(mut self, max: u64) -> Result { + require_nonzero("max_request_header_count", max)?; + self.header_count = max; + Ok(self) + } + + /// # Errors + /// Returns an internal startup-policy error when `max` is zero. + #[inline] + pub fn with_max_request_target_bytes(mut self, max: u64) -> Result { + require_nonzero("max_request_target_bytes", max)?; + self.target_bytes = max; + Ok(self) + } +} + +impl Default for IngressHeadLimits { + #[inline] + fn default() -> Self { + Self { + header_bytes: DEFAULT_MAX_REQUEST_HEADER_BYTES, + header_count: DEFAULT_MAX_REQUEST_HEADER_COUNT, + target_bytes: DEFAULT_MAX_REQUEST_TARGET_BYTES, + } + } +} + +/// Normalized, body-free request metadata supplied by an adapter before admission. +pub struct IngressHeadParts { + extensions: Extensions, + framing: IngressFraming, + head_accounting: IngressHeadAccounting, + headers: HeaderMap, + method: Method, + target: Uri, + version: Version, +} + +impl IngressHeadParts { + /// Copies normalized metadata from body-free request parts. + #[must_use] + #[inline] + pub fn from_parts( + parts: &RequestParts, + head_accounting: IngressHeadAccounting, + framing: IngressFraming, + ) -> Self { + Self { + extensions: parts.extensions.clone(), + framing, + head_accounting, + headers: parts.headers.clone(), + method: parts.method.clone(), + target: parts.uri.clone(), + version: parts.version, + } + } + + /// Copies normalized metadata from a core request without inspecting its body. + #[must_use] + #[inline] + pub fn from_request( + request: &Request, + head_accounting: IngressHeadAccounting, + framing: IngressFraming, + ) -> Self { + Self { + extensions: request.extensions().clone(), + framing, + head_accounting, + headers: request.headers().clone(), + method: request.method().clone(), + target: request.uri().clone(), + version: request.version(), + } + } + + pub(crate) fn into_head( + self, + request_start: MonotonicInstant, + route_resolution: RouteResolution, + ) -> IngressHead { + IngressHead { + extensions: self.extensions, + framing: self.framing, + head_accounting: self.head_accounting, + headers: self.headers, + method: self.method, + request_start, + route_resolution, + target: self.target, + version: self.version, + } + } + + #[must_use] + #[inline] + pub fn method(&self) -> &Method { + &self.method + } + + /// Creates host-managed request-head metadata. Adapters with a raw parser boundary replace + /// the accounting and framing markers through the builder methods below. + #[must_use] + #[inline] + pub fn new(method: Method, target: Uri, version: Version, headers: HeaderMap) -> Self { + Self { + extensions: Extensions::new(), + framing: IngressFraming::HostManaged, + head_accounting: IngressHeadAccounting::HostManaged, + headers, + method, + target, + version, + } + } + + #[must_use] + #[inline] + pub fn target(&self) -> &Uri { + &self.target + } + + /// Applies normalized defense-in-depth checks without making a raw-boundary claim. + /// + /// # Errors + /// Returns 414/431 for normalized head overages and 400 for visible ambiguous framing. + #[inline] + pub fn validate_normalized(&self, limits: IngressHeadLimits) -> Result<(), EdgeError> { + validate_normalized_head(&self.target, self.version, &self.headers, limits) + } + + #[must_use] + #[inline] + pub fn with_extension(mut self, value: T) -> Self + where + T: Clone + Send + Sync + 'static, + { + self.extensions.insert(value); + self + } + + #[must_use] + #[inline] + pub fn with_framing(mut self, framing: IngressFraming) -> Self { + self.framing = framing; + self + } + + #[must_use] + #[inline] + pub fn with_head_accounting(mut self, head_accounting: IngressHeadAccounting) -> Self { + self.head_accounting = head_accounting; + self + } +} + +/// Immutable request-head view supplied to the application admission policy. +pub struct IngressHead { + extensions: Extensions, + framing: IngressFraming, + head_accounting: IngressHeadAccounting, + headers: HeaderMap, + method: Method, + request_start: MonotonicInstant, + route_resolution: RouteResolution, + target: Uri, + version: Version, +} + +impl IngressHead { + #[must_use] + #[inline] + pub fn extension(&self) -> Option<&T> + where + T: Send + Sync + 'static, + { + self.extensions.get::() + } + + #[must_use] + #[inline] + pub fn framing(&self) -> IngressFraming { + self.framing + } + + /// Builds the body-blind admission view from a normalized core request. + #[must_use] + #[inline] + pub fn from_request( + request: &Request, + request_start: MonotonicInstant, + route_resolution: RouteResolution, + head_accounting: IngressHeadAccounting, + framing: IngressFraming, + ) -> Self { + IngressHeadParts::from_request(request, head_accounting, framing) + .into_head(request_start, route_resolution) + } + + #[must_use] + #[inline] + pub fn head_accounting(&self) -> IngressHeadAccounting { + self.head_accounting + } + + #[must_use] + #[inline] + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + #[must_use] + #[inline] + pub fn method(&self) -> &Method { + &self.method + } + + /// Builds a finite deadline relative to this head's captured request start. + /// + /// The duration is clamped to the configured far-future bound. Arithmetic + /// overflow fails closed by returning a deadline at the request start. + #[must_use] + #[inline] + pub fn read_deadline_after(&self, duration: Duration) -> Deadline { + let bounded = duration.min(DEADLINE_FAR_FUTURE); + Deadline::at_instant( + self.request_start + .checked_add(bounded) + .unwrap_or(self.request_start), + ) + } + + #[must_use] + #[inline] + pub fn request_start(&self) -> MonotonicInstant { + self.request_start + } + + #[must_use] + #[inline] + pub fn route_resolution(&self) -> &RouteResolution { + &self.route_resolution + } + + #[must_use] + #[inline] + pub fn target(&self) -> &Uri { + &self.target + } + + #[must_use] + #[inline] + pub fn version(&self) -> Version { + self.version + } + + #[must_use] + #[inline] + pub fn with_extension(mut self, value: T) -> Self + where + T: Clone + Send + Sync + 'static, + { + self.extensions.insert(value); + self + } +} + +/// Opaque, single-use admission proof paired with the exact resolved dispatch token. +pub struct PreparedIngress { + admitted: AdmittedIngress, + completion: ResponseEgressCompletion, + resolved: ResolvedDispatch, +} + +impl PreparedIngress { + pub(crate) fn into_parts( + self, + ) -> (ResolvedDispatch, AdmittedIngress, ResponseEgressCompletion) { + (self.resolved, self.admitted, self.completion) + } + + #[must_use] + #[inline] + pub fn monotonic_clock(&self) -> MonotonicClock { + self.admitted.monotonic_clock() + } + + pub(crate) fn new( + resolved: ResolvedDispatch, + admitted: AdmittedIngress, + completion: ResponseEgressCompletion, + ) -> Self { + Self { + admitted, + completion, + resolved, + } + } + + #[must_use] + #[inline] + pub fn read_deadline(&self) -> Deadline { + self.admitted.read_deadline() + } + + /// Canonical request method captured before admission. + #[must_use] + #[inline] + pub fn request_method(&self) -> &Method { + self.resolved.method() + } + + #[must_use] + #[inline] + pub fn request_start(&self) -> MonotonicInstant { + self.admitted.request_start() + } + + /// Canonical metadata for the admitted matched route, if any. + #[must_use] + #[inline] + pub fn route_metadata(&self) -> Option<&RouteMetadata> { + match self.resolved.resolution() { + RouteResolution::Matched(route) => Some(route), + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound => None, + } + } + + #[must_use] + #[inline] + pub fn route_resolution(&self) -> &RouteResolution { + self.resolved.resolution() + } +} + +pub(crate) type IngressAdmissionPolicy = + Arc AdmissionDecision + Send + Sync>; + +pub(crate) fn default_admission_policy() -> IngressAdmissionPolicy { + Arc::new(|head| AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(DEFAULT_INBOUND_READ_BUDGET), + }) +} + +pub(crate) fn apply_admission_policy( + policy: &IngressAdmissionPolicy, + head: &IngressHead, + monotonic_clock: MonotonicClock, +) -> Result { + let (completion, grant, read_deadline, dispatch_disposition) = match policy(head) { + AdmissionDecision::Refuse { + completion, + response, + } => { + return Ok(IngressAdmissionOutcome::Refused { + completion, + response, + }); + } + AdmissionDecision::Abort => return Ok(IngressAdmissionOutcome::Aborted), + AdmissionDecision::Admit { + completion, + grant, + read_deadline, + } => ( + completion, + grant, + read_deadline, + IngressDispatchDisposition::Dispatch, + ), + AdmissionDecision::ReadBodyBeforeFallback { + completion, + grant, + max_body_bytes, + read_deadline, + on_exceeded, + on_timeout, + } => { + match head.route_resolution() { + RouteResolution::MethodNotAllowed { .. } | RouteResolution::NotFound => {} + RouteResolution::Matched(_) => { + return Err(EdgeError::internal(anyhow::anyhow!( + "fallback body policy requires an unmatched or wrong-method route" + ))); + } + } + ( + completion, + grant, + read_deadline, + IngressDispatchDisposition::ReadBodyBeforeFallback(Box::new(FallbackDispatch { + max_body_bytes, + on_exceeded, + on_timeout, + })), + ) + } + }; + let maximum = head + .request_start() + .checked_add(DEADLINE_FAR_FUTURE) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "ingress admission deadline arithmetic overflow" + )) + })?; + let deadline = Deadline::at_instant(read_deadline.instant().min(maximum)); + Ok(IngressAdmissionOutcome::Admitted { + completion, + ingress: AdmittedIngress { + config_extraction_limits: ConfigExtractionLimits::default(), + dispatch_disposition, + grant, + monotonic_clock, + read_deadline: deadline, + request_start: head.request_start(), + }, + }) +} + +fn require_nonzero(name: &str, value: u64) -> Result<(), EdgeError> { + if value == 0 { + return Err(EdgeError::internal(anyhow::anyhow!( + "ingress head limit `{name}` must be nonzero" + ))); + } + Ok(()) +} + +/// Applies finite defense-in-depth limits and framing checks to a normalized head. +/// +/// This cannot recover raw HTTP field-line or request-target octets and therefore never +/// upgrades [`IngressHeadAccounting::HostManaged`] or [`IngressFraming::HostManaged`]. +/// +/// # Errors +/// Returns 414/431 for normalized head overages and 400 for visible ambiguous framing. +#[inline] +pub fn validate_normalized_ingress_parts( + parts: &RequestParts, + limits: IngressHeadLimits, +) -> Result<(), EdgeError> { + validate_normalized_head(&parts.uri, parts.version, &parts.headers, limits) +} + +fn validate_normalized_head( + target: &Uri, + version: Version, + headers: &HeaderMap, + limits: IngressHeadLimits, +) -> Result<(), EdgeError> { + let target_bytes = u64::try_from(target.to_string().len()).map_err(|_length_error| { + EdgeError::uri_too_long("normalized request target is too large") + })?; + if target_bytes > limits.max_request_target_bytes() { + return Err(EdgeError::uri_too_long( + "normalized request target exceeds configured limit", + )); + } + + let header_count = u64::try_from(headers.len()).map_err(|_length_error| { + EdgeError::request_header_fields_too_large("normalized request has too many headers") + })?; + if header_count > limits.max_request_header_count() { + return Err(EdgeError::request_header_fields_too_large( + "normalized request header count exceeds configured limit", + )); + } + + let mut header_bytes = 2_u64; + for (name, value) in headers { + let line_bytes = name + .as_str() + .len() + .checked_add(value.as_bytes().len()) + .and_then(|bytes| bytes.checked_add(4)) + .and_then(|bytes| u64::try_from(bytes).ok()) + .ok_or_else(|| { + EdgeError::request_header_fields_too_large( + "normalized request header size accounting overflow", + ) + })?; + header_bytes = header_bytes.checked_add(line_bytes).ok_or_else(|| { + EdgeError::request_header_fields_too_large( + "normalized request header size accounting overflow", + ) + })?; + if header_bytes > limits.max_request_header_bytes() { + return Err(EdgeError::request_header_fields_too_large( + "normalized request headers exceed configured limit", + )); + } + } + + validate_normalized_framing(version, headers) +} + +fn validate_normalized_framing(version: Version, headers: &HeaderMap) -> Result<(), EdgeError> { + let content_lengths = headers.get_all(CONTENT_LENGTH).iter().collect::>(); + let transfer_encodings = headers + .get_all(TRANSFER_ENCODING) + .iter() + .collect::>(); + + if !content_lengths.is_empty() && !transfer_encodings.is_empty() { + return Err(EdgeError::bad_request( + "ambiguous request framing is not accepted", + )); + } + if content_lengths.len() > 1 { + return Err(EdgeError::bad_request( + "duplicate content-length is not accepted", + )); + } + if let Some(value) = content_lengths.first() { + let raw = value + .to_str() + .map_err(|_utf8_error| EdgeError::bad_request("malformed content-length"))? + .trim(); + if raw.is_empty() + || raw.contains(',') + || raw.starts_with(['+', '-']) + || raw.parse::().is_err() + { + return Err(EdgeError::bad_request("malformed content-length")); + } + } + + if transfer_encodings.len() > 1 { + return Err(EdgeError::bad_request( + "duplicate transfer-encoding is not accepted", + )); + } + if let Some(value) = transfer_encodings.first() { + if version != Version::HTTP_11 { + return Err(EdgeError::bad_request( + "transfer-encoding is not accepted for this HTTP version", + )); + } + let raw = value + .to_str() + .map_err(|_utf8_error| EdgeError::bad_request("malformed transfer-encoding"))?; + if raw.contains(',') || !raw.trim().eq_ignore_ascii_case("chunked") { + return Err(EdgeError::bad_request( + "unsupported or malformed transfer-encoding", + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::body::Body; + use crate::http::{HeaderName, HeaderValue, StatusCode, request_builder, response_builder}; + + fn parts_with_headers(version: Version, headers: &[(&str, &str)]) -> RequestParts { + let mut request = request_builder() + .uri("/") + .version(version) + .body(Body::empty()) + .expect("request"); + for (name, value) in headers { + request.headers_mut().append( + name.parse::().expect("header name"), + HeaderValue::from_str(value).expect("header value"), + ); + } + request.into_parts().0 + } + + #[test] + fn ingress_grant_downcasts_once_without_exposing_type_metadata() { + let string_grant = IngressGrant::new(String::from("lease")); + let retained_grant = string_grant.downcast::().expect_err("wrong type"); + assert_eq!( + retained_grant.downcast::().expect("right type"), + "lease" + ); + IngressGrant::empty() + .downcast::() + .expect_err("empty grant"); + } + + #[test] + fn buffered_ingress_response_is_cloneable_and_builds_exact_plain_text() { + let response = + BufferedIngressResponse::text(StatusCode::PAYLOAD_TOO_LARGE, "fallback body exceeded"); + let cloned = response.clone(); + + assert_eq!(cloned.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + cloned.headers().get("content-type").expect("content type"), + "text/plain; charset=utf-8" + ); + assert_eq!( + cloned + .headers() + .get("content-length") + .expect("content length"), + "22" + ); + assert_eq!(cloned.body(), b"fallback body exceeded"); + } + + #[test] + fn ingress_head_limits_are_finite_nonzero_and_independently_configurable() { + let limits = IngressHeadLimits::default(); + assert_eq!(limits.max_request_header_bytes(), 0x0001_0000); + assert_eq!(limits.max_request_header_count(), 100); + assert_eq!(limits.max_request_target_bytes(), 8_192); + limits + .with_max_request_header_bytes(0) + .expect_err("zero header bytes"); + limits + .with_max_request_header_count(0) + .expect_err("zero header count"); + limits + .with_max_request_target_bytes(0) + .expect_err("zero target bytes"); + } + + #[test] + fn normalized_head_limits_reject_before_admission_without_raw_claims() { + let target_request = request_builder() + .uri("/1234") + .body(Body::empty()) + .expect("request"); + let target_parts = target_request.into_parts().0; + let target_limits = IngressHeadLimits::default() + .with_max_request_target_bytes(4) + .expect("target limit"); + assert_eq!( + validate_normalized_ingress_parts(&target_parts, target_limits) + .expect_err("target over limit") + .status(), + StatusCode::URI_TOO_LONG + ); + + let count_parts = parts_with_headers(Version::HTTP_11, &[("x-a", "1"), ("x-b", "2")]); + let count_limits = IngressHeadLimits::default() + .with_max_request_header_count(1) + .expect("count limit"); + assert_eq!( + validate_normalized_ingress_parts(&count_parts, count_limits) + .expect_err("header count over limit") + .status(), + StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE + ); + + let bytes_parts = parts_with_headers(Version::HTTP_11, &[("x", "1")]); + let bytes_limits = IngressHeadLimits::default() + .with_max_request_header_bytes(7) + .expect("bytes limit"); + assert_eq!( + validate_normalized_ingress_parts(&bytes_parts, bytes_limits) + .expect_err("header bytes over limit") + .status(), + StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE + ); + } + + #[test] + fn normalized_framing_defense_rejects_visible_ambiguous_shapes() { + let cases = [ + parts_with_headers( + Version::HTTP_11, + &[("content-length", "1"), ("transfer-encoding", "chunked")], + ), + parts_with_headers( + Version::HTTP_11, + &[("content-length", "1"), ("content-length", "1")], + ), + parts_with_headers(Version::HTTP_11, &[("content-length", "1, 1")]), + parts_with_headers(Version::HTTP_11, &[("content-length", "+1")]), + parts_with_headers( + Version::HTTP_11, + &[("content-length", "18446744073709551616")], + ), + parts_with_headers(Version::HTTP_11, &[("transfer-encoding", "gzip, chunked")]), + parts_with_headers(Version::HTTP_2, &[("transfer-encoding", "chunked")]), + ]; + for parts in cases { + assert_eq!( + validate_normalized_ingress_parts(&parts, IngressHeadLimits::default()) + .expect_err("ambiguous framing") + .status(), + StatusCode::BAD_REQUEST + ); + } + + for parts in [ + parts_with_headers(Version::HTTP_11, &[("content-length", "1")]), + parts_with_headers(Version::HTTP_11, &[("transfer-encoding", "chunked")]), + ] { + validate_normalized_ingress_parts(&parts, IngressHeadLimits::default()) + .expect("unambiguous visible framing"); + } + } + + #[test] + fn admission_clamps_deadline_and_preserves_refusal() { + let start = MonotonicInstant::now(); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let head = IngressHead::from_request( + &request, + start, + RouteResolution::NotFound, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + let too_late = Deadline::at_instant( + start + .checked_add(DEADLINE_FAR_FUTURE + Duration::from_secs(1)) + .expect("deadline"), + ); + let admit_policy: IngressAdmissionPolicy = Arc::new(move |_| AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + read_deadline: too_late, + }); + let IngressAdmissionOutcome::Admitted { + completion, + ingress: admitted, + } = apply_admission_policy(&admit_policy, &head, MonotonicClock::default()) + .expect("admission") + else { + panic!("expected admission"); + }; + drop(completion); + assert_eq!( + admitted.read_deadline().instant(), + start.checked_add(DEADLINE_FAR_FUTURE).expect("maximum") + ); + + let refuse_policy: IngressAdmissionPolicy = Arc::new(|_| AdmissionDecision::Refuse { + completion: ResponseEgressCompletion::empty(), + response: response_builder() + .status(StatusCode::TOO_MANY_REQUESTS) + .body(Body::empty()) + .expect("response"), + }); + let IngressAdmissionOutcome::Refused { + completion: refusal_completion, + response, + } = apply_admission_policy(&refuse_policy, &head, MonotonicClock::default()) + .expect("refusal") + else { + panic!("expected refusal"); + }; + drop(refusal_completion); + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + } + + #[test] + fn ingress_head_builds_relative_deadlines_in_its_start_clock_domain() { + let global = MonotonicInstant::now(); + let request_start = global + .checked_add(Duration::from_hours(24)) + .expect("offset request start"); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let head = IngressHead::from_request( + &request, + request_start, + RouteResolution::NotFound, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + + assert_eq!( + head.read_deadline_after(Duration::from_secs(2)).instant(), + request_start + .checked_add(Duration::from_secs(2)) + .expect("relative deadline") + ); + } +} diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs index d74ba664c..e991e1e8e 100644 --- a/crates/edgezero-core/src/introspection.rs +++ b/crates/edgezero-core/src/introspection.rs @@ -117,7 +117,9 @@ pub async fn config(ctx: RequestContext) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use crate::config_store::{ + ConfigStore, ConfigStoreError, ConfigStoreHandle, ready_config_store_bounded_read, + }; use crate::http::{Method, Response, request_builder}; use crate::router::RouterService; use crate::store_registry::{ConfigRegistry, ConfigStoreBinding, StoreRegistry}; @@ -129,10 +131,10 @@ mod tests { // A config store returning a fixed result for `get`, used to drive the // config handler's status-code mapping. Mirrors the pattern in // extractor.rs::config_extractor_resolves_from_registry. - struct StubStore(Result, ConfigStoreError>); + struct StubStore(Result, ConfigStoreError>); #[async_trait(?Send)] impl ConfigStore for StubStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { match &self.0 { Ok(val) => Ok(val.clone()), Err(ConfigStoreError::Unavailable { .. }) => { @@ -144,6 +146,8 @@ mod tests { Err(_) => Err(ConfigStoreError::internal(anyhow::anyhow!("boom"))), } } + + ready_config_store_bounded_read!(); } // Collect a buffered response body into JSON (introspection responses are @@ -187,7 +191,7 @@ mod tests { #[test] fn manifest_returns_injected_json() { let router = RouterService::builder() - .with_manifest_json("{\"app\":{\"name\":\"t\"}}") + .with_manifest_json(serde_json::json!({"app": {"name": "t"}}).to_string()) .get("/m", manifest) .build(); let req = request_builder() @@ -261,7 +265,7 @@ mod tests { #[test] fn config_happy_path_returns_envelope_data_secret_safe() { let data = serde_json::json!({ "greeting": "hi", "api_token": "demo_api_token" }); - let resp = run_config(StubStore(Ok(Some(valid_envelope_json(data))))); + let resp = run_config(StubStore(Ok(Some(valid_envelope_json(data).into())))); assert_eq!(resp.status(), StatusCode::OK); // Raw envelope `data` verbatim: the secret field holds the KEY NAME, // never a resolved value. @@ -298,22 +302,34 @@ mod tests { #[test] fn config_malformed_envelope_maps_500() { - let resp = run_config(StubStore(Ok(Some("not json".to_owned())))); + let resp = run_config(StubStore(Ok(Some("not json".into())))); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } #[test] fn config_sha_mismatch_maps_500() { // Valid JSON envelope shape but wrong sha → verify() fails. - let bad = r#"{"data":{"a":1},"generated_at":"t","sha256":"deadbeef","version":1}"#; - let resp = run_config(StubStore(Ok(Some(bad.to_owned())))); + let bad = serde_json::json!({ + "data": {"a": 1_u32}, + "generated_at": "t", + "sha256": "deadbeef", + "version": 1_u32, + }) + .to_string(); + let resp = run_config(StubStore(Ok(Some(bad.into())))); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } #[test] fn config_unknown_version_maps_500() { - let bad = r#"{"data":{},"generated_at":"t","sha256":"x","version":99}"#; - let resp = run_config(StubStore(Ok(Some(bad.to_owned())))); + let bad = serde_json::json!({ + "data": {}, + "generated_at": "t", + "sha256": "x", + "version": 99_u32, + }) + .to_string(); + let resp = run_config(StubStore(Ok(Some(bad.into())))); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } } diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index 66aa85456..828f87c81 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -27,14 +27,20 @@ pub mod error; pub mod extractor; pub mod handler; pub mod http; +pub mod ingress; pub mod introspection; pub mod key_value_store; +pub mod logging; pub mod manifest; pub mod middleware; +pub mod outbound; pub mod params; -pub mod proxy; +pub mod platform; pub mod responder; pub mod response; +pub mod response_egress; +#[doc(hidden)] +pub mod response_egress_framing; pub mod router; pub mod secret_store; pub mod store_registry; @@ -42,5 +48,110 @@ pub mod store_registry; /// module docs. Enable via the `test-utils` feature in `[dev-dependencies]`. #[cfg(any(test, feature = "test-utils"))] pub mod test_env; +pub mod time; + +pub use body::{Body, BodyStream}; +pub use compression::{ + BROTLI_DECODER_FIXED_CHARGE_BYTES, ContentEncoding, FLATE_DECODER_FIXED_CHARGE_BYTES, + PassthroughReason, brotli_decoder_memory_charge, classify_content_encoding, + decode_brotli_stream, decode_deflate_stream, decode_gzip_stream, +}; +pub use config_store::{ + BoundedStoreRead, ConfigExtractionLimits, ConfigValue, DEFAULT_CONFIG_BACKEND_BYTES, + DEFAULT_CONFIG_BLOB_BYTES, DEFAULT_CONFIG_EXTRACTION_BYTES, DEFAULT_CONFIG_EXTRACTION_TIMEOUT, + DEFAULT_CONFIG_JSON_DEPTH, DEFAULT_CONFIG_JSON_NODES, DEFAULT_CONFIG_SECRET_BYTES, +}; pub use edgezero_macros::{AppConfig, action, app}; +pub use error::{ + BadGatewayDecodeReason, BadGatewayReason, BudgetSource, EdgeError, ResponseLimitReason, + StoreExtractionReason, +}; +pub use ingress::{ + AdmissionDecision, AdmittedIngress, BufferedIngressResponse, DEFAULT_INBOUND_READ_BUDGET, + DEFAULT_MAX_REQUEST_HEADER_BYTES, DEFAULT_MAX_REQUEST_HEADER_COUNT, + DEFAULT_MAX_REQUEST_TARGET_BYTES, IngressAdmissionOutcome, IngressBeginOutcome, + IngressDispatchOutcome, IngressFraming, IngressGrant, IngressHead, IngressHeadAccounting, + IngressHeadLimits, IngressHeadParts, PreparedIngress, +}; +pub use logging::{BOOT_LOG_LEVEL, BOOT_LOG_TARGET, resolve_logging_level}; +pub use manifest::{ + AtomicHost, BakedManifest, Capability, CapabilitySupport, HostParseError, HostPat, + ManifestCapabilities, ManifestContract, ManifestOutboundCapability, Port, Scheme, + canonicalize_outbound_host, +}; +pub use outbound::{ + ContentTypeIneligibility, ContentTypeProvenance, DEFAULT_MAX_DECODER_BYTES, + DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_OUTBOUND_REQUEST_BODY_BYTES, HttpClient, OutboundBatch, + OutboundBatchDriverEvent, OutboundBatchFailure, OutboundBatchItem, OutboundBatchNext, + OutboundBatchResults, OutboundBatchTermination, OutboundCachePolicy, OutboundHttpClient, + OutboundRequest, OutboundRequestParts, OutboundResponse, OutboundSlotResult, PROXY_HEADER, + ResponseBodyDisposition, ResponseBodyPolicy, ResponseHeadNormalization, ResponseHeaderLimiter, + ResponseMode, apply_response_body_policy, collect_response_stream, + collect_response_stream_until_with_clock, enforce_payload_content_length, insert_proxy_header, + limit_decoded_stream, limit_encoded_stream, normalize_for_dispatch, normalize_response_headers, + rechunk_stream, validate_for_dispatch, +}; +pub use platform::{ + HostIngressMemoryAccounting, InboundRequestPopulationBound, MemoryCeiling, MemoryCeilingScope, + MemoryEnvelope, MemoryEnvelopeValidation, MemoryEnvelopeValidationError, MemoryResource, + MemoryValidationIndeterminateReason, PlatformFact, PlatformMetadata, PlatformResourceSource, + PlatformUnknownReason, +}; +pub use response_egress::{ + DEFAULT_RESPONSE_WRITE_BUDGET, DetachedResponseEgressDecision, + DetachedResponseEgressDecisionFactory, DetachedResponseEgressHead, + RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET, ResponseEgressAttempt, ResponseEgressBeginFailure, + ResponseEgressBodyKind, ResponseEgressCompletion, ResponseEgressDeadline, + ResponseEgressEnvelope, ResponseEgressFallbackDisposition, ResponseEgressHead, + ResponseEgressObserver, ResponseEgressObserverHandle, ResponseEgressOutcome, + ResponseEgressPolicy, ResponseEgressPolicyCallback, ResponseEgressReport, + ResponseEgressResource, ResponseEgressResourceInstallError, default_response_egress_policy, +}; +pub use router::{ResolvedDispatch, RouteId, RouteInfo, RouteMetadata, RouteResolution}; +pub use time::{ + BATCH_DISPATCH_SLACK_MAX, DEADLINE_FAR_FUTURE, DEFAULT_NO_DEADLINE_BUDGET, Deadline, + DispatchBudget, MonotonicClock, MonotonicInstant, dispatch_budget, +}; + +#[cfg(test)] +mod public_config_extraction_contract_tests { + use std::time::Duration; + + #[test] + fn bounded_config_types_and_defaults_are_root_exports() { + let limits = crate::ConfigExtractionLimits::default(); + let _: crate::BoundedStoreRead = crate::BoundedStoreRead { + backend_bytes: 0, + value: None, + }; + assert_eq!(limits.max_blob_bytes, crate::DEFAULT_CONFIG_BLOB_BYTES); + assert_eq!( + limits.max_backend_bytes, + crate::DEFAULT_CONFIG_BACKEND_BYTES + ); + assert_eq!(limits.max_secret_bytes, crate::DEFAULT_CONFIG_SECRET_BYTES); + assert_eq!(limits.max_json_depth, crate::DEFAULT_CONFIG_JSON_DEPTH); + assert_eq!(limits.max_json_nodes, crate::DEFAULT_CONFIG_JSON_NODES); + assert_eq!( + limits.max_total_bytes, + crate::DEFAULT_CONFIG_EXTRACTION_BYTES + ); + assert_eq!(limits.timeout, crate::DEFAULT_CONFIG_EXTRACTION_TIMEOUT); + assert_eq!(crate::BATCH_DISPATCH_SLACK_MAX, Duration::from_millis(25)); + assert_eq!(crate::DEADLINE_FAR_FUTURE, Duration::from_hours(168)); + assert_eq!(crate::DEFAULT_NO_DEADLINE_BUDGET, Duration::from_secs(30)); + } +} + +#[cfg(test)] +mod public_response_egress_contract_tests { + #[test] + fn late_bound_resource_types_are_root_exports() { + let (resource, completion) = crate::ResponseEgressCompletion::late_bound::>(); + let _: crate::ResponseEgressResource> = resource; + let _: crate::ResponseEgressResourceInstallError = + crate::ResponseEgressResourceInstallError::Closed; + drop(completion); + } +} diff --git a/crates/edgezero-core/src/logging.rs b/crates/edgezero-core/src/logging.rs new file mode 100644 index 000000000..5b9ce6e33 --- /dev/null +++ b/crates/edgezero-core/src/logging.rs @@ -0,0 +1,76 @@ +//! Shared logging policy for adapter and application-owned backends. + +use std::str::FromStr as _; + +use log::LevelFilter; + +use crate::env_config::EnvConfig; + +/// Default boot filter for managed backends, independent of ordinary logging. +pub const BOOT_LOG_LEVEL: LevelFilter = LevelFilter::Warn; +/// Logical target for startup diagnostics; this is not a platform log endpoint. +pub const BOOT_LOG_TARGET: &str = "edgezero::boot"; + +/// Resolve `EDGEZERO__LOGGING__LEVEL` without installing or reconfiguring a logger. +/// +/// Valid values are case-insensitive `off`, `error`, `warn`, `info`, `debug`, and +/// `trace`. Missing or invalid values fall back to `Info`. The caller supplies +/// platform-resolved environment configuration; this function does not read the +/// process environment or implicitly apply manifest logging settings. +/// +/// Application-owned backends must apply this level to ordinary records, apply +/// their chosen boot filter to [`BOOT_LOG_TARGET`], and set the facade maximum +/// high enough for both. Raising the facade maximum does not override a backend +/// filter. +#[must_use] +#[inline] +pub fn resolve_logging_level(env: &EnvConfig) -> LevelFilter { + env.logging_level() + .and_then(|raw| LevelFilter::from_str(raw).ok()) + .unwrap_or(LevelFilter::Info) +} + +#[cfg(test)] +mod tests { + use log::LevelFilter; + + use crate::env_config::EnvConfig; + + #[test] + fn boot_target_preserves_warnings_without_runtime_verbosity() { + assert_eq!(super::BOOT_LOG_TARGET, "edgezero::boot"); + assert_eq!(super::BOOT_LOG_LEVEL, LevelFilter::Warn); + } + + #[test] + fn resolver_defaults_absent_and_invalid_levels_to_info() { + for raw in [None, Some(""), Some("invalid"), Some(" debug ")] { + let env = EnvConfig::from_vars(raw.map(|level| ("EDGEZERO__LOGGING__LEVEL", level))); + assert_eq!(super::resolve_logging_level(&env), LevelFilter::Info); + } + } + + #[test] + fn resolver_preserves_all_levels_and_case_insensitivity() { + for (raw, expected) in [ + ("off", LevelFilter::Off), + ("error", LevelFilter::Error), + ("warn", LevelFilter::Warn), + ("info", LevelFilter::Info), + ("debug", LevelFilter::Debug), + ("trace", LevelFilter::Trace), + ("DeBuG", LevelFilter::Debug), + ] { + let env = EnvConfig::from_vars([("EDGEZERO__LOGGING__LEVEL", raw)]); + assert_eq!(super::resolve_logging_level(&env), expected); + } + } + + #[test] + fn resolver_does_not_change_global_logging_state() { + let before = log::max_level(); + let env = EnvConfig::from_vars([("EDGEZERO__LOGGING__LEVEL", "trace")]); + assert_eq!(super::resolve_logging_level(&env), LevelFilter::Trace); + assert_eq!(log::max_level(), before); + } +} diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index af2d0fc78..9bc9c9893 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -2,11 +2,257 @@ use log::LevelFilter; use serde::de::Error as DeError; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::fmt; +use std::net::Ipv6Addr; use std::path::{Path, PathBuf}; +use std::str::FromStr as _; use std::sync::Arc; use std::{env, fs, io}; use validator::{Validate, ValidationError}; +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct AtomicHost { + pub host: HostPat, + pub port: Port, + pub scheme: Scheme, +} + +impl AtomicHost { + fn render_host_and_port(&self, scheme: &str, host: &str) -> String { + let mut rendered = format!("{scheme}://{host}"); + match self.port { + Port::Any => rendered.push_str(":*"), + Port::Exact(port) if port != self.scheme.default_port() => { + rendered.push(':'); + rendered.push_str(&port.to_string()); + } + Port::Exact(_) => {} + } + rendered + } + + #[inline] + #[must_use] + pub fn render_spin_host(&self) -> String { + let scheme = match self.scheme { + Scheme::Http => "http", + Scheme::Https => "https", + }; + let host = match &self.host { + HostPat::Any => "*", + HostPat::Exact(host) => host, + HostPat::WildcardSubdomain(host) => return self.render_wildcard_host(scheme, host), + }; + self.render_host_and_port(scheme, host) + } + + fn render_wildcard_host(&self, scheme: &str, host: &str) -> String { + self.render_host_and_port(scheme, &format!("*.{host}")) + } +} + +/// Compile-time manifest state exposed by macro-generated application hooks. +/// +/// This distinguishes an application without a baked manifest from a corrupt +/// baked contract so capability enforcement cannot fail open. +#[derive(Clone, Copy, Debug)] +#[non_exhaustive] +pub enum BakedManifest { + /// No manifest was baked into this application. + Absent, + /// A baked manifest was present but could not be reconstructed safely. + Malformed(&'static str), + /// A parsed, validated, and finalized baked manifest. + Present(&'static Manifest), +} + +impl BakedManifest { + /// Borrow this baked state as the lifetime-neutral capability-gate input. + #[must_use] + #[inline] + pub fn as_contract(&self) -> ManifestContract<'_> { + match self { + Self::Absent => ManifestContract::None, + Self::Malformed(reason) => ManifestContract::Malformed(reason), + Self::Present(manifest) => ManifestContract::Present(manifest), + } + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] +pub enum Capability { + ConfigReadAllocationBounds, + ConfigReadDeadlines, + InboundReadDeadlines, + IngressAdmission, + IngressAdmissionAbort, + LazyStreamedResponsePassthrough, + OutboundAuthorityOverride, + OutboundBatchCancellation, + OutboundBatchCompletionOrder, + OutboundBatchSlotIsolation, + OutboundCacheBypass, + OutboundCompleteResourceAccounting, + OutboundDeadlines, + OutboundFlexiblePhaseBudget, + OutboundHeaderFidelity, + OutboundHttp, + RawIngressFramingValidation, + RawIngressHeadLimits, + ResponseEgressAbort, + ResponseEgressBackpressure, + ResponseEgressCompletion, + ResponseWriteDeadlines, + StreamedUploadDeadlines, +} + +impl Capability { + #[expect( + clippy::allow_attributes, + reason = "the included macro copy is private while the core API is public" + )] + #[allow( + clippy::trivially_copy_pass_by_ref, + reason = "the public capability contract specifies as_str(&self)" + )] + #[inline] + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::ConfigReadAllocationBounds => "config-read-allocation-bounds", + Self::ConfigReadDeadlines => "config-read-deadlines", + Self::InboundReadDeadlines => "inbound-read-deadlines", + Self::IngressAdmission => "ingress-admission", + Self::IngressAdmissionAbort => "ingress-admission-abort", + Self::LazyStreamedResponsePassthrough => "lazy-streamed-response-passthrough", + Self::OutboundAuthorityOverride => "outbound-authority-override", + Self::OutboundBatchCancellation => "outbound-batch-cancellation", + Self::OutboundBatchCompletionOrder => "outbound-batch-completion-order", + Self::OutboundBatchSlotIsolation => "outbound-batch-slot-isolation", + Self::OutboundCacheBypass => "outbound-cache-bypass", + Self::OutboundCompleteResourceAccounting => "outbound-complete-resource-accounting", + Self::OutboundDeadlines => "outbound-deadlines", + Self::OutboundFlexiblePhaseBudget => "outbound-flexible-phase-budget", + Self::OutboundHeaderFidelity => "outbound-header-fidelity", + Self::OutboundHttp => "outbound-http", + Self::RawIngressFramingValidation => "raw-ingress-framing-validation", + Self::RawIngressHeadLimits => "raw-ingress-head-limits", + Self::ResponseEgressAbort => "response-egress-abort", + Self::ResponseEgressBackpressure => "response-egress-backpressure", + Self::ResponseEgressCompletion => "response-egress-completion", + Self::ResponseWriteDeadlines => "response-write-deadlines", + Self::StreamedUploadDeadlines => "streamed-upload-deadlines", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum CapabilitySupport { + BestEffort, + BoundedCooperative, + Native, + Unsupported, +} + +/// Manifest input accepted by runtime capability gates. +/// +/// File-backed manifests may be borrowed for any lifetime, while a baked +/// manifest converts through [`BakedManifest::as_contract`]. +#[derive(Clone, Copy, Debug)] +#[non_exhaustive] +pub enum ManifestContract<'manifest> { + /// A manifest contract exists but cannot be verified. + Malformed(&'static str), + /// No capability contract exists. + None, + /// A parsed and validated manifest contract. + Present(&'manifest Manifest), +} + +impl<'manifest> ManifestContract<'manifest> { + /// Convert an optional file-backed manifest reference into a contract. + #[must_use] + #[inline] + pub fn from_opt(manifest: Option<&'manifest Manifest>) -> Self { + manifest.map_or(Self::None, Self::Present) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum HostParseError { + Empty, + FragmentNotAllowed, + InvalidHost, + InvalidPort, + InvalidScheme, + InvalidWildcard, + MissingAuthority, + NonAsciiHost, + PathNotAllowed, + QueryNotAllowed, + UserinfoNotAllowed, + Whitespace, +} + +impl fmt::Display for HostParseError { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Empty => "host entry is empty", + Self::FragmentNotAllowed => "fragment is not allowed", + Self::InvalidHost => "host is invalid", + Self::InvalidPort => "port must be * or 1..=65535", + Self::InvalidScheme => "scheme must be http or https", + Self::InvalidWildcard => "wildcard is invalid", + Self::MissingAuthority => "authority is required", + Self::NonAsciiHost => "host must be ASCII", + Self::PathNotAllowed => "path is not allowed", + Self::QueryNotAllowed => "query is not allowed", + Self::UserinfoNotAllowed => "userinfo is not allowed", + Self::Whitespace => "whitespace is not allowed", + }) + } +} + +#[expect( + clippy::missing_trait_methods, + reason = "HostParseError has no source or backtrace data beyond Display" +)] +impl Error for HostParseError {} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum HostPat { + Any, + Exact(String), + WildcardSubdomain(String), +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Port { + Any, + Exact(u16), +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Scheme { + Http, + Https, +} + +impl Scheme { + const fn default_port(self) -> u16 { + match self { + Self::Http => 80, + Self::Https => 443, + } + } +} + pub struct ManifestLoader { manifest: Arc, } @@ -17,8 +263,7 @@ impl ManifestLoader { #[inline] pub fn from_path(path: &Path) -> Result { let contents = fs::read_to_string(path)?; - let mut manifest: Manifest = toml::from_str(&contents) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let mut manifest = deserialize_manifest(&contents)?; let cwd = env::current_dir()?; let root_path = resolve_root_path(path, &cwd); manifest.root = Some(root_path); @@ -71,8 +316,7 @@ impl ManifestLoader { /// Returns an [`io::Error`] if `contents` is not valid TOML or fails manifest validation. #[inline] pub fn try_load_from_str(contents: &str) -> Result { - let mut manifest: Manifest = toml::from_str(contents) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let mut manifest = deserialize_manifest(contents)?; manifest .validate() .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; @@ -84,6 +328,7 @@ impl ManifestLoader { } #[derive(Debug, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] #[validate(schema(function = "validate_manifest_adapter_keys_case_unique"))] #[expect( clippy::partial_pub_fields, @@ -98,6 +343,9 @@ pub struct Manifest { pub app: ManifestApp, #[serde(default)] #[validate(nested)] + pub capabilities: ManifestCapabilities, + #[serde(default)] + #[validate(nested)] pub environment: ManifestEnvironment, #[serde(default)] #[validate(nested)] @@ -114,6 +362,29 @@ pub struct Manifest { pub triggers: ManifestTriggers, } +#[derive(Debug, Default, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +#[validate(schema(function = "validate_capabilities_disjoint"))] +#[non_exhaustive] +pub struct ManifestCapabilities { + #[serde(default)] + pub optional: Vec, + #[serde(default)] + #[validate(nested)] + pub outbound: ManifestOutboundCapability, + #[serde(default)] + pub required: Vec, +} + +#[derive(Debug, Default, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +#[non_exhaustive] +pub struct ManifestOutboundCapability { + #[serde(default)] + #[validate(length(min = 1_u64), custom(function = "validate_outbound_hosts"))] + pub hosts: Option>, +} + impl Manifest { /// Look up a `[adapters.]` entry by adapter name, matching /// case-insensitively against the manifest's declared keys. @@ -186,6 +457,37 @@ impl Manifest { self.logging_resolved = resolved; } + /// Parse, validate, and finalize JSON emitted by the `app!` macro. + /// + /// This is macro-support API. Call it at most once per application process; + /// a successful call intentionally leaks one manifest so generated hooks can + /// expose it for the process lifetime. + #[doc(hidden)] + #[inline] + #[must_use] + pub fn from_baked_json(json: &'static str) -> BakedManifest { + let value: serde_json::Value = match serde_json::from_str(json) { + Ok(value) => value, + Err(_error) => { + return BakedManifest::Malformed("baked manifest did not parse"); + } + }; + if reject_misplaced_capabilities_json(&value).is_err() { + return BakedManifest::Malformed("baked manifest has misplaced capabilities"); + } + let mut manifest: Self = match serde_json::from_value(value) { + Ok(manifest) => manifest, + Err(_error) => { + return BakedManifest::Malformed("baked manifest did not parse"); + } + }; + if manifest.validate().is_err() { + return BakedManifest::Malformed("baked manifest failed validation"); + } + manifest.finalize(); + BakedManifest::Present(Box::leak(Box::new(manifest))) + } + #[must_use] #[inline] pub fn logging_for(&self, adapter: &str) -> Option<&ResolvedLoggingConfig> { @@ -249,6 +551,9 @@ pub struct ManifestHttpTrigger { #[serde(rename = "body-mode")] #[serde(default)] pub body_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(length(min = 1_u64))] + pub class: Option, #[serde(default)] #[validate(length(min = 1_u64))] pub description: Option, @@ -363,7 +668,7 @@ pub struct ManifestAdapter { /// Catch-all for any sub-table other than the four canonical ones /// (`adapter`, `build`, `commands`, `logging`). The pre-rewrite /// `[adapters..stores.*]` tables land here and are rejected by - /// [`validate_manifest_adapter`] with the migration-guide message. + /// `validate_manifest_adapter` with the migration-guide message. #[serde(flatten)] pub legacy: BTreeMap, #[serde(default)] @@ -764,6 +1069,83 @@ impl serde::Serialize for LogLevel { } } +/// Canonicalizes one outbound host declaration into atomic scheme/host/port entries. +/// +/// # Errors +/// Returns [`HostParseError`] when `entry` is outside the manifest host grammar. +#[inline] +pub fn canonicalize_outbound_host(entry: &str) -> Result, HostParseError> { + if entry.is_empty() { + return Err(HostParseError::Empty); + } + if entry.chars().any(char::is_whitespace) { + return Err(HostParseError::Whitespace); + } + if entry == "*" { + return Ok(vec![ + AtomicHost { + host: HostPat::Any, + port: Port::Any, + scheme: Scheme::Http, + }, + AtomicHost { + host: HostPat::Any, + port: Port::Any, + scheme: Scheme::Https, + }, + ]); + } + + let (scheme, authority) = parse_scheme(entry)?; + if authority.is_empty() { + return Err(HostParseError::MissingAuthority); + } + if authority.contains('@') { + return Err(HostParseError::UserinfoNotAllowed); + } + if authority.contains('/') { + return Err(HostParseError::PathNotAllowed); + } + if authority.contains('?') { + return Err(HostParseError::QueryNotAllowed); + } + if authority.contains('#') { + return Err(HostParseError::FragmentNotAllowed); + } + if !authority.is_ascii() { + return Err(HostParseError::NonAsciiHost); + } + + let (host, port) = parse_authority(authority, scheme)?; + Ok(vec![AtomicHost { host, port, scheme }]) +} + +/// Canonicalizes the complete outbound host declaration into a sorted, duplicate-free list. +/// +/// An absent declaration retains Spin's HTTPS-only wildcard default. +/// +/// # Errors +/// Returns [`HostParseError`] when any declared host is outside the portable host grammar. +#[inline] +pub fn canonicalize_outbound_hosts( + hosts: Option<&[String]>, +) -> Result, HostParseError> { + let mut canonical = BTreeSet::new(); + match hosts { + Some(entries) => { + for entry in entries { + for host in canonicalize_outbound_host(entry)? { + canonical.insert(host.render_spin_host()); + } + } + } + None => { + canonical.insert(String::from("https://*:*")); + } + } + Ok(canonical.into_iter().collect()) +} + /// Serialize a `[[environment.secrets]]` list without exposing `value`. /// Secret bindings share `ManifestBinding` with variables, whose `value` /// is safe to emit; secret values must never appear in manifest output. @@ -806,6 +1188,214 @@ fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { } } +fn deserialize_manifest(contents: &str) -> Result { + let value: toml::Value = toml::from_str(contents) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + reject_misplaced_capabilities(&value) + .map_err(|message| io::Error::new(io::ErrorKind::InvalidData, message))?; + value + .try_into() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +#[must_use] +pub(crate) fn is_reserved_capabilities_key(key: &str) -> bool { + key.eq_ignore_ascii_case("capabilities") +} + +fn parse_authority(authority: &str, scheme: Scheme) -> Result<(HostPat, Port), HostParseError> { + if let Some(bracketed) = authority.strip_prefix('[') { + let Some((address, remainder)) = bracketed.split_once(']') else { + return Err(HostParseError::InvalidHost); + }; + let ipv6 = Ipv6Addr::from_str(address).map_err(|_error| HostParseError::InvalidHost)?; + let port = if remainder.is_empty() { + Port::Exact(scheme.default_port()) + } else if let Some(raw_port) = remainder.strip_prefix(':') { + parse_port(raw_port)? + } else { + return Err(HostParseError::InvalidHost); + }; + return Ok((HostPat::Exact(format!("[{ipv6}]")), port)); + } + + if authority.contains(['[', ']']) || authority.matches(':').count() > 1 { + return Err(HostParseError::InvalidHost); + } + let (raw_host, raw_port) = match authority.split_once(':') { + Some((host, port)) => (host, Some(port)), + None => (authority, None), + }; + let host = parse_host_pattern(raw_host)?; + let port = raw_port.map_or(Ok(Port::Exact(scheme.default_port())), parse_port)?; + Ok((host, port)) +} + +fn parse_host_pattern(host: &str) -> Result { + if host == "*" { + return Ok(HostPat::Any); + } + if host.contains('*') { + let Some(suffix) = host.strip_prefix("*.") else { + return Err(HostParseError::InvalidWildcard); + }; + if suffix.contains('*') || !is_valid_dns_name(suffix) { + return Err(if suffix.contains('*') { + HostParseError::InvalidWildcard + } else { + HostParseError::InvalidHost + }); + } + return Ok(HostPat::WildcardSubdomain(suffix.to_ascii_lowercase())); + } + if !is_valid_dns_name(host) { + return Err(HostParseError::InvalidHost); + } + Ok(HostPat::Exact(host.to_ascii_lowercase())) +} + +fn parse_port(port: &str) -> Result { + if port == "*" { + return Ok(Port::Any); + } + if !port.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(HostParseError::InvalidPort); + } + let parsed_port = port + .parse::() + .map_err(|_error| HostParseError::InvalidPort)?; + if parsed_port == 0 { + return Err(HostParseError::InvalidPort); + } + Ok(Port::Exact(parsed_port)) +} + +fn parse_scheme(entry: &str) -> Result<(Scheme, &str), HostParseError> { + let Some((raw_scheme, authority)) = entry.split_once("://") else { + return Ok((Scheme::Https, entry)); + }; + let parsed_scheme = if raw_scheme.eq_ignore_ascii_case("http") { + Scheme::Http + } else if raw_scheme.eq_ignore_ascii_case("https") { + Scheme::Https + } else { + return Err(HostParseError::InvalidScheme); + }; + Ok((parsed_scheme, authority)) +} + +fn is_valid_dns_name(host: &str) -> bool { + if host.is_empty() || host.len() > 253 { + return false; + } + host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} + +fn validate_capabilities_disjoint( + capabilities: &ManifestCapabilities, +) -> Result<(), ValidationError> { + let mut required = BTreeSet::new(); + if capabilities + .required + .iter() + .any(|capability| !required.insert(capability.as_str())) + { + return Err(ValidationError::new("capability_required_duplicate")); + } + + let mut optional = BTreeSet::new(); + if capabilities + .optional + .iter() + .any(|capability| !optional.insert(capability.as_str())) + { + return Err(ValidationError::new("capability_optional_duplicate")); + } + if required + .iter() + .any(|capability| optional.contains(capability)) + { + return Err(ValidationError::new("capability_required_optional_overlap")); + } + Ok(()) +} + +fn validate_outbound_hosts(hosts: &[String]) -> Result<(), ValidationError> { + for host in hosts { + if let Err(error) = canonicalize_outbound_host(host) { + let mut validation_error = ValidationError::new("outbound_host"); + validation_error.message = Some(error.to_string().into()); + return Err(validation_error); + } + } + Ok(()) +} + +pub(crate) fn reject_misplaced_capabilities(value: &toml::Value) -> Result<(), &'static str> { + fn walk(value: &toml::Value, top_level: bool) -> Result<(), &'static str> { + match value { + toml::Value::Array(values) => { + for array_value in values { + walk(array_value, false)?; + } + } + toml::Value::Table(table) => { + for (key, nested_value) in table { + if is_reserved_capabilities_key(key) && (!top_level || key != "capabilities") { + return Err( + "capabilities is reserved for the exact lowercase top-level table", + ); + } + walk(nested_value, false)?; + } + } + toml::Value::Boolean(_) + | toml::Value::Datetime(_) + | toml::Value::Float(_) + | toml::Value::Integer(_) + | toml::Value::String(_) => {} + } + Ok(()) + } + + walk(value, true) +} + +fn reject_misplaced_capabilities_json(value: &serde_json::Value) -> Result<(), ()> { + fn walk(value: &serde_json::Value, top_level: bool) -> Result<(), ()> { + match value { + serde_json::Value::Array(values) => { + for array_value in values { + walk(array_value, false)?; + } + } + serde_json::Value::Object(object) => { + for (key, nested_value) in object { + if is_reserved_capabilities_key(key) && (!top_level || key != "capabilities") { + return Err(()); + } + walk(nested_value, false)?; + } + } + serde_json::Value::Bool(_) + | serde_json::Value::Null + | serde_json::Value::Number(_) + | serde_json::Value::String(_) => {} + } + Ok(()) + } + + walk(value, true) +} + /// Validates a single `[adapters..adapter]` block. The portable /// manifest model lists the declared fields explicitly; an unknown key /// would otherwise be silently dropped by serde, so we surface it as a @@ -1066,6 +1656,617 @@ name = "API_TOKEN" env = "APP_TOKEN" "#; + #[test] + #[expect( + clippy::too_many_lines, + reason = "the exhaustive capability parse-and-validation table is clearer in one test" + )] + fn capability_manifest_rejects_unknown_duplicate_and_overlap() { + let source = r#" +[capabilities] +required = [ + "config-read-allocation-bounds", + "config-read-deadlines", + "inbound-read-deadlines", + "ingress-admission", + "ingress-admission-abort", + "lazy-streamed-response-passthrough", + "outbound-authority-override", + "outbound-batch-cancellation", + "outbound-batch-completion-order", + "outbound-batch-slot-isolation", + "outbound-cache-bypass", + "outbound-complete-resource-accounting", + "outbound-deadlines", + "outbound-flexible-phase-budget", + "outbound-header-fidelity", + "outbound-http", + "raw-ingress-framing-validation", + "raw-ingress-head-limits", + "response-egress-abort", + "response-egress-backpressure", + "response-egress-completion", + "response-write-deadlines", + "streamed-upload-deadlines", +] +optional = [] + +[capabilities.outbound] +hosts = ["*", "HTTPS://Example.COM", "api.example.com:8443"] +"#; + let loader = ManifestLoader::try_load_from_str(source).expect("capability manifest"); + let expected = [ + Capability::ConfigReadAllocationBounds, + Capability::ConfigReadDeadlines, + Capability::InboundReadDeadlines, + Capability::IngressAdmission, + Capability::IngressAdmissionAbort, + Capability::LazyStreamedResponsePassthrough, + Capability::OutboundAuthorityOverride, + Capability::OutboundBatchCancellation, + Capability::OutboundBatchCompletionOrder, + Capability::OutboundBatchSlotIsolation, + Capability::OutboundCacheBypass, + Capability::OutboundCompleteResourceAccounting, + Capability::OutboundDeadlines, + Capability::OutboundFlexiblePhaseBudget, + Capability::OutboundHeaderFidelity, + Capability::OutboundHttp, + Capability::RawIngressFramingValidation, + Capability::RawIngressHeadLimits, + Capability::ResponseEgressAbort, + Capability::ResponseEgressBackpressure, + Capability::ResponseEgressCompletion, + Capability::ResponseWriteDeadlines, + Capability::StreamedUploadDeadlines, + ]; + assert_eq!(loader.manifest().capabilities.required, expected); + assert!(loader.manifest().capabilities.optional.is_empty()); + assert_eq!( + loader.manifest().capabilities.outbound.hosts.as_deref(), + Some( + ["*", "HTTPS://Example.COM", "api.example.com:8443"] + .map(String::from) + .as_slice() + ) + ); + for (capability, expected_name) in expected.iter().zip([ + "config-read-allocation-bounds", + "config-read-deadlines", + "inbound-read-deadlines", + "ingress-admission", + "ingress-admission-abort", + "lazy-streamed-response-passthrough", + "outbound-authority-override", + "outbound-batch-cancellation", + "outbound-batch-completion-order", + "outbound-batch-slot-isolation", + "outbound-cache-bypass", + "outbound-complete-resource-accounting", + "outbound-deadlines", + "outbound-flexible-phase-budget", + "outbound-header-fidelity", + "outbound-http", + "raw-ingress-framing-validation", + "raw-ingress-head-limits", + "response-egress-abort", + "response-egress-backpressure", + "response-egress-completion", + "response-write-deadlines", + "streamed-upload-deadlines", + ]) { + assert_eq!(capability.as_str(), expected_name); + } + + let serialized = toml::to_string(loader.manifest()).expect("serialize manifest"); + let reparsed = ManifestLoader::try_load_from_str(&serialized).expect("round trip"); + assert_eq!(reparsed.manifest().capabilities.required, expected); + assert_eq!( + reparsed.manifest().capabilities.outbound.hosts.as_deref(), + loader.manifest().capabilities.outbound.hosts.as_deref() + ); + + for invalid in [ + "[capabilities]\nrequired = [\"outbound-retries\"]\n", + "[capabilities]\nrequired = [\"Outbound-Http\"]\n", + "[capabilities]\nrequire = [\"outbound-http\"]\n", + "[capabilities.outbound]\nhost = [\"*\"]\n", + "[capabilites]\nrequired = [\"outbound-http\"]\n", + "[capability]\nrequired = [\"outbound-http\"]\n", + "[custom]\nvalue = true\n", + "[capabilities]\nrequired = [\"outbound-http\", \"outbound-http\"]\n", + "[capabilities]\noptional = [\"outbound-http\", \"outbound-http\"]\n", + "[capabilities]\nrequired = [\"outbound-http\"]\noptional = [\"outbound-http\"]\n", + "[capabilities.outbound]\nhosts = []\n", + ] { + assert!( + ManifestLoader::try_load_from_str(invalid).is_err(), + "manifest unexpectedly accepted: {invalid}" + ); + } + } + + #[test] + fn capability_manifest_rejects_reserved_key_at_every_depth_and_case() { + let valid = + ManifestLoader::try_load_from_str("[capabilities]\nrequired = [\"outbound-http\"]\n") + .expect("exact lowercase top-level key"); + assert_eq!( + valid.manifest().capabilities.required, + [Capability::OutboundHttp] + ); + + let invalid = [ + "[Capabilities]\nrequired = []\n", + "[CAPABILITIES]\nrequired = []\n", + "[capabilitieS]\nrequired = []\n", + "[app.capabilities]\nrequired = []\n", + "[app.Capabilities]\nrequired = []\n", + "[[triggers.http]]\npath = \"/\"\n[triggers.http.CAPABILITIES]\nrequired = []\n", + "[[environment.variables]]\nname = \"X\"\n[environment.variables.capabilitieS]\nrequired = []\n", + "[adapters.axum.build.cApAbIlItIeS]\nrequired = []\n", + "[app]\nlayers = [{ capabilities = { required = [] } }]\n", + "[app]\nlayers = [[{ CaPaBiLiTiEs = { required = [] } }]]\n", + "[capabilities.outbound.capabilities]\nrequired = []\n", + ]; + + for source in invalid { + assert!( + ManifestLoader::try_load_from_str(source).is_err(), + "reserved key unexpectedly accepted: {source}" + ); + } + } + + #[test] + #[expect( + clippy::too_many_lines, + reason = "the required grammar table keeps accepted and rejected cases together" + )] + fn outbound_host_grammar_table() { + let label_63 = "a".repeat(63); + let name_253 = format!( + "{}.{}.{}.{}", + "a".repeat(63), + "b".repeat(63), + "c".repeat(63), + "d".repeat(61) + ); + let accepted = [ + ( + "*".to_owned(), + vec![ + AtomicHost { + scheme: Scheme::Http, + host: HostPat::Any, + port: Port::Any, + }, + AtomicHost { + scheme: Scheme::Https, + host: HostPat::Any, + port: Port::Any, + }, + ], + vec!["http://*:*", "https://*:*"], + ), + ( + "*.example.com".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::WildcardSubdomain("example.com".to_owned()), + port: Port::Exact(443), + }], + vec!["https://*.example.com"], + ), + ( + "x:8443".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("x".to_owned()), + port: Port::Exact(8443), + }], + vec!["https://x:8443"], + ), + ( + "x:1".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("x".to_owned()), + port: Port::Exact(1), + }], + vec!["https://x:1"], + ), + ( + "x:65535".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("x".to_owned()), + port: Port::Exact(u16::MAX), + }], + vec!["https://x:65535"], + ), + ( + "https://x:443".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("x".to_owned()), + port: Port::Exact(443), + }], + vec!["https://x"], + ), + ( + "https://[::1]".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("[::1]".to_owned()), + port: Port::Exact(443), + }], + vec!["https://[::1]"], + ), + ( + "HTTP://[2001:0DB8::1]:80".to_owned(), + vec![AtomicHost { + scheme: Scheme::Http, + host: HostPat::Exact("[2001:db8::1]".to_owned()), + port: Port::Exact(80), + }], + vec!["http://[2001:db8::1]"], + ), + ( + "https://127.0.0.1".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("127.0.0.1".to_owned()), + port: Port::Exact(443), + }], + vec!["https://127.0.0.1"], + ), + ( + "xn--caf-dma.com".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("xn--caf-dma.com".to_owned()), + port: Port::Exact(443), + }], + vec!["https://xn--caf-dma.com"], + ), + ( + "HTTPS://Example.COM:*".to_owned(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact("example.com".to_owned()), + port: Port::Any, + }], + vec!["https://example.com:*"], + ), + ( + format!("https://{label_63}"), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact(label_63), + port: Port::Exact(443), + }], + vec![], + ), + ( + name_253.clone(), + vec![AtomicHost { + scheme: Scheme::Https, + host: HostPat::Exact(name_253), + port: Port::Exact(443), + }], + vec![], + ), + ]; + + for (entry, expected, rendered) in accepted { + let actual = canonicalize_outbound_host(&entry).expect("accepted host"); + assert_eq!(actual, expected, "entry: {entry}"); + if !rendered.is_empty() { + assert_eq!( + actual + .iter() + .map(AtomicHost::render_spin_host) + .collect::>(), + rendered, + "entry: {entry}" + ); + for host in &actual { + assert_eq!( + canonicalize_outbound_host(&host.render_spin_host()), + Ok(vec![host.clone()]), + "rendered host must parse to the same atomic" + ); + } + } + + let source = format!("[capabilities.outbound]\nhosts = [{entry:?}]\n"); + ManifestLoader::try_load_from_str(&source).expect("manifest host validation"); + } + + let label_64 = "a".repeat(64); + let name_254 = format!( + "{}.{}.{}.{}", + "a".repeat(63), + "b".repeat(63), + "c".repeat(63), + "d".repeat(62) + ); + let rejected = [ + (String::new(), HostParseError::Empty, "host entry is empty"), + ( + " x".to_owned(), + HostParseError::Whitespace, + "whitespace is not allowed", + ), + ( + "x.com ".to_owned(), + HostParseError::Whitespace, + "whitespace is not allowed", + ), + ( + "x .com".to_owned(), + HostParseError::Whitespace, + "whitespace is not allowed", + ), + ( + "https:// x".to_owned(), + HostParseError::Whitespace, + "whitespace is not allowed", + ), + ( + "ftp://x".to_owned(), + HostParseError::InvalidScheme, + "scheme must be http or https", + ), + ( + "https://".to_owned(), + HostParseError::MissingAuthority, + "authority is required", + ), + ( + "https://u:p@x".to_owned(), + HostParseError::UserinfoNotAllowed, + "userinfo is not allowed", + ), + ( + "https://x/p".to_owned(), + HostParseError::PathNotAllowed, + "path is not allowed", + ), + ( + "https://x?q".to_owned(), + HostParseError::QueryNotAllowed, + "query is not allowed", + ), + ( + "https://x#f".to_owned(), + HostParseError::FragmentNotAllowed, + "fragment is not allowed", + ), + ( + "ex\u{20ac}ample.com".to_owned(), + HostParseError::NonAsciiHost, + "host must be ASCII", + ), + ( + "caf\u{e9}.com".to_owned(), + HostParseError::NonAsciiHost, + "host must be ASCII", + ), + ( + "ex*ample.com".to_owned(), + HostParseError::InvalidWildcard, + "wildcard is invalid", + ), + ( + "*.*.com".to_owned(), + HostParseError::InvalidWildcard, + "wildcard is invalid", + ), + ( + "a.*.com".to_owned(), + HostParseError::InvalidWildcard, + "wildcard is invalid", + ), + ( + "**.com".to_owned(), + HostParseError::InvalidWildcard, + "wildcard is invalid", + ), + ( + "-x.com".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "x-.com".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "x..com".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "x_y.com".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "x.com.".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + (label_64, HostParseError::InvalidHost, "host is invalid"), + (name_254, HostParseError::InvalidHost, "host is invalid"), + ( + "https://[::g]".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "https://[:::1]".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "https://[12345::]".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "https://[1:2:3:4:5:6:7:8:9]".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "https://[::1".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "https://::1]".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "https://::1".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ( + "https://x:0".to_owned(), + HostParseError::InvalidPort, + "port must be * or 1..=65535", + ), + ( + "https://x:70000".to_owned(), + HostParseError::InvalidPort, + "port must be * or 1..=65535", + ), + ( + "https://x:abc".to_owned(), + HostParseError::InvalidPort, + "port must be * or 1..=65535", + ), + ( + "x:+443".to_owned(), + HostParseError::InvalidPort, + "port must be * or 1..=65535", + ), + ( + "https://x:".to_owned(), + HostParseError::InvalidPort, + "port must be * or 1..=65535", + ), + ( + "ftp:// x/p?q#f".to_owned(), + HostParseError::Whitespace, + "whitespace is not allowed", + ), + ( + "ftp://".to_owned(), + HostParseError::InvalidScheme, + "scheme must be http or https", + ), + ( + "https://u@x/p?q#f".to_owned(), + HostParseError::UserinfoNotAllowed, + "userinfo is not allowed", + ), + ( + "https://x/p?q#f".to_owned(), + HostParseError::PathNotAllowed, + "path is not allowed", + ), + ( + "https://x?q#f".to_owned(), + HostParseError::QueryNotAllowed, + "query is not allowed", + ), + ( + "https://x#caf\u{e9}".to_owned(), + HostParseError::FragmentNotAllowed, + "fragment is not allowed", + ), + ( + "caf\u{e9}*.com:0".to_owned(), + HostParseError::NonAsciiHost, + "host must be ASCII", + ), + ( + "ex*ample_.com:0".to_owned(), + HostParseError::InvalidWildcard, + "wildcard is invalid", + ), + ( + "x_y.com:0".to_owned(), + HostParseError::InvalidHost, + "host is invalid", + ), + ]; + + for (entry, expected, message) in rejected { + let error = canonicalize_outbound_host(&entry).expect_err("rejected host"); + assert_eq!(error, expected, "entry: {entry}"); + assert_eq!(error.to_string(), message, "entry: {entry}"); + assert!(!error.to_string().is_empty()); + + let source = format!("[capabilities.outbound]\nhosts = [{entry:?}]\n"); + assert!( + ManifestLoader::try_load_from_str(&source).is_err(), + "invalid manifest host accepted: {entry}" + ); + } + } + + #[test] + fn outbound_host_default_is_https_only() { + let loader = ManifestLoader::try_load_from_str("").expect("empty manifest"); + assert!(loader.manifest().capabilities.outbound.hosts.is_none()); + + let absent = canonicalize_outbound_host("https://*:*").expect("https-only default"); + assert_eq!( + absent, + [AtomicHost { + scheme: Scheme::Https, + host: HostPat::Any, + port: Port::Any, + }] + ); + assert_eq!(absent[0].render_spin_host(), "https://*:*"); + + let explicit = canonicalize_outbound_host("*").expect("explicit wildcard"); + assert_eq!(explicit.len(), 2); + assert_eq!(explicit[0].scheme, Scheme::Http); + assert_eq!(explicit[1].scheme, Scheme::Https); + assert!(explicit.iter().all(|host| host.port == Port::Any)); + } + + #[test] + fn canonical_outbound_host_set_expands_defaults_and_normalizes_order() { + assert_eq!( + canonicalize_outbound_hosts(None).expect("absent default"), + vec!["https://*:*".to_owned()] + ); + + let hosts = vec![ + "HTTPS://API.EXAMPLE.COM:443".to_owned(), + "*".to_owned(), + "api.example.com".to_owned(), + ]; + assert_eq!( + canonicalize_outbound_hosts(Some(&hosts)).expect("canonical hosts"), + vec![ + "http://*:*".to_owned(), + "https://*:*".to_owned(), + "https://api.example.com".to_owned(), + ] + ); + } + #[test] fn parse_manifest_sample() { let loader = ManifestLoader::load_from_str(SAMPLE); @@ -2074,6 +3275,7 @@ ids = ["default"] let manifest = r#" [[triggers.http]] id = "route-1" +class = "auction" path = "/api/users" methods = ["GET", "POST"] handler = "handlers::users" @@ -2084,6 +3286,7 @@ body-mode = "buffered" let loader = ManifestLoader::load_from_str(manifest); let trigger = &loader.manifest().triggers.http[0]; assert_eq!(trigger.id.as_deref(), Some("route-1")); + assert_eq!(trigger.class.as_deref(), Some("auction")); assert_eq!(trigger.path, "/api/users"); assert_eq!(trigger.methods(), vec!["GET", "POST"]); assert_eq!(trigger.handler.as_deref(), Some("handlers::users")); @@ -2095,6 +3298,19 @@ body-mode = "buffered" assert_eq!(trigger.body_mode, Some(BodyMode::Buffered)); } + #[test] + fn http_trigger_rejects_empty_route_class() { + let manifest = r#" +[[triggers.http]] +class = "" +path = "/api/users" +"#; + let error = ManifestLoader::try_load_from_str(manifest) + .err() + .expect("an empty route class must fail validation"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + // -- Secret store config ----------------------------------------------- #[test] @@ -2242,4 +3458,29 @@ default = "feature__flags" .err() .expect("double-underscore store id must fail validation"); } + + #[test] + fn baked_manifest_states_are_distinct() { + assert!(matches!( + Manifest::from_baked_json("not json"), + BakedManifest::Malformed("baked manifest did not parse") + )); + assert!(matches!( + Manifest::from_baked_json(r#"{"app":{"Capabilities":{}}}"#), + BakedManifest::Malformed("baked manifest has misplaced capabilities") + )); + let baked = Manifest::from_baked_json(r#"{"capabilities":{"required":["outbound-http"]}}"#); + let BakedManifest::Present(manifest) = baked else { + panic!("expected present baked manifest"); + }; + assert_eq!(manifest.capabilities.required, [Capability::OutboundHttp]); + assert!(matches!( + BakedManifest::Absent.as_contract(), + ManifestContract::None + )); + assert!(matches!( + ManifestContract::from_opt(Some(manifest)), + ManifestContract::Present(_) + )); + } } diff --git a/crates/edgezero-core/src/middleware.rs b/crates/edgezero-core/src/middleware.rs index a9edaf2c0..7d5b2a454 100644 --- a/crates/edgezero-core/src/middleware.rs +++ b/crates/edgezero-core/src/middleware.rs @@ -77,8 +77,8 @@ pub struct RequestLogger; impl Middleware for RequestLogger { #[inline] async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { - let method = ctx.request().method().clone(); - let path = ctx.request().uri().path().to_owned(); + let method = ctx.method().clone(); + let path = ctx.uri().path().to_owned(); let start = Instant::now(); match next.run(ctx).await { diff --git a/crates/edgezero-core/src/outbound.rs b/crates/edgezero-core/src/outbound.rs new file mode 100644 index 000000000..21df3df31 --- /dev/null +++ b/crates/edgezero-core/src/outbound.rs @@ -0,0 +1,4424 @@ +use std::future::Future; +use std::iter; +use std::net::IpAddr; +use std::num::NonZeroU64; +use std::str; +use std::sync::Arc; +use std::task::Poll; +use std::time::Duration; + +use async_stream::stream; +use async_trait::async_trait; +use bytes::Bytes; +use futures_util::StreamExt as _; +use futures_util::future::poll_fn; +use futures_util::stream::{FuturesUnordered, LocalBoxStream, Stream, iter}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use url::Url; + +use crate::body::{Body, BodyStream}; +use crate::compression::{ + ContentEncoding, classify_content_encoding, decode_brotli_stream, decode_deflate_stream, + decode_gzip_stream, +}; +use crate::error::{BadGatewayDecodeReason, BadGatewayReason, EdgeError, ResponseLimitReason}; +use crate::http::header::{ + CONNECTION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, HOST, PROXY_AUTHENTICATE, + PROXY_AUTHORIZATION, TE, TRAILER, TRANSFER_ENCODING, UPGRADE, +}; +use crate::http::{ + Authority, HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode, Uri, + response_builder, +}; +use crate::time::{Deadline, MonotonicClock, MonotonicInstant}; + +pub const DEFAULT_MAX_DECODER_BYTES: u64 = 32 * 1024 * 1024; +pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 1024 * 1024; +pub const DEFAULT_OUTBOUND_REQUEST_BODY_BYTES: u64 = 8 * 1024 * 1024; +/// Response header identifying the adapter that completed an outbound request. +pub const PROXY_HEADER: &str = "x-edgezero-proxy"; + +#[derive(Clone, Copy)] +pub(crate) struct BudgetInputs { + pub deadline: Option, + pub timeout: Option, +} + +#[derive(Debug)] +pub struct OutboundRequest { + body: Body, + cache_policy: OutboundCachePolicy, + deadline: Option, + headers: HeaderMap, + host_authority_override: Option, + max_brotli_window_bits: u8, + max_chunk_bytes: Option, + max_decoded_response_bytes: Option, + max_decoder_bytes: u64, + max_encoded_response_bytes: Option, + max_request_body_bytes: u64, + max_response_header_bytes: Option, + max_response_header_count: Option, + method: Method, + response_mode: ResponseMode, + timeout: Option, + uri: Uri, +} + +#[derive(Debug)] +#[non_exhaustive] +pub struct OutboundRequestParts { + pub body: Body, + pub cache_policy: OutboundCachePolicy, + pub deadline: Option, + pub headers: HeaderMap, + pub host_authority_override: Option, + pub max_brotli_window_bits: u8, + pub max_chunk_bytes: Option, + pub max_decoded_response_bytes: Option, + pub max_decoder_bytes: u64, + pub max_encoded_response_bytes: Option, + pub max_request_body_bytes: u64, + pub max_response_header_bytes: Option, + pub max_response_header_count: Option, + pub method: Method, + pub response_mode: ResponseMode, + pub timeout: Option, + pub uri: Uri, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResponseMode { + Buffered { max_bytes: u64 }, + Streamed, +} + +/// Shared response-body limits and decoding policy applied after adapter header normalization. +/// Transport reads, deadlines, cancellation, and final buffered collection remain adapter-owned. +#[derive(Clone, Copy, Debug)] +pub struct ResponseBodyPolicy { + pub max_brotli_window_bits: u8, + pub max_chunk_bytes: Option, + pub max_decoded_response_bytes: Option, + pub max_decoder_bytes: u64, + pub max_encoded_response_bytes: Option, + pub response_mode: ResponseMode, +} + +/// Controls use of an adapter-managed intermediary cache for one request. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum OutboundCachePolicy { + Bypass, + #[default] + PlatformDefault, +} + +#[derive(Clone)] +pub struct HttpClient { + inner: Arc, +} + +/// One terminal batch slot paired with its original request index. +#[derive(Debug)] +#[non_exhaustive] +pub struct OutboundBatchItem { + pub index: usize, + pub result: OutboundSlotResult, +} + +/// Ordered terminal batch slots and the reason collection stopped. +#[derive(Debug)] +#[non_exhaustive] +pub struct OutboundBatchResults { + /// Index-aligned terminal results. `None` is valid only when termination is + /// [`OutboundBatchTermination::Cutoff`]. + pub slots: Vec>, + /// Why the batch stopped producing terminal results. + pub termination: OutboundBatchTermination, +} + +/// An adapter-driver failure paired with terminal slots collected before it occurred. +#[derive(Debug, thiserror::Error)] +#[error("outbound batch driver failed: {error}")] +#[non_exhaustive] +pub struct OutboundBatchFailure { + /// The precise adapter or core invariant failure. + #[source] + pub error: EdgeError, + /// Index-aligned terminal results observed before the failure. + pub slots: Vec>, +} + +/// Why an outbound batch stopped producing terminal slot results. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum OutboundBatchTermination { + /// Every input slot produced exactly one terminal result and the driver reached clean EOF. + Completed, + /// The method-level observation cutoff won while one or more slots remained unresolved. + Cutoff, +} + +/// One observable step from an outbound batch. +/// +/// After the final [`Self::Item`], callers must poll once more to receive +/// `Finished(Completed)`; completion is not inferred from the number of items observed. +/// That poll waits for driver EOF and still validates trailing events or finalization failures. +#[derive(Debug)] +#[non_exhaustive] +pub enum OutboundBatchNext { + /// The batch reached its normal or cutoff terminal state. + Finished(OutboundBatchTermination), + /// One newly terminal slot. + Item(OutboundBatchItem), +} + +/// Adapter-to-core protocol for constructing an [`OutboundBatch`]. +/// +/// Driver streams emit each terminal index at most once. They emit [`Self::Cutoff`] when the +/// method-level observation cutoff wins, or [`Self::Failed`] when the adapter cannot continue +/// without violating an invariant. Stream EOF is valid only after every input index has emitted; +/// premature EOF is a core invariant failure. +/// Resolving the last slot does not end validation: trailing failures, duplicate/out-of-range +/// indices, and a cutoff with no unresolved slots are errors, including on zero-slot drivers. +#[derive(Debug)] +#[non_exhaustive] +pub enum OutboundBatchDriverEvent { + /// The method-level observation cutoff won before every slot became terminal. + Cutoff, + /// The adapter could not continue driving the batch without violating an invariant. + Failed(EdgeError), + /// One newly terminal slot from an adapter driver. + Item(OutboundBatchItem), +} + +/// Adapter-owned outbound work observed in completion order. +pub struct OutboundBatch { + poisoned: bool, + stream: LocalBoxStream<'static, OutboundBatchDriverEvent>, + termination: Option, + unresolved: Vec, +} + +impl OutboundBatch { + /// Stops observation, drops adapter-owned pending work, and returns unresolved indices. + /// + /// A caller mapping cancellation into its own fan-out result still owes every returned slot + /// an outcome because `EdgeZero` observed no terminal result for those indices. + #[must_use] + #[inline] + pub fn cancel(self) -> Vec { + let Self { + stream, unresolved, .. + } = self; + drop(stream); + unresolved + .iter() + .enumerate() + .filter_map(|(index, is_unresolved)| is_unresolved.then_some(index)) + .collect() + } + + /// Drives the batch to normal completion or cutoff and restores input index order. + /// + /// # Errors + /// Returns the precise adapter or core invariant failure together with every terminal slot + /// collected before the failure. + #[inline] + pub async fn collect(mut self) -> Result { + let mut slots = iter::repeat_with(|| None) + .take(self.unresolved.len()) + .collect::>(); + loop { + match self.next().await { + Err(error) => return Err(OutboundBatchFailure { error, slots }), + Ok(OutboundBatchNext::Item(item)) => { + let Some(slot) = slots.get_mut(item.index) else { + return Err(OutboundBatchFailure { + error: batch_driver_error( + "validated outbound batch index became invalid", + ), + slots, + }); + }; + *slot = Some(item.result); + } + Ok(OutboundBatchNext::Finished(termination)) => { + return Ok(OutboundBatchResults { slots, termination }); + } + } + } + } + + /// Builds an already-cut-off batch without polling adapter transport work. + /// + /// Adapters use this when the method-level observation cutoff is expired at batch entry. Every + /// input index remains unresolved. + /// With zero slots, the driver is empty and normal completion is returned instead. + #[must_use] + #[inline] + pub fn cutoff(slot_count: usize) -> Self { + Self::from_driver( + slot_count, + iter((slot_count != 0).then_some(OutboundBatchDriverEvent::Cutoff)), + ) + } + + /// Builds a batch from an adapter-owned driver stream. + /// + /// `slot_count` fixes the valid index range and the length of collected results. The driver + /// must emit each terminal index exactly once unless it first emits [`OutboundBatchDriverEvent::Cutoff`] + /// or [`OutboundBatchDriverEvent::Failed`]. Ending the stream with unresolved indices is an + /// invariant failure. + /// Core waits for clean EOF even after the last item or when `slot_count` is zero; adapter + /// finalization must not remain pending forever after all slots resolve. + #[must_use] + #[inline] + pub fn from_driver(slot_count: usize, stream: StreamValue) -> Self + where + StreamValue: Stream + 'static, + { + Self { + poisoned: false, + stream: stream.boxed_local(), + termination: None, + unresolved: vec![true; slot_count], + } + } + + /// Builds a batch from preflight outcomes and adapter-owned exchange futures. + /// + /// Every outcome carries its original index and already-sampled terminal instant. Ready + /// futures are drained before the first item is delivered, retaining on-time observations + /// across consumer delays. No transport timer or clock sampling is introduced here. + /// The same driver-index, cutoff, and EOF invariants as [`Self::from_driver`] apply. + #[must_use] + #[inline] + pub fn from_futures( + slot_count: usize, + batch_started_at: MonotonicInstant, + observation_cutoff: Deadline, + mut completed: Vec<(usize, MonotonicInstant, Result)>, + pending_futures: Pending, + ) -> Self + where + F: Future)> + + 'static, + Pending: IntoIterator, + { + let mut pending = pending_futures.into_iter().collect::>(); + let completions = stream! { + poll_fn(|context| { + loop { + match pending.poll_next_unpin(context) { + Poll::Ready(Some(item)) => completed.push(item), + Poll::Ready(None) | Poll::Pending => return Poll::Ready(()), + } + } + }).await; + let mut outcomes = iter(completed).chain(pending); + while let Some((index, completed_at, outcome)) = outcomes.next().await { + let Some(item) = finish_batch_item( + index, batch_started_at, completed_at, observation_cutoff, outcome, + ) else { + drop(outcomes); + yield OutboundBatchDriverEvent::Cutoff; + return; + }; + yield OutboundBatchDriverEvent::Item(item); + } + }; + Self::from_driver(slot_count, completions) + } + + /// Returns the next newly terminal slot or the typed batch termination reason. + /// + /// Cancelling the returned future does not consume a ready slot. + /// + /// # Errors + /// Returns an internal error if the adapter driver fails, ends prematurely, or emits an + /// invalid or duplicate slot index. + #[inline] + pub async fn next(&mut self) -> Result { + if self.poisoned { + return Err(batch_driver_error( + "outbound batch driver was polled after an invariant failure", + )); + } + if let Some(termination) = self.termination { + return Ok(OutboundBatchNext::Finished(termination)); + } + + match self.stream.next().await { + Some(OutboundBatchDriverEvent::Item(item)) => { + let Some(unresolved) = self.unresolved.get_mut(item.index) else { + self.poisoned = true; + return Err(batch_driver_error( + "outbound batch driver emitted an out-of-range slot index", + )); + }; + if !*unresolved { + self.poisoned = true; + return Err(batch_driver_error( + "outbound batch driver emitted a duplicate slot index", + )); + } + *unresolved = false; + Ok(OutboundBatchNext::Item(item)) + } + Some(OutboundBatchDriverEvent::Cutoff) => { + if !self.unresolved.iter().any(|is_unresolved| *is_unresolved) { + self.poisoned = true; + return Err(batch_driver_error( + "outbound batch driver emitted cutoff after every slot resolved", + )); + } + self.termination = Some(OutboundBatchTermination::Cutoff); + Ok(OutboundBatchNext::Finished( + OutboundBatchTermination::Cutoff, + )) + } + Some(OutboundBatchDriverEvent::Failed(error)) => { + self.poisoned = true; + Err(error) + } + None => { + if !self.unresolved.iter().any(|is_unresolved| *is_unresolved) { + self.termination = Some(OutboundBatchTermination::Completed); + return Ok(OutboundBatchNext::Finished( + OutboundBatchTermination::Completed, + )); + } + self.poisoned = true; + Err(batch_driver_error( + "outbound batch driver ended before every slot resolved", + )) + } + } + } +} + +impl From for EdgeError { + #[inline] + fn from(failure: OutboundBatchFailure) -> Self { + failure.error + } +} + +impl OutboundBatchItem { + /// Pairs one terminal result with its original request index. + #[must_use] + #[inline] + pub fn new(index: usize, result: OutboundSlotResult) -> Self { + Self { index, result } + } +} + +#[async_trait(?Send)] +pub trait OutboundHttpClient: Send + Sync { + /// Sends one outbound request. + /// + /// # Errors + /// Returns a typed request, transport, deadline, or response-policy failure. + async fn send(&self, request: OutboundRequest) -> Result; + + /// Starts buffered batch work under one absolute observation cutoff. + /// + /// Every eligible request is attempted before the adapter waits for transport completion. + /// The returned batch yields terminal results in observed completion order with original input + /// indices. Dropping it stops observation and applies the adapter's strongest available + /// teardown without undoing already-issued network side effects. + /// + /// The observation cutoff stops batch observation regardless of any remaining per-request + /// budget or later per-request deadline. + /// Passing an earlier observation cutoff intentionally leaves unresolved slots. + fn start_batch_until( + &self, + requests: Vec, + observation_cutoff: Deadline, + ) -> OutboundBatch; +} + +#[derive(Debug)] +pub struct OutboundResponse { + body: Body, + content_type_provenance: ContentTypeProvenance, + headers: HeaderMap, + headers_mutated: bool, + monotonic_clock: MonotonicClock, + request_method: Method, + status: StatusCode, +} + +#[derive(Debug)] +#[non_exhaustive] +/// One terminal result produced by an outbound batch slot. +pub struct OutboundSlotResult { + /// Time from the batch's shared method-entry snapshot until this slot became terminal. + pub elapsed: Duration, + /// This slot's terminal response or typed failure. + pub outcome: Result, +} + +impl OutboundSlotResult { + #[must_use] + #[inline] + pub fn new(elapsed: Duration, outcome: Result) -> Self { + Self { elapsed, outcome } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResponseBodyDisposition { + FramingBodyless, + Payload, + ResetContent { declared_body: bool }, +} + +/// Why the original Content-Type cannot certify a retained textual singleton. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ContentTypeIneligibility { + Missing, + Modified, + MultipleValues, + NonText, + Removed, +} + +/// Original Content-Type evidence, captured before `EdgeZero` filters response headers. +/// +/// This does not validate MIME syntax, split commas, trim, or redact the value. A textual +/// singleton can still be invalid MIME. `Unknown` is required when the provider hides original +/// bytes or field boundaries. No raw header value is copied into this metadata. +#[non_exhaustive] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ContentTypeProvenance { + Ineligible(ContentTypeIneligibility), + SingleTextUnchanged, + Unknown, +} + +/// Results of one upstream-header normalization pass, including pre-filter evidence. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ResponseHeadNormalization { + pub body_disposition: ResponseBodyDisposition, + pub content_type_provenance: ContentTypeProvenance, +} + +pub struct ResponseHeaderLimiter { + max_bytes: Option, + max_count: Option, + observed_bytes: u64, + observed_count: u64, +} + +impl ResponseHeaderLimiter { + #[must_use] + #[inline] + pub fn new(max_bytes: Option, max_count: Option) -> Self { + Self { + max_bytes, + max_count, + observed_bytes: 0, + observed_count: 0, + } + } + + /// Adds one adapter-visible upstream field section to the cumulative response-header + /// budget before normalization. + /// + /// # Errors + /// Returns a typed response limit error on count, byte, or accounting overflow. + #[inline] + pub fn observe(&mut self, headers: &HeaderMap) -> Result<(), EdgeError> { + for (name, value) in headers { + self.observe_field(name, value)?; + } + Ok(()) + } + + fn observe_field(&mut self, name: &HeaderName, value: &HeaderValue) -> Result<(), EdgeError> { + let next_count = self + .observed_count + .checked_add(1) + .ok_or_else(|| response_limit_error(ResponseLimitReason::HeaderCount))?; + self.observed_count = next_count; + if self.max_count.is_some_and(|max| next_count > max) { + return Err(response_limit_error(ResponseLimitReason::HeaderCount)); + } + + let name_bytes = u64::try_from(name.as_str().len()).unwrap_or(u64::MAX); + let value_bytes = u64::try_from(value.as_bytes().len()).unwrap_or(u64::MAX); + let next_bytes = self + .observed_bytes + .checked_add(name_bytes) + .and_then(|total| total.checked_add(value_bytes)) + .ok_or_else(|| response_limit_error(ResponseLimitReason::HeaderBytes))?; + self.observed_bytes = next_bytes; + if self.max_bytes.is_some_and(|max| next_bytes > max) { + return Err(response_limit_error(ResponseLimitReason::HeaderBytes)); + } + Ok(()) + } +} + +impl HttpClient { + #[inline] + pub fn new(client: Arc) -> Self { + Self { inner: client } + } + + /// Delegates one request to the adapter-owned outbound implementation. + /// + /// # Errors + /// Returns the adapter's typed outbound failure unchanged. + #[inline] + pub async fn send(&self, request: OutboundRequest) -> Result { + self.inner.send(request).await + } + + /// Collects a completion-driven batch into original request order. + /// + /// # Errors + /// Returns the precise adapter or core invariant failure together with every terminal slot + /// collected before the failure. + #[inline] + pub async fn send_all_until( + &self, + requests: Vec, + observation_cutoff: Deadline, + ) -> Result { + self.start_batch_until(requests, observation_cutoff) + .collect() + .await + } + + #[must_use] + #[inline] + pub fn start_batch_until( + &self, + requests: Vec, + observation_cutoff: Deadline, + ) -> OutboundBatch { + self.inner.start_batch_until(requests, observation_cutoff) + } + + #[inline] + pub fn with_client(client: Client) -> Self + where + Client: OutboundHttpClient + 'static, + { + Self::new(Arc::new(client)) + } +} + +impl OutboundResponse { + #[must_use] + #[inline] + pub fn body(&self) -> &Body { + &self.body + } + + /// Returns original-header evidence, never inferred from the filtered header map. + /// + /// Mutable header access invalidates an originally eligible result conservatively, even + /// when only an unrelated field changes. Original ineligible/unknown evidence cannot be + /// upgraded by application edits. MIME validation and redaction remain application-owned. + #[must_use] + #[inline] + pub fn content_type_provenance(&self) -> ContentTypeProvenance { + if self.headers_mutated + && self.content_type_provenance == ContentTypeProvenance::SingleTextUnchanged + { + ContentTypeProvenance::Ineligible(ContentTypeIneligibility::Modified) + } else { + self.content_type_provenance + } + } + + #[must_use] + #[inline] + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + #[must_use] + /// Returns adapter-facing mutable headers without normalizing them. + /// + /// Callers that mutate framing or hop-by-hop metadata must finish through + /// [`Self::into_response`], which reapplies the defensive normalization pass. + #[inline] + pub fn headers_mut(&mut self) -> &mut HeaderMap { + self.headers_mutated = true; + &mut self.headers + } + + #[must_use] + #[inline] + pub fn into_body(self) -> Body { + self.body + } + + /// Collects the body under a final response-buffer limit. + /// + /// # Errors + /// Returns a typed source error or `BufferedBody` response-limit error. + #[inline] + pub async fn into_bytes_bounded(self, max: u64) -> Result { + match self.body { + Body::Once(bytes) => validate_buffered_response_bytes(bytes, max), + Body::Stream(stream) => collect_response_stream(stream, max).await, + } + } + + /// Collects the body under a final response-buffer limit and cooperative deadline. + /// + /// # Errors + /// Returns 504 when the deadline is observed expired, otherwise a typed source or + /// `BufferedBody` response-limit error. + #[inline] + pub async fn into_bytes_bounded_until( + self, + max: u64, + deadline: Deadline, + ) -> Result { + let clock = self.monotonic_clock.clone(); + ensure_deadline_live(deadline, &clock)?; + match self.body { + Body::Once(bytes) => { + let outcome = validate_buffered_response_bytes(bytes, max); + ensure_deadline_live(deadline, &clock)?; + outcome + } + Body::Stream(stream) => { + collect_response_stream_until_with_clock(stream, max, deadline, &clock).await + } + } + } + + #[must_use] + /// Destructures the adapter-facing response representation without re-running response-header + /// normalization. Call [`Self::into_response`] at an application boundary that requires the + /// final defensive framing pass. + #[inline] + pub fn into_parts(self) -> (Method, StatusCode, HeaderMap, Body, ContentTypeProvenance) { + let provenance = self.content_type_provenance(); + ( + self.request_method, + self.status, + self.headers, + self.body, + provenance, + ) + } + + /// Converts the outbound value after one final defensive metadata pass. + /// + /// # Errors + /// Returns a typed protocol error for malformed framing, or an internal error if the + /// already-validated response cannot be assembled. + #[inline] + pub fn into_response(mut self) -> Result { + let provenance = self.content_type_provenance(); + let normalized = + normalize_response_headers(&self.request_method, self.status, &mut self.headers)?; + if normalized.body_disposition != ResponseBodyDisposition::Payload { + self.body = Body::empty(); + } + let effective_provenance = if provenance == ContentTypeProvenance::SingleTextUnchanged { + normalized.content_type_provenance + } else { + provenance + }; + let mut builder = response_builder() + .status(self.status) + .extension(effective_provenance); + for (name, value) in &self.headers { + builder = builder.header(name, value); + } + builder.body(self.body).map_err(EdgeError::internal) + } + + #[must_use] + #[inline] + pub fn is_success(&self) -> bool { + self.status.is_success() + } + + /// Deserializes an already-buffered upstream body as JSON. + /// + /// # Errors + /// Returns protocol 502 for a streamed body and decode 502 for malformed JSON. + #[inline] + pub fn json(&self) -> Result + where + Value: DeserializeOwned, + { + let Body::Once(bytes) = &self.body else { + return Err(EdgeError::bad_gateway_with_reason( + "response body not buffered; use json_bounded(max) or json_bounded_until(max, deadline)", + BadGatewayReason::Protocol, + )); + }; + decode_json(bytes) + } + + /// Collects and deserializes an upstream body as JSON. + /// + /// # Errors + /// Returns typed source/limit failures or decode 502 for malformed JSON. + #[inline] + pub async fn json_bounded(self, max: u64) -> Result + where + Value: DeserializeOwned, + { + let bytes = self.into_bytes_bounded(max).await?; + decode_json(&bytes) + } + + /// Collects and deserializes an upstream body under a cooperative deadline. + /// + /// # Errors + /// Returns 504 on observed expiry, typed source/limit failures, or decode 502 for malformed + /// JSON. + #[inline] + pub async fn json_bounded_until( + self, + max: u64, + deadline: Deadline, + ) -> Result + where + Value: DeserializeOwned, + { + let clock = self.monotonic_clock.clone(); + let bytes = self.into_bytes_bounded_until(max, deadline).await?; + ensure_deadline_live(deadline, &clock)?; + decode_json(&bytes) + } + + #[must_use] + /// Constructs an adapter response paired with its application clock. + /// + /// `headers` must already have passed [`normalize_response_headers`], including bodyless + /// disposition handling, before the response is exposed through app-facing accessors. + /// `content_type_provenance` must come from that pre-filter pass, not a second inspection of + /// normalized headers. Use `Unknown` if original field boundaries or bytes are unavailable. + /// The clock must be the same source used for the request and outbound client. This keeps + /// deferred response-body deadlines in the application's monotonic clock domain. + #[inline] + pub fn new( + request_method: Method, + status: StatusCode, + headers: HeaderMap, + body: Body, + monotonic_clock: MonotonicClock, + content_type_provenance: ContentTypeProvenance, + ) -> Self { + Self { + body, + content_type_provenance, + headers, + headers_mutated: false, + monotonic_clock, + request_method, + status, + } + } + + #[must_use] + #[inline] + pub fn status(&self) -> StatusCode { + self.status + } +} + +impl OutboundRequest { + #[must_use] + #[inline] + pub fn backend_target(&self) -> String { + let host = bracket_ipv6(self.host_name()); + format!("{host}:{}", self.resolved_port()) + } + + #[must_use] + #[inline] + pub fn body(mut self, body: BodyValue) -> Self + where + BodyValue: Into, + { + self.body = body.into(); + self + } + + pub(crate) fn budget_inputs(&self) -> BudgetInputs { + BudgetInputs { + deadline: self.deadline, + timeout: self.timeout, + } + } + + #[must_use] + #[inline] + pub fn cache_policy(mut self, policy: OutboundCachePolicy) -> Self { + self.cache_policy = policy; + self + } + + #[must_use] + #[inline] + pub fn cert_host(&self) -> Option<&str> { + (self.uri.scheme_str() == Some("https")).then(|| self.host_name()) + } + + #[must_use] + #[inline] + pub fn deadline(mut self, deadline: Deadline) -> Self { + self.deadline = Some(deadline); + self + } + + /// Reassembles a request and revalidates its target. + /// + /// # Errors + /// Returns [`EdgeError::BadRequest`] when `parts.uri` is not a canonicalizable HTTP(S) + /// target. + #[inline] + pub fn from_parts(parts: OutboundRequestParts) -> Result { + let canonical_uri = canonicalize_typed_uri(&parts.uri)?; + Ok(Self { + body: parts.body, + cache_policy: parts.cache_policy, + deadline: parts.deadline, + headers: parts.headers, + host_authority_override: parts + .host_authority_override + .map(validate_host_authority_override) + .transpose()?, + max_decoder_bytes: parts.max_decoder_bytes, + max_brotli_window_bits: parts.max_brotli_window_bits, + max_chunk_bytes: parts.max_chunk_bytes, + max_decoded_response_bytes: parts.max_decoded_response_bytes, + max_encoded_response_bytes: parts.max_encoded_response_bytes, + max_request_body_bytes: parts.max_request_body_bytes, + max_response_header_bytes: parts.max_response_header_bytes, + max_response_header_count: parts.max_response_header_count, + method: parts.method, + response_mode: parts.response_mode, + timeout: parts.timeout, + uri: canonical_uri, + }) + } + + fn from_raw(method: Method, raw: &str) -> Result { + reject_ambiguous_raw_target(raw)?; + let uri = canonicalize_url(raw)?; + Ok(Self::with_canonical_uri(method, uri)) + } + + /// Creates an outbound request from an inbound request while removing framing and + /// hop-by-hop metadata. + /// + /// # Errors + /// Returns [`EdgeError::BadRequest`] when the target is invalid or a `connection` + /// nomination is malformed. + #[inline] + pub fn from_request(request: Request, target: Uri) -> Result { + let (parts, body) = request.into_parts(); + let mut outbound = Self::new(parts.method, target)?; + outbound.body = body; + outbound.headers = parts.headers; + normalize_request_headers(&mut outbound.headers)?; + Ok(outbound) + } + + /// Creates a canonical GET request from a raw URL. + /// + /// # Errors + /// Returns [`EdgeError::BadRequest`] when the URL is not an absolute HTTP(S) target. + #[inline] + pub fn get(uri: Target) -> Result + where + Target: AsRef, + { + Self::from_raw(Method::GET, uri.as_ref()) + } + + #[must_use] + #[inline] + pub fn has_host_authority_override(&self) -> bool { + self.host_authority_override.is_some() + } + + /// Appends one validated header value. + /// + /// # Errors + /// Returns [`EdgeError::BadRequest`] when the name is invalid, the value is not UTF-8, + /// or the value contains bytes forbidden by HTTP header syntax. + #[inline] + pub fn header(mut self, name: N, value: V) -> Result + where + N: AsRef<[u8]>, + V: AsRef<[u8]>, + { + let header_name = HeaderName::from_bytes(name.as_ref()) + .map_err(|_error| EdgeError::bad_request("invalid outbound header name"))?; + if str::from_utf8(value.as_ref()).is_err() { + return Err(EdgeError::bad_request(format!( + "header value is not valid UTF-8: {header_name}" + ))); + } + let header_value = HeaderValue::from_bytes(value.as_ref()).map_err(|_error| { + EdgeError::bad_request(format!( + "header value contains forbidden bytes: {header_name}" + )) + })?; + self.headers.append(header_name, header_value); + Ok(self) + } + + #[must_use] + #[inline] + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + #[must_use] + #[inline] + pub fn headers_mut(&mut self) -> &mut HeaderMap { + &mut self.headers + } + + #[must_use] + #[inline] + pub fn host_authority(&self) -> &str { + self.host_authority_override.as_ref().map_or_else( + || self.uri.authority().map_or("", Authority::as_str), + Authority::as_str, + ) + } + + /// Overrides only the outgoing HTTP authority while preserving the URI connection identity. + /// + /// # Errors + /// Returns [`EdgeError::BadRequest`] unless `authority` is a host or host-and-port value with + /// bracketed IPv6 and no user information, scheme, path, query, fragment, or controls. + #[inline] + pub fn host_authority_override(mut self, authority: &str) -> Result { + let parsed = authority + .parse::() + .map_err(|_error| invalid_host_authority_override())?; + self.host_authority_override = Some(validate_host_authority_override(parsed)?); + Ok(self) + } + + #[must_use] + #[inline] + pub fn host_name(&self) -> &str { + let host = self.uri.host().unwrap_or_default(); + host.strip_prefix('[') + .and_then(|unbracketed| unbracketed.strip_suffix(']')) + .unwrap_or(host) + } + + #[must_use] + #[inline] + pub fn into_parts(self) -> OutboundRequestParts { + OutboundRequestParts { + body: self.body, + cache_policy: self.cache_policy, + deadline: self.deadline, + headers: self.headers, + host_authority_override: self.host_authority_override, + max_decoder_bytes: self.max_decoder_bytes, + max_brotli_window_bits: self.max_brotli_window_bits, + max_chunk_bytes: self.max_chunk_bytes, + max_decoded_response_bytes: self.max_decoded_response_bytes, + max_encoded_response_bytes: self.max_encoded_response_bytes, + max_request_body_bytes: self.max_request_body_bytes, + max_response_header_bytes: self.max_response_header_bytes, + max_response_header_count: self.max_response_header_count, + method: self.method, + response_mode: self.response_mode, + timeout: self.timeout, + uri: self.uri, + } + } + + #[must_use] + #[inline] + pub fn is_stream_body(&self) -> bool { + self.body.is_stream() + } + + #[must_use] + #[inline] + pub fn is_stream_response(&self) -> bool { + self.response_mode == ResponseMode::Streamed + } + + /// Serializes a request body as JSON. + /// + /// # Errors + /// Returns [`EdgeError::Internal`] when serialization fails. + #[inline] + pub fn json(mut self, value: &T) -> Result { + self.body = Body::json(value).map_err(EdgeError::internal)?; + if !self.headers.contains_key(CONTENT_TYPE) { + self.headers + .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + } + Ok(self) + } + + #[must_use] + #[inline] + pub fn max_brotli_window_bits(mut self, bits: u8) -> Self { + self.max_brotli_window_bits = bits; + self + } + + #[must_use] + #[inline] + pub fn max_chunk_bytes(mut self, bytes: NonZeroU64) -> Self { + self.max_chunk_bytes = Some(bytes); + self + } + + #[must_use] + #[inline] + pub fn max_decoded_response_bytes(mut self, bytes: u64) -> Self { + self.max_decoded_response_bytes = Some(bytes); + self + } + + #[must_use] + #[inline] + pub fn max_decoder_bytes(mut self, bytes: u64) -> Self { + self.max_decoder_bytes = bytes; + self + } + + #[must_use] + #[inline] + pub fn max_encoded_response_bytes(mut self, bytes: u64) -> Self { + self.max_encoded_response_bytes = Some(bytes); + self + } + + #[must_use] + #[inline] + pub fn max_request_body_bytes(mut self, bytes: u64) -> Self { + self.max_request_body_bytes = bytes; + self + } + + #[must_use] + /// Selects buffered response mode and sets its final collection limit. + /// + /// This is a last-write-wins mode setter: a later [`Self::stream_response`] call selects + /// streamed mode instead. + #[inline] + pub fn max_response_bytes(mut self, bytes: u64) -> Self { + self.response_mode = ResponseMode::Buffered { max_bytes: bytes }; + self + } + + #[must_use] + #[inline] + pub fn max_response_header_bytes(mut self, bytes: u64) -> Self { + self.max_response_header_bytes = Some(bytes); + self + } + + #[must_use] + #[inline] + pub fn max_response_header_count(mut self, count: u64) -> Self { + self.max_response_header_count = Some(count); + self + } + + #[must_use] + #[inline] + pub fn method(&self) -> &Method { + &self.method + } + + /// Creates an outbound request from a typed URI. + /// + /// # Errors + /// Returns [`EdgeError::BadRequest`] when the URI is not an absolute HTTP(S) target. + #[expect( + clippy::needless_pass_by_value, + reason = "the public request constructor takes ownership consistently with http::Request" + )] + #[inline] + pub fn new(method: Method, uri: Uri) -> Result { + let canonical_uri = canonicalize_typed_uri(&uri)?; + Ok(Self::with_canonical_uri(method, canonical_uri)) + } + + /// Creates a canonical POST request from a raw URL. + /// + /// # Errors + /// Returns [`EdgeError::BadRequest`] when the URL is not an absolute HTTP(S) target. + #[inline] + pub fn post(uri: Target) -> Result + where + Target: AsRef, + { + Self::from_raw(Method::POST, uri.as_ref()) + } + + fn resolved_port(&self) -> u16 { + self.uri.port_u16().unwrap_or_else(|| { + if self.uri.scheme_str() == Some("https") { + 443 + } else { + 80 + } + }) + } + + #[must_use] + #[inline] + pub fn sni_hostname(&self) -> Option<&str> { + let host = self.host_name(); + (self.uri.scheme_str() == Some("https") && host.parse::().is_err()).then_some(host) + } + + #[must_use] + /// Selects streamed response mode. + /// + /// This is a last-write-wins mode setter: a later [`Self::max_response_bytes`] call selects + /// buffered mode instead. + #[inline] + pub fn stream_response(mut self) -> Self { + self.response_mode = ResponseMode::Streamed; + self + } + + #[must_use] + #[inline] + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + #[must_use] + #[inline] + pub fn uri(&self) -> &Uri { + &self.uri + } + + fn with_canonical_uri(method: Method, uri: Uri) -> Self { + Self { + body: Body::empty(), + cache_policy: OutboundCachePolicy::PlatformDefault, + deadline: None, + headers: HeaderMap::new(), + host_authority_override: None, + max_decoder_bytes: DEFAULT_MAX_DECODER_BYTES, + max_brotli_window_bits: 24, + max_chunk_bytes: None, + max_decoded_response_bytes: None, + max_encoded_response_bytes: None, + max_request_body_bytes: DEFAULT_OUTBOUND_REQUEST_BODY_BYTES, + max_response_header_bytes: None, + max_response_header_count: None, + method, + response_mode: ResponseMode::Buffered { + max_bytes: DEFAULT_MAX_RESPONSE_BYTES, + }, + timeout: None, + uri, + } + } +} + +/// Applies the shared payload policy without polling the native body. +/// +/// Declared lengths are checked first, then streaming encoded limits, recognized decoding, +/// decoded limits, and rechunking. Raw passthrough keeps its encoding/length headers and bypasses +/// only the decoded limit. Adapters must handle bodyless responses before calling this helper. +/// +/// # Errors +/// Returns malformed content-length or declared payload-limit errors before rewriting headers. +#[inline] +pub fn apply_response_body_policy( + native: BodyStream, + headers: &mut HeaderMap, + policy: ResponseBodyPolicy, +) -> Result { + let encoding = classify_content_encoding(headers); + let max_buffered = match policy.response_mode { + ResponseMode::Buffered { max_bytes } => Some(max_bytes), + ResponseMode::Streamed => None, + }; + enforce_payload_content_length( + headers, + &encoding, + max_buffered, + policy.max_decoded_response_bytes, + policy.max_encoded_response_bytes, + )?; + let encoded = limit_encoded_stream(native, policy.max_encoded_response_bytes); + let decoded = match &encoding { + ContentEncoding::Brotli => decode_brotli_stream( + encoded, + policy.max_brotli_window_bits, + policy.max_decoder_bytes, + ), + ContentEncoding::Deflate => decode_deflate_stream(encoded, policy.max_decoder_bytes), + ContentEncoding::Gzip => decode_gzip_stream(encoded, policy.max_decoder_bytes), + ContentEncoding::Identity | ContentEncoding::Passthrough(_) => encoded, + }; + if matches!( + &encoding, + ContentEncoding::Brotli | ContentEncoding::Deflate | ContentEncoding::Gzip + ) { + headers.remove(CONTENT_ENCODING); + headers.remove(CONTENT_LENGTH); + } + let output = match &encoding { + ContentEncoding::Brotli + | ContentEncoding::Deflate + | ContentEncoding::Gzip + | ContentEncoding::Identity => { + limit_decoded_stream(decoded, policy.max_decoded_response_bytes) + } + ContentEncoding::Passthrough(_) => decoded, + }; + Ok(rechunk_stream(output, policy.max_chunk_bytes)) +} + +/// Converts one adapter-observed terminal batch outcome into its indexed public result. +/// +/// Adapters must pass samples from the batch's shared monotonic clock. `None` means the terminal +/// observation was at or past the method-level observation cutoff. Timeout provenance alone never +/// terminates the batch. A backward terminal sample remains a terminal item with zero elapsed time +/// and an internal invariant error so it cannot enlarge or bypass the batch budget. +#[must_use] +#[inline] +pub fn finish_batch_item( + index: usize, + started_at: MonotonicInstant, + completed_at: MonotonicInstant, + observation_cutoff: Deadline, + outcome: Result, +) -> Option { + if observation_cutoff.is_expired_at(completed_at) { + return None; + } + let result = match completed_at.checked_duration_since(started_at) { + Some(elapsed) => OutboundSlotResult::new(elapsed, outcome), + None => OutboundSlotResult::new( + Duration::ZERO, + Err(EdgeError::internal(anyhow::anyhow!( + "monotonic clock moved backwards during outbound dispatch" + ))), + ), + }; + Some(OutboundBatchItem::new(index, result)) +} + +#[inline] +fn batch_driver_error(message: &'static str) -> EdgeError { + EdgeError::internal(anyhow::anyhow!(message)) +} + +fn invalid_host_authority_override() -> EdgeError { + EdgeError::bad_request("invalid outbound host authority override") +} + +fn validate_host_authority_override(authority: Authority) -> Result { + let raw = authority.as_str(); + let host = authority.host(); + let invalid_port = match raw.strip_prefix(host) { + Some("") => false, + Some(suffix) if suffix.starts_with(':') => authority.port_u16().is_none(), + _ => true, + }; + let unbracketed_ipv6 = raw.bytes().filter(|byte| *byte == b':').count() > 1 + && !(raw.starts_with('[') && raw.contains(']')); + if raw.is_empty() + || host.is_empty() + || raw.contains('@') + || invalid_port + || unbracketed_ipv6 + || HeaderValue::from_str(raw).is_err() + { + return Err(invalid_host_authority_override()); + } + Ok(authority) +} + +/// Collects a response stream under the final buffered-body cap. +/// +/// # Errors +/// Returns a typed source error unchanged or a `BufferedBody` response-limit error. +#[inline] +pub async fn collect_response_stream(stream: BodyStream, max: u64) -> Result { + collect_response_stream_inner(stream, max, None).await +} + +/// Applies sound pre-poll checks to a normalized payload `content-length`. +/// +/// # Errors +/// Returns a protocol 502 for malformed length metadata or a typed response-limit 502 when +/// the declared wire/final size exceeds a comparable configured cap. +#[inline] +pub fn enforce_payload_content_length( + headers: &HeaderMap, + encoding: &ContentEncoding, + max_buffered_bytes: Option, + max_decoded_bytes: Option, + max_encoded_bytes: Option, +) -> Result<(), EdgeError> { + let Some(length) = parse_content_length(headers)? else { + return Ok(()); + }; + if max_encoded_bytes.is_some_and(|max| length > max) { + return Err(response_limit_error(ResponseLimitReason::EncodedBody)); + } + match encoding { + ContentEncoding::Identity => { + if max_decoded_bytes.is_some_and(|max| length > max) { + return Err(response_limit_error(ResponseLimitReason::DecodedBody)); + } + if max_buffered_bytes.is_some_and(|max| length > max) { + return Err(response_limit_error(ResponseLimitReason::BufferedBody)); + } + } + ContentEncoding::Passthrough(_) => { + if max_buffered_bytes.is_some_and(|max| length > max) { + return Err(response_limit_error(ResponseLimitReason::BufferedBody)); + } + } + ContentEncoding::Brotli | ContentEncoding::Deflate | ContentEncoding::Gzip => {} + } + Ok(()) +} + +/// Accounts and inserts `EdgeZero`'s guest-visible adapter marker. +/// +/// # Errors +/// Returns a typed response-header limit error before insertion when the marker would exceed the +/// configured cumulative byte or field-count cap. +#[inline] +pub fn insert_proxy_header( + headers: &mut HeaderMap, + limiter: &mut ResponseHeaderLimiter, + value: HeaderValue, +) -> Result<(), EdgeError> { + let name = HeaderName::from_static(PROXY_HEADER); + limiter.observe_field(&name, &value)?; + headers.insert(name, value); + Ok(()) +} + +#[must_use] +#[inline] +pub fn limit_decoded_stream(stream: BodyStream, max: Option) -> BodyStream { + limit_response_stream(stream, max, ResponseLimitReason::DecodedBody) +} + +#[must_use] +#[inline] +pub fn limit_encoded_stream(stream: BodyStream, max: Option) -> BodyStream { + limit_response_stream(stream, max, ResponseLimitReason::EncodedBody) +} + +/// Reapplies request header normalization immediately before platform conversion. +/// +/// # Errors +/// Returns [`EdgeError::BadRequest`] when a `connection` nomination is malformed. +#[inline] +pub fn normalize_for_dispatch(request: &mut OutboundRequest) -> Result<(), EdgeError> { + normalize_request_headers(&mut request.headers) +} + +/// Normalizes raw upstream metadata before response-body policy is selected. +/// +/// # Errors +/// Returns a protocol-classified 502 when `connection` or payload framing is malformed. +#[inline] +pub fn normalize_response_headers( + request_method: &Method, + status: StatusCode, + headers: &mut HeaderMap, +) -> Result { + let original_provenance = original_content_type_provenance(headers); + let nominated = connection_nominations(headers, true)?; + retain_utf8_header_values(headers, true); + strip_hop_by_hop(headers, nominated, false); + + let content_type_provenance = if original_provenance + == ContentTypeProvenance::SingleTextUnchanged + && !headers.contains_key(CONTENT_TYPE) + { + ContentTypeProvenance::Ineligible(ContentTypeIneligibility::Removed) + } else { + original_provenance + }; + + let body_disposition = if status.is_informational() || status == StatusCode::NO_CONTENT { + headers.remove(CONTENT_LENGTH); + ResponseBodyDisposition::FramingBodyless + } else if request_method == Method::HEAD || status == StatusCode::NOT_MODIFIED { + parse_content_length(headers)?; + ResponseBodyDisposition::FramingBodyless + } else { + let content_length = parse_content_length(headers)?; + if status == StatusCode::RESET_CONTENT { + headers.insert(CONTENT_LENGTH, HeaderValue::from_static("0")); + ResponseBodyDisposition::ResetContent { + declared_body: content_length.is_some_and(|length| length > 0), + } + } else { + ResponseBodyDisposition::Payload + } + }; + Ok(ResponseHeadNormalization { + body_disposition, + content_type_provenance, + }) +} + +fn original_content_type_provenance(headers: &HeaderMap) -> ContentTypeProvenance { + let mut values = headers.get_all(CONTENT_TYPE).iter(); + let Some(value) = values.next() else { + return ContentTypeProvenance::Ineligible(ContentTypeIneligibility::Missing); + }; + if values.next().is_some() { + ContentTypeProvenance::Ineligible(ContentTypeIneligibility::MultipleValues) + } else if str::from_utf8(value.as_bytes()).is_err() { + ContentTypeProvenance::Ineligible(ContentTypeIneligibility::NonText) + } else { + ContentTypeProvenance::SingleTextUnchanged + } +} + +#[must_use] +#[inline] +pub fn rechunk_stream(mut source: BodyStream, optional_maximum: Option) -> BodyStream { + let Some(maximum) = optional_maximum else { + return source; + }; + let maximum_chunk = usize::try_from(maximum.get()).unwrap_or(usize::MAX); + stream! { + while let Some(item) = source.next().await { + match item { + Ok(mut bytes) => { + if bytes.is_empty() { + yield Ok(Bytes::new()); + continue; + } + while bytes.len() > maximum_chunk { + yield Ok(bytes.split_to(maximum_chunk)); + } + if !bytes.is_empty() { + yield Ok(bytes); + } + } + Err(error) => { + yield Err(error); + return; + } + } + } + } + .boxed_local() +} + +/// Validates the adapter-independent outbound method, body, and response-policy contract. +/// +/// # Errors +/// Returns [`EdgeError::BadRequest`] when a common contract invariant is invalid. Individual +/// adapters may reject additional target-specific constraints during request preparation. +#[inline] +pub fn validate_for_dispatch(request: &OutboundRequest) -> Result<(), EdgeError> { + if !matches!( + request.method, + Method::GET + | Method::HEAD + | Method::POST + | Method::PUT + | Method::PATCH + | Method::DELETE + | Method::OPTIONS + ) { + return Err(EdgeError::bad_request(format!( + "method {} is not portable; supported: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS", + request.method + ))); + } + + if matches!(request.method, Method::GET | Method::HEAD) { + match &request.body { + Body::Once(bytes) if bytes.is_empty() => {} + Body::Once(_) => { + return Err(EdgeError::bad_request( + "GET/HEAD request must not carry a body", + )); + } + Body::Stream(_) => { + return Err(EdgeError::bad_request( + "GET/HEAD request must not carry a streamed body; emptiness cannot be determined without consuming the stream", + )); + } + } + } + + if !(10..=30).contains(&request.max_brotli_window_bits) { + return Err(EdgeError::bad_request( + "max_brotli_window_bits must be between 10 and 30 inclusive", + )); + } + Ok(()) +} + +fn bracket_ipv6(host: &str) -> String { + if host.contains(':') { + format!("[{host}]") + } else { + host.to_owned() + } +} + +fn canonicalize_typed_uri(uri: &Uri) -> Result { + let raw = uri.to_string(); + reject_ambiguous_raw_target(&raw)?; + canonicalize_url(&raw) +} + +fn canonicalize_url(raw: &str) -> Result { + let mut parsed = Url::parse(raw) + .map_err(|_error| EdgeError::bad_request("outbound URI must be absolute with authority"))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(EdgeError::bad_request( + "outbound URI scheme must be http or https", + )); + } + if parsed.host_str().is_none() { + return Err(EdgeError::bad_request( + "outbound URI must be absolute with authority", + )); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(EdgeError::bad_request( + "outbound URI must not contain userinfo; pass credentials via the `authorization` header", + )); + } + if parsed.fragment().is_some() { + return Err(EdgeError::bad_request( + "outbound URI must not contain a fragment", + )); + } + let default_port = if parsed.scheme() == "http" { 80 } else { 443 }; + if parsed.port() == Some(default_port) { + parsed + .set_port(None) + .map_err(|()| EdgeError::bad_request("outbound URI contains an invalid port"))?; + } + parsed + .as_str() + .parse() + .map_err(|_error| EdgeError::bad_request("outbound URI cannot be represented as HTTP URI")) +} + +async fn collect_response_stream_inner( + mut source: BodyStream, + max: u64, + optional_deadline: Option<(Deadline, &MonotonicClock)>, +) -> Result { + let mut collected = Vec::new(); + let mut total = 0_u64; + loop { + if let Some((active_deadline, clock)) = optional_deadline { + ensure_deadline_live(active_deadline, clock)?; + } + let next_item = source.next().await; + if let Some((active_deadline, clock)) = optional_deadline { + ensure_deadline_live(active_deadline, clock)?; + } + let Some(item) = next_item else { + return Ok(Bytes::from(collected)); + }; + let bytes = item?; + let chunk_len = u64::try_from(bytes.len()) + .map_err(|_error| response_limit_error(ResponseLimitReason::BufferedBody))?; + let next_total = total + .checked_add(chunk_len) + .ok_or_else(|| response_limit_error(ResponseLimitReason::BufferedBody))?; + if next_total > max { + if let Some((active_deadline, clock)) = optional_deadline { + ensure_deadline_live(active_deadline, clock)?; + } + return Err(response_limit_error(ResponseLimitReason::BufferedBody)); + } + collected.extend_from_slice(&bytes); + total = next_total; + } +} + +/// Collects a response stream under an absolute deadline evaluated by `clock`. +/// +/// # Errors +/// Returns a typed buffered-body overflow or gateway timeout while preserving source errors. +#[doc(hidden)] +#[inline] +pub async fn collect_response_stream_until_with_clock( + stream: BodyStream, + max: u64, + deadline: Deadline, + clock: &MonotonicClock, +) -> Result { + collect_response_stream_inner(stream, max, Some((deadline, clock))).await +} + +fn decode_json(bytes: &[u8]) -> Result +where + Value: DeserializeOwned, +{ + serde_json::from_slice(bytes).map_err(|_error| { + EdgeError::bad_gateway_with_reason( + "failed to decode upstream JSON", + BadGatewayReason::Decode(BadGatewayDecodeReason::Json), + ) + }) +} + +fn ensure_deadline_live(deadline: Deadline, clock: &MonotonicClock) -> Result<(), EdgeError> { + if deadline.is_expired_at(clock.now()) { + Err(EdgeError::gateway_timeout("response body deadline expired")) + } else { + Ok(()) + } +} + +fn normalize_request_headers(headers: &mut HeaderMap) -> Result<(), EdgeError> { + let nominated = connection_nominations(headers, false)?; + retain_utf8_header_values(headers, false); + strip_hop_by_hop(headers, nominated, true); + Ok(()) +} + +fn connection_nominations( + headers: &HeaderMap, + response: bool, +) -> Result, EdgeError> { + let mut nominated = Vec::new(); + for header_value in headers.get_all(CONNECTION) { + let raw_value = str::from_utf8(header_value.as_bytes()).map_err(|_error| { + malformed_connection(response, "connection header value is not valid UTF-8") + })?; + for raw_token in raw_value.split(',') { + let token = raw_token.trim_matches([' ', '\t']); + if token.is_empty() { + return Err(malformed_connection( + response, + "connection header contains an empty nomination", + )); + } + let name = HeaderName::from_bytes(token.as_bytes()).map_err(|_error| { + malformed_connection(response, "connection header contains an invalid nomination") + })?; + nominated.push(name); + } + } + Ok(nominated) +} + +fn malformed_connection(response: bool, message: &'static str) -> EdgeError { + if response { + EdgeError::bad_gateway_with_reason(message, BadGatewayReason::Protocol) + } else { + EdgeError::bad_request(message) + } +} + +fn limit_response_stream( + mut source: BodyStream, + optional_maximum: Option, + reason: ResponseLimitReason, +) -> BodyStream { + let Some(maximum) = optional_maximum else { + return source; + }; + stream! { + let mut total = 0_u64; + while let Some(item) = source.next().await { + match item { + Ok(bytes) => { + let Ok(chunk_len) = u64::try_from(bytes.len()) else { + let error = response_limit_error(reason); + drop(source); + yield Err(error); + return; + }; + let Some(next_total) = total.checked_add(chunk_len) else { + let error = response_limit_error(reason); + drop(source); + yield Err(error); + return; + }; + if next_total > maximum { + let error = response_limit_error(reason); + drop(source); + yield Err(error); + return; + } + total = next_total; + yield Ok(bytes); + } + Err(error) => { + drop(source); + yield Err(error); + return; + } + } + } + } + .boxed_local() +} + +fn parse_content_length(headers: &HeaderMap) -> Result, EdgeError> { + let mut parsed = None; + for value in headers.get_all(CONTENT_LENGTH) { + let raw = str::from_utf8(value.as_bytes()).map_err(|_error| protocol_content_length())?; + if raw.contains(',') { + return Err(protocol_content_length()); + } + let trimmed = raw.trim_matches([' ', '\t']); + if trimmed.is_empty() || !trimmed.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(protocol_content_length()); + } + let length = trimmed + .parse::() + .map_err(|_error| protocol_content_length())?; + if parsed.is_some_and(|previous| previous != length) { + return Err(protocol_content_length()); + } + parsed = Some(length); + } + Ok(parsed) +} + +fn protocol_content_length() -> EdgeError { + EdgeError::bad_gateway_with_reason( + "upstream response has malformed or conflicting content-length", + BadGatewayReason::Protocol, + ) +} + +fn response_limit_error(reason: ResponseLimitReason) -> EdgeError { + EdgeError::response_too_large_with_reason("upstream response exceeded configured limit", reason) +} + +fn retain_utf8_header_values(headers: &mut HeaderMap, response: bool) { + let mut retained = HeaderMap::with_capacity(headers.len()); + for (name, value) in headers.iter() { + if name != CONNECTION + && !(response && (name == CONTENT_ENCODING || name == CONTENT_LENGTH)) + && str::from_utf8(value.as_bytes()).is_err() + { + if response { + log::warn!("dropping non-UTF-8 outbound response header: {name}"); + } else { + log::warn!("dropping non-UTF-8 outbound request header: {name}"); + } + continue; + } + retained.append(name.clone(), value.clone()); + } + *headers = retained; +} + +fn strip_hop_by_hop( + headers: &mut HeaderMap, + nominated: Vec, + strip_request_framing: bool, +) { + for name in nominated { + headers.remove(name); + } + for name in [ + CONNECTION, + HeaderName::from_static("keep-alive"), + HeaderName::from_static("proxy-connection"), + PROXY_AUTHENTICATE, + PROXY_AUTHORIZATION, + TE, + TRAILER, + TRANSFER_ENCODING, + UPGRADE, + ] { + headers.remove(name); + } + if strip_request_framing { + headers.remove(HOST); + headers.remove(CONTENT_LENGTH); + } +} + +fn validate_buffered_response_bytes(bytes: Bytes, max: u64) -> Result { + let length = u64::try_from(bytes.len()) + .map_err(|_error| response_limit_error(ResponseLimitReason::BufferedBody))?; + if length > max { + Err(response_limit_error(ResponseLimitReason::BufferedBody)) + } else { + Ok(bytes) + } +} + +fn reject_ambiguous_raw_target(raw: &str) -> Result<(), EdgeError> { + if raw.as_bytes().contains(&b'#') { + return Err(EdgeError::bad_request( + "outbound URI must not contain a fragment", + )); + } + if raw.as_bytes().contains(&b'\\') { + return Err(EdgeError::bad_request( + "outbound URI must not contain a backslash", + )); + } + let Some((_scheme, authority_tail)) = raw.split_once("://") else { + return Err(EdgeError::bad_request( + "outbound URI must be absolute with authority", + )); + }; + let authority = authority_tail + .split(['/', '?', '#']) + .next() + .unwrap_or_default(); + if authority.is_empty() { + return Err(EdgeError::bad_request( + "outbound URI must be absolute with authority", + )); + } + if authority.as_bytes().contains(&b'@') { + return Err(EdgeError::bad_request( + "outbound URI must not contain userinfo; pass credentials via the `authorization` header", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::shadow_reuse, + clippy::shadow_unrelated, + reason = "table-driven tests intentionally reuse conventional request, response, and budget names" + )] + + use std::cell::Cell; + use std::future::Future as _; + use std::io::Write as _; + use std::num::NonZeroU64; + use std::rc::Rc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use std::task::{Context, Poll}; + use std::time::Duration; + + use async_trait::async_trait; + use bytes::Bytes; + use flate2::write::{GzEncoder, ZlibEncoder}; + use futures::FutureExt as _; + use futures::channel::oneshot; + use futures::executor::block_on; + use futures::future::{Ready, pending, ready}; + use futures::pin_mut; + use futures::task::noop_waker_ref; + use futures_util::{StreamExt as _, stream}; + + use crate::body::Body; + use crate::compression::{ContentEncoding, PassthroughReason, classify_content_encoding}; + use crate::error::{ + BadGatewayDecodeReason, BadGatewayReason, BudgetSource, EdgeError, ResponseLimitReason, + }; + use crate::http::{ + Authority, HeaderMap, HeaderValue, Method, StatusCode, Uri, request_builder, + }; + use crate::time::{ + DEADLINE_FAR_FUTURE, DEFAULT_NO_DEADLINE_BUDGET, Deadline, MonotonicClock, + MonotonicInstant, dispatch_budget, + }; + + use super::{ + HttpClient, OutboundBatch, OutboundBatchDriverEvent, OutboundBatchItem, OutboundBatchNext, + OutboundBatchTermination, OutboundCachePolicy, OutboundHttpClient, OutboundRequest, + OutboundResponse, OutboundSlotResult, PROXY_HEADER, ResponseBodyDisposition, + ResponseBodyPolicy, ResponseHeaderLimiter, ResponseMode, apply_response_body_policy, + collect_response_stream, enforce_payload_content_length, finish_batch_item, + insert_proxy_header, limit_decoded_stream, limit_encoded_stream, normalize_for_dispatch, + normalize_response_headers, rechunk_stream, validate_for_dispatch, + }; + + struct MockClient { + batch_calls: AtomicUsize, + send_calls: AtomicUsize, + } + + struct BodyDropSignal(Rc>); + + impl Drop for BodyDropSignal { + fn drop(&mut self) { + self.0.set(self.0.get().saturating_add(1)); + } + } + + #[async_trait(?Send)] + impl OutboundHttpClient for MockClient { + async fn send(&self, request: OutboundRequest) -> Result { + self.send_calls.fetch_add(1, Ordering::Relaxed); + Ok(OutboundResponse::new( + request.method().clone(), + StatusCode::CREATED, + HeaderMap::new(), + Body::from("single"), + MonotonicClock::default(), + super::ContentTypeProvenance::Unknown, + )) + } + + fn start_batch_until( + &self, + requests: Vec, + _observation_cutoff: Deadline, + ) -> OutboundBatch { + self.batch_calls.fetch_add(1, Ordering::Relaxed); + let slot_count = requests.len(); + let mut items = requests + .into_iter() + .enumerate() + .map(|(index, request)| OutboundBatchItem { + index, + result: OutboundSlotResult { + elapsed: Duration::from_millis( + u64::try_from(index) + .expect("index") + .checked_add(1) + .expect("elapsed"), + ), + outcome: if request.method() == Method::DELETE { + Err(EdgeError::bad_gateway("mock failure")) + } else { + Ok(OutboundResponse::new( + request.method().clone(), + StatusCode::OK, + HeaderMap::new(), + Body::empty(), + MonotonicClock::default(), + super::ContentTypeProvenance::Unknown, + )) + }, + }, + }) + .collect::>(); + items.reverse(); + OutboundBatch::from_driver( + slot_count, + stream::iter(items.into_iter().map(OutboundBatchDriverEvent::Item)), + ) + } + } + + fn batch_item(index: usize) -> OutboundBatchItem { + OutboundBatchItem::new( + index, + OutboundSlotResult::new( + Duration::from_millis(u64::try_from(index).expect("index")), + Ok(response_with_body(Body::empty())), + ), + ) + } + + #[test] + fn future_batch_retains_ready_timestamps_before_delivering_preflight_failures() { + let start = MonotonicInstant::now(); + let observation_cutoff = Deadline::at_instant(start + Duration::from_secs(1)); + let current = Rc::new(Cell::new(start + Duration::from_millis(5))); + let sampled = Rc::clone(¤t); + let pending = async move { (1, sampled.get(), Err(EdgeError::bad_request("ready slot"))) }; + let mut batch = OutboundBatch::from_futures( + 2, + start, + observation_cutoff, + vec![( + 0, + start + Duration::from_millis(2), + Err(EdgeError::bad_request("preflight")), + )], + [pending], + ); + let OutboundBatchNext::Item(first) = block_on(batch.next()).expect("preflight") else { + panic!("expected preflight item"); + }; + assert_eq!(first.index, 0); + assert_eq!(first.result.elapsed, Duration::from_millis(2)); + current.set(start + Duration::from_secs(2)); + let OutboundBatchNext::Item(second) = block_on(batch.next()).expect("retained result") + else { + panic!("expected retained result"); + }; + assert_eq!(second.index, 1); + assert_eq!(second.result.elapsed, Duration::from_millis(5)); + assert!(matches!( + block_on(batch.next()).expect("EOF"), + OutboundBatchNext::Finished(OutboundBatchTermination::Completed) + )); + } + + #[test] + fn future_batch_cutoff_depends_on_observation_not_timeout_attribution() { + let start = MonotonicInstant::now(); + let observation_cutoff = Deadline::at_instant(start + Duration::from_secs(1)); + let early = ready(( + 1, + start + Duration::from_millis(5), + Err(EdgeError::gateway_timeout_caused( + "phase timeout", + BudgetSource::BatchCutoff, + )), + )); + let at_cutoff = ready(( + 0, + observation_cutoff.instant(), + Err(EdgeError::bad_request("late")), + )); + let batch = OutboundBatch::from_futures( + 2, + start, + observation_cutoff, + Vec::new(), + [early, at_cutoff], + ); + let result = block_on(batch.collect()).expect("observation_cutoff"); + assert_eq!(result.termination, OutboundBatchTermination::Cutoff); + assert!(matches!(result.slots.first(), Some(None))); + let early_result = result + .slots + .get(1) + .and_then(Option::as_ref) + .expect("early slot retained"); + assert!(matches!( + early_result.outcome, + Err(EdgeError::GatewayTimeout { + cause: BudgetSource::BatchCutoff, + .. + }) + )); + } + + #[test] + fn future_batch_orders_controlled_completions_and_drops_cancelled_work() { + let start = MonotonicInstant::now(); + let observation_cutoff = Deadline::at_instant(start + Duration::from_secs(1)); + let drops = Rc::new(Cell::new(0)); + let signal = BodyDropSignal(Rc::clone(&drops)); + let (send_first, receive_first) = oneshot::channel::<()>(); + let (send_second, receive_second) = oneshot::channel::<()>(); + let first = async move { + let _signal = signal; + receive_first.await.expect("first completion"); + ( + 0, + start + Duration::from_millis(8), + Err(EdgeError::bad_request("first")), + ) + } + .boxed_local(); + let second = async move { + receive_second.await.expect("second completion"); + ( + 1, + start + Duration::from_millis(3), + Err(EdgeError::bad_request("second")), + ) + } + .boxed_local(); + let mut batch = + OutboundBatch::from_futures(2, start, observation_cutoff, Vec::new(), [first, second]); + send_second.send(()).expect("second ready"); + let OutboundBatchNext::Item(item) = block_on(batch.next()).expect("second completes first") + else { + panic!("expected completion"); + }; + assert_eq!(item.index, 1); + assert_eq!(batch.cancel(), vec![0]); + assert_eq!(drops.get(), 1); + assert!(send_first.send(()).is_err()); + } + + #[test] + fn future_batch_validates_empty_and_premature_drivers() { + let start = MonotonicInstant::now(); + let observation_cutoff = Deadline::at_instant(start + Duration::from_secs(1)); + let empty = + Vec::)>>::new(); + let completed = block_on( + OutboundBatch::from_futures(0, start, observation_cutoff, Vec::new(), empty).collect(), + ) + .expect("empty completion"); + assert_eq!(completed.termination, OutboundBatchTermination::Completed); + let empty = + Vec::)>>::new(); + let failure = block_on( + OutboundBatch::from_futures( + 2, + start, + observation_cutoff, + vec![(1, start, Err(EdgeError::bad_request("preflight")))], + empty, + ) + .collect(), + ) + .expect_err("missing future"); + assert!(matches!(failure.error, EdgeError::Internal { .. })); + assert!(failure.slots.get(1).is_some_and(Option::is_some)); + } + + #[test] + fn future_batch_cutoff_releases_pending_work_before_reporting_finished() { + let start = MonotonicInstant::now(); + let observation_cutoff = Deadline::at_instant(start + Duration::from_secs(1)); + let drops = Rc::new(Cell::new(0)); + let signal = BodyDropSignal(Rc::clone(&drops)); + let pending = async move { + let _signal = signal; + pending::<()>().await; + ( + 1, + start, + Err(EdgeError::bad_request("unreachable completion")), + ) + }; + let mut batch = OutboundBatch::from_futures( + 2, + start, + observation_cutoff, + vec![( + 0, + observation_cutoff.instant(), + Err(EdgeError::bad_request("late completion")), + )], + [pending], + ); + assert!(matches!( + block_on(batch.next()).expect("cutoff"), + OutboundBatchNext::Finished(OutboundBatchTermination::Cutoff) + )); + assert_eq!( + drops.get(), + 1, + "finished cutoff must release pending work even while batch remains owned" + ); + assert_eq!(batch.cancel(), vec![0, 1]); + assert_eq!(drops.get(), 1); + } + + fn body_policy() -> ResponseBodyPolicy { + ResponseBodyPolicy { + max_brotli_window_bits: 24, + max_chunk_bytes: None, + max_decoded_response_bytes: Some(32), + max_decoder_bytes: super::DEFAULT_MAX_DECODER_BYTES, + max_encoded_response_bytes: Some(256), + response_mode: ResponseMode::Buffered { max_bytes: 32 }, + } + } + + #[test] + fn body_policy_rejects_declared_limits_without_polling_or_rewriting_headers() { + let polls = Rc::new(Cell::new(0_usize)); + let counted = Rc::clone(&polls); + let source = stream::poll_fn(move |_context| { + counted.set(counted.get() + 1); + Poll::Ready(None) + }) + .boxed_local(); + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static("gzip")); + headers.insert("content-length", HeaderValue::from_static("257")); + let error = apply_response_body_policy(source, &mut headers, body_policy()) + .err() + .expect("encoded preflight"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::EncodedBody, + .. + } + )); + assert_eq!(polls.get(), 0); + assert!(headers.contains_key("content-encoding")); + assert!(headers.contains_key("content-length")); + } + + #[test] + fn body_policy_decodes_recognized_codings_before_rechunking() { + let plain = b"decoded body"; + let mut gzip = GzEncoder::new(Vec::new(), flate2::Compression::default()); + gzip.write_all(plain).expect("gzip"); + let mut deflate = ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + deflate.write_all(plain).expect("deflate"); + let mut brotli = Vec::new(); + let mut compressor = brotli::CompressorWriter::new(&mut brotli, 4096, 5, 21); + compressor.write_all(plain).expect("brotli"); + drop(compressor); + for (coding, encoded) in [ + ("gzip", gzip.finish().expect("gzip finish")), + ("deflate", deflate.finish().expect("deflate finish")), + ("br", brotli), + ] { + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static(coding)); + headers.insert( + "content-length", + HeaderValue::from_str(&encoded.len().to_string()).expect("length"), + ); + let source = stream::iter([Ok(Bytes::from(encoded))]).boxed_local(); + let mut policy = body_policy(); + policy.max_chunk_bytes = NonZeroU64::new(2); + let output = apply_response_body_policy(source, &mut headers, policy).expect("policy"); + assert!(!headers.contains_key("content-encoding")); + assert!(!headers.contains_key("content-length")); + let chunks = block_on(output.collect::>()); + let mut collected = Vec::new(); + for chunk in chunks { + let bytes = chunk.expect("decoded chunk"); + assert!(bytes.len() <= 2); + collected.extend_from_slice(&bytes); + } + assert_eq!(collected, plain); + } + } + + #[test] + fn body_policy_passthrough_preserves_headers_and_bypasses_decoded_limit_only() { + for coding in ["unknown", "gzip, br", "gzip;"] { + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static(coding)); + headers.insert("content-length", HeaderValue::from_static("4")); + let source = stream::iter([Ok(Bytes::from_static(b"abcd"))]).boxed_local(); + let mut policy = body_policy(); + policy.max_decoded_response_bytes = Some(1); + let output = apply_response_body_policy(source, &mut headers, policy) + .expect("passthrough policy"); + assert_eq!( + headers.get("content-encoding"), + Some(&HeaderValue::from_static(coding)) + ); + assert!(headers.contains_key("content-length")); + assert_eq!( + block_on(collect_response_stream(output, 4)).expect("passthrough"), + Bytes::from_static(b"abcd") + ); + } + let mut headers = HeaderMap::new(); + let mut policy = body_policy(); + policy.max_decoded_response_bytes = Some(1); + policy.max_chunk_bytes = NonZeroU64::new(1); + let source = stream::iter([Ok(Bytes::from_static(b"ab"))]).boxed_local(); + let mut output = + apply_response_body_policy(source, &mut headers, policy).expect("identity"); + assert!(matches!( + block_on(output.next()), + Some(Err(EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::DecodedBody, + .. + })) + )); + } + + #[test] + fn body_policy_encoded_limit_precedes_decoder_and_preserves_source_errors() { + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static("gzip")); + let mut policy = body_policy(); + policy.max_encoded_response_bytes = Some(1); + let source = stream::iter([Ok(Bytes::from_static(b"invalid gzip"))]).boxed_local(); + let output = apply_response_body_policy(source, &mut headers, policy).expect("policy"); + assert!(matches!( + block_on(collect_response_stream(output, 32)), + Err(EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::EncodedBody, + .. + }) + )); + + for coding in ["identity", "gzip", "deflate", "br", "unknown"] { + let mut headers = HeaderMap::new(); + headers.insert("content-encoding", HeaderValue::from_static(coding)); + let source = stream::iter([Err(EdgeError::gateway_timeout_caused( + "source timeout", + BudgetSource::RequestDeadline, + ))]) + .boxed_local(); + let output = + apply_response_body_policy(source, &mut headers, body_policy()).expect("policy"); + assert!(matches!( + block_on(collect_response_stream(output, 32)), + Err(EdgeError::GatewayTimeout { + cause: BudgetSource::RequestDeadline, + .. + }) + )); + } + } + + #[test] + fn finish_batch_item_preserves_an_on_time_terminal_outcome() { + let started_at = MonotonicInstant::now(); + let completed_at = started_at + .checked_add(Duration::from_millis(7)) + .expect("completion instant"); + let observation_cutoff = Deadline::at_instant( + started_at + .checked_add(Duration::from_millis(10)) + .expect("cutoff instant"), + ); + + let item = finish_batch_item( + 3, + started_at, + completed_at, + observation_cutoff, + Ok(response_with_body(Body::empty())), + ) + .expect("on-time outcome"); + + assert_eq!(item.index, 3); + assert_eq!(item.result.elapsed, Duration::from_millis(7)); + let _response = item.result.outcome.expect("successful on-time outcome"); + } + + #[test] + fn finish_batch_item_rejects_an_outcome_observed_at_observation_cutoff() { + let started_at = MonotonicInstant::now(); + let observation_cutoff_at = started_at + .checked_add(Duration::from_millis(10)) + .expect("cutoff instant"); + + let item = finish_batch_item( + 0, + started_at, + observation_cutoff_at, + Deadline::at_instant(observation_cutoff_at), + Err(EdgeError::bad_gateway("late")), + ); + + assert!(item.is_none()); + } + + #[test] + fn finish_batch_item_preserves_an_early_observation_cutoff_attributed_timeout() { + let started_at = MonotonicInstant::now(); + let completed_at = started_at + .checked_add(Duration::from_millis(1)) + .expect("completion instant"); + let observation_cutoff = Deadline::at_instant( + started_at + .checked_add(Duration::from_millis(10)) + .expect("cutoff instant"), + ); + + let item = finish_batch_item( + 0, + started_at, + completed_at, + observation_cutoff, + Err(EdgeError::gateway_timeout_caused( + "batch cutoff", + BudgetSource::BatchCutoff, + )), + ) + .expect("early phase timeout is a terminal slot result"); + + assert_eq!(item.result.elapsed, Duration::from_millis(1)); + assert!(matches!( + item.result.outcome, + Err(EdgeError::GatewayTimeout { + cause: BudgetSource::BatchCutoff, + .. + }) + )); + } + + #[test] + fn finish_batch_item_maps_a_backward_terminal_sample_to_internal() { + let started_at = MonotonicInstant::now(); + let completed_at = started_at + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let observation_cutoff = Deadline::at_instant( + started_at + .checked_add(Duration::from_millis(10)) + .expect("cutoff instant"), + ); + + let item = finish_batch_item( + 4, + started_at, + completed_at, + observation_cutoff, + Ok(response_with_body(Body::empty())), + ) + .expect("clock fault remains terminal"); + + assert_eq!(item.index, 4); + assert_eq!(item.result.elapsed, Duration::ZERO); + let error = item.result.outcome.expect_err("clock fault outcome"); + assert_eq!( + error.to_string(), + "internal error: monotonic clock moved backwards during outbound dispatch" + ); + } + + #[expect( + clippy::wildcard_enum_match_arm, + reason = "EdgeError is non-exhaustive and this assertion helper must reject future variants" + )] + fn bad_request_message(error: EdgeError) -> String { + match error { + EdgeError::BadRequest { message } => message, + other => panic!("expected bad request, got {other:?}"), + } + } + + fn response_with_body(body: Body) -> OutboundResponse { + OutboundResponse::new( + Method::GET, + StatusCode::OK, + HeaderMap::new(), + body, + MonotonicClock::default(), + super::ContentTypeProvenance::Unknown, + ) + } + + #[test] + fn buffered_collection_limit_is_independent() { + let source = Body::from_stream(stream::iter([ + Ok(Bytes::from_static(b"ab")), + Ok(Bytes::from_static(b"cd")), + ])) + .into_stream() + .expect("stream"); + let bytes = block_on(collect_response_stream(source, 4)).expect("exact cap"); + assert_eq!(bytes, Bytes::from_static(b"abcd")); + + let error = + block_on(response_with_body(Body::from("raw-passthrough")).into_bytes_bounded(3)) + .expect_err("final cap"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::BufferedBody, + .. + } + )); + } + + #[test] + fn dispatch_budget_rejects_expired_or_zero() { + let now = MonotonicInstant::now(); + let expired = OutboundRequest::get("https://example.com") + .expect("request") + .deadline(Deadline::at_instant(now)); + let error = dispatch_budget(&expired, now, None).expect_err("expired"); + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::RequestDeadline, + .. + } + )); + + let tied = OutboundRequest::get("https://example.com") + .expect("request") + .timeout(Duration::ZERO) + .deadline(Deadline::at_instant(now)); + let error = dispatch_budget(&tied, now, None).expect_err("zero tie"); + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::PerCallTimeout, + .. + } + )); + } + + #[test] + fn dispatch_budget_selects_and_attributes_minimum() { + let now = MonotonicInstant::now(); + + let default = OutboundRequest::get("https://example.com").expect("request"); + let budget = dispatch_budget(&default, now, None).expect("default budget"); + assert_eq!(budget.cause, BudgetSource::Default); + assert_eq!(budget.duration, DEFAULT_NO_DEADLINE_BUDGET); + assert_eq!( + budget.deadline.instant(), + now.checked_add(DEFAULT_NO_DEADLINE_BUDGET) + .expect("no overflow") + ); + + let timeout = OutboundRequest::get("https://example.com") + .expect("request") + .timeout(Duration::from_secs(3)); + let budget = dispatch_budget(&timeout, now, None).expect("timeout budget"); + assert_eq!(budget.cause, BudgetSource::PerCallTimeout); + assert_eq!(budget.duration, Duration::from_secs(3)); + + let deadline = Deadline::at_instant( + now.checked_add(Duration::from_secs(4)) + .expect("no overflow"), + ); + let request = OutboundRequest::get("https://example.com") + .expect("request") + .deadline(deadline); + let budget = dispatch_budget(&request, now, None).expect("deadline budget"); + assert_eq!(budget.cause, BudgetSource::RequestDeadline); + assert_eq!(budget.duration, Duration::from_secs(4)); + assert_eq!(budget.deadline.instant(), deadline.instant()); + + let timeout_wins = OutboundRequest::get("https://example.com") + .expect("request") + .timeout(Duration::from_secs(2)) + .deadline(deadline); + let budget = dispatch_budget(&timeout_wins, now, None).expect("timeout wins"); + assert_eq!(budget.cause, BudgetSource::PerCallTimeout); + assert_eq!(budget.duration, Duration::from_secs(2)); + + let deadline_wins = OutboundRequest::get("https://example.com") + .expect("request") + .timeout(Duration::from_secs(5)) + .deadline(deadline); + let budget = dispatch_budget(&deadline_wins, now, None).expect("deadline wins"); + assert_eq!(budget.cause, BudgetSource::RequestDeadline); + assert_eq!(budget.duration, Duration::from_secs(4)); + + let tied_deadline = Deadline::at_instant( + now.checked_add(Duration::from_secs(5)) + .expect("no overflow"), + ); + let tied = OutboundRequest::get("https://example.com") + .expect("request") + .timeout(Duration::from_secs(5)) + .deadline(tied_deadline); + let budget = dispatch_budget(&tied, now, None).expect("equal budget"); + assert_eq!(budget.cause, BudgetSource::PerCallTimeout); + + let huge_timeout = OutboundRequest::get("https://example.com") + .expect("request") + .timeout(Duration::MAX); + let budget = dispatch_budget(&huge_timeout, now, None).expect("clamped timeout"); + assert_eq!(budget.duration, DEADLINE_FAR_FUTURE); + + let far_deadline = Deadline::at_instant( + now.checked_add(Duration::from_hours(8_760)) + .expect("no overflow"), + ); + let far = OutboundRequest::get("https://example.com") + .expect("request") + .deadline(far_deadline); + let budget = dispatch_budget(&far, now, None).expect("clamped deadline"); + assert_eq!(budget.duration, DEADLINE_FAR_FUTURE); + assert_eq!(budget.cause, BudgetSource::RequestDeadline); + + let observation_cutoff = Deadline::at_instant( + now.checked_add(Duration::from_secs(1)) + .expect("cutoff instant"), + ); + let budget = + dispatch_budget(&default, now, Some(observation_cutoff)).expect("batch cutoff"); + assert_eq!(budget.cause, BudgetSource::BatchCutoff); + assert_eq!(budget.deadline.instant(), observation_cutoff.instant()); + + let observation_cutoff_tie = OutboundRequest::get("https://example.com") + .expect("request") + .timeout(Duration::from_secs(1)) + .deadline(observation_cutoff); + let budget = dispatch_budget(&observation_cutoff_tie, now, Some(observation_cutoff)) + .expect("cutoff tie"); + assert_eq!(budget.cause, BudgetSource::BatchCutoff); + + let error = dispatch_budget(&default, now, Some(Deadline::at_instant(now))) + .expect_err("cutoff equality is expired"); + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::BatchCutoff, + .. + } + )); + } + + #[test] + fn encoded_limit_stops_before_decoder() { + let polls = Rc::new(Cell::new(0_usize)); + let observed = Rc::clone(&polls); + let source = Body::from_stream( + stream::iter([ + Ok(Bytes::from_static(b"four")), + Ok(Bytes::from_static(b"later")), + ]) + .inspect(move |_| observed.set(observed.get().saturating_add(1))), + ) + .into_stream() + .expect("stream"); + let mut limited = limit_encoded_stream(source, Some(3)); + let error = block_on(limited.next()) + .expect("limit result") + .expect_err("over limit"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::EncodedBody, + .. + } + )); + assert_eq!(polls.get(), 1); + assert!(block_on(limited.next()).is_none()); + } + + #[test] + fn terminal_stream_error_releases_source_before_yield() { + struct DropSignal(Rc>); + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.set(self.0.get().saturating_add(1)); + } + } + + let limit_drops = Rc::new(Cell::new(0_usize)); + let limit_signal = DropSignal(Rc::clone(&limit_drops)); + let limit_source = stream::poll_fn(move |_context| { + let _keep_signal_alive = &limit_signal; + Poll::Ready(Some(Ok(Bytes::from_static(b"over")))) + }) + .boxed_local(); + let mut limited = limit_encoded_stream(limit_source, Some(3)); + block_on(limited.next()) + .expect("limit result") + .expect_err("over limit"); + assert_eq!(limit_drops.get(), 1_usize); + + let source_drops = Rc::new(Cell::new(0_usize)); + let source_signal = DropSignal(Rc::clone(&source_drops)); + let source_error = stream::poll_fn(move |_context| { + let _keep_signal_alive = &source_signal; + Poll::Ready(Some(Err(EdgeError::bad_gateway("source failed")))) + }) + .boxed_local(); + let mut limited = limit_decoded_stream(source_error, Some(3)); + block_on(limited.next()) + .expect("source result") + .expect_err("source error"); + assert_eq!(source_drops.get(), 1_usize); + } + + #[test] + fn http_client_delegates_send_and_empty_batch_collection() { + let client = HttpClient::with_client(MockClient { + batch_calls: AtomicUsize::new(0), + send_calls: AtomicUsize::new(0), + }); + + let response = block_on( + client.send( + OutboundRequest::post("https://example.com") + .expect("request") + .body("request"), + ), + ) + .expect("response"); + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(response.body().as_bytes(), Some(b"single".as_slice())); + + let empty = + block_on(client.send_all_until(Vec::new(), Deadline::after(Duration::from_secs(1)))) + .expect("valid empty batch"); + assert!(empty.slots.is_empty()); + assert_eq!(empty.termination, OutboundBatchTermination::Completed); + } + + #[test] + fn batch_next_yields_completion_order_and_collection_restores_input_order() { + let client = HttpClient::with_client(MockClient { + batch_calls: AtomicUsize::new(0), + send_calls: AtomicUsize::new(0), + }); + let requests = vec![ + OutboundRequest::get("https://example.com/one").expect("one"), + OutboundRequest::new(Method::DELETE, Uri::from_static("https://example.com/two")) + .expect("two"), + OutboundRequest::post("https://example.com/three").expect("three"), + ]; + + let observation_cutoff = Deadline::after(Duration::from_secs(1)); + let mut batch = client.start_batch_until(requests, observation_cutoff); + let OutboundBatchNext::Item(first) = block_on(batch.next()).expect("valid batch driver") + else { + panic!("expected first completion"); + }; + assert_eq!(first.index, 2); + assert_eq!(first.result.elapsed, Duration::from_millis(3)); + let OutboundBatchNext::Item(second) = block_on(batch.next()).expect("valid batch driver") + else { + panic!("expected second completion"); + }; + assert_eq!(second.index, 1); + assert!(matches!( + second.result.outcome, + Err(EdgeError::BadGateway { .. }) + )); + let OutboundBatchNext::Item(third) = block_on(batch.next()).expect("valid batch driver") + else { + panic!("expected third completion"); + }; + assert_eq!(third.index, 0); + assert!(matches!( + block_on(batch.next()).expect("valid batch driver"), + OutboundBatchNext::Finished(OutboundBatchTermination::Completed) + )); + + let requests = vec![ + OutboundRequest::get("https://example.com/one").expect("one"), + OutboundRequest::new(Method::DELETE, Uri::from_static("https://example.com/two")) + .expect("two"), + OutboundRequest::post("https://example.com/three").expect("three"), + ]; + let results = block_on(client.send_all_until(requests, observation_cutoff)) + .expect("valid batch driver"); + assert_eq!(results.termination, OutboundBatchTermination::Completed); + assert_eq!(results.slots.len(), 3); + assert_eq!( + results.slots[0].as_ref().expect("first slot").elapsed, + Duration::from_millis(1) + ); + assert_eq!( + results.slots[1].as_ref().expect("second slot").elapsed, + Duration::from_millis(2) + ); + assert_eq!( + results.slots[2].as_ref().expect("third slot").elapsed, + Duration::from_millis(3) + ); + results.slots[0] + .as_ref() + .expect("first slot") + .outcome + .as_ref() + .expect("first slot success"); + assert!(matches!( + results.slots[1].as_ref().expect("second slot").outcome, + Err(EdgeError::BadGateway { .. }) + )); + results.slots[2] + .as_ref() + .expect("third slot") + .outcome + .as_ref() + .expect("third slot success"); + } + + #[test] + fn batch_next_is_cancellation_safe_and_cancel_reports_unresolved_indices() { + fn poll_next_once(batch: &mut OutboundBatch) -> Poll> { + let next = batch.next(); + pin_mut!(next); + let waker = noop_waker_ref(); + let mut context = Context::from_waker(waker); + next.as_mut().poll(&mut context) + } + + let poll_count = Rc::new(Cell::new(0_u8)); + let observed_poll_count = Rc::clone(&poll_count); + let source = stream::poll_fn(move |_context| { + let current = observed_poll_count.get(); + observed_poll_count.set(current.saturating_add(1)); + if current == 1 { + Poll::Ready(Some(OutboundBatchDriverEvent::Item(OutboundBatchItem { + index: 1, + result: OutboundSlotResult::new( + Duration::from_millis(2), + Err(EdgeError::bad_gateway("second")), + ), + }))) + } else { + Poll::Pending + } + }); + let mut batch = OutboundBatch::from_driver(3, source); + + assert!(poll_next_once(&mut batch).is_pending()); + + let OutboundBatchNext::Item(item) = + block_on(batch.next()).expect("item survives cancelled next future") + else { + panic!("expected terminal item"); + }; + assert_eq!(item.index, 1); + assert_eq!(batch.cancel(), vec![0, 2]); + } + + #[test] + fn batch_collection_reports_cutoff_and_preserves_unresolved_slots() { + let batch = OutboundBatch::from_driver( + 3, + stream::iter([ + OutboundBatchDriverEvent::Item(batch_item(2)), + OutboundBatchDriverEvent::Cutoff, + ]), + ); + + let results = block_on(batch.collect()).expect("valid cutoff"); + assert_eq!(results.termination, OutboundBatchTermination::Cutoff); + assert!(matches!(results.slots.first(), Some(None))); + assert!(results.slots[1].is_none()); + assert!(results.slots[2].is_some()); + } + + #[test] + fn batch_cutoff_constructor_reports_every_slot_unresolved() { + let results = block_on(OutboundBatch::cutoff(3).collect()).expect("valid cutoff"); + + assert_eq!(results.termination, OutboundBatchTermination::Cutoff); + assert_eq!(results.slots.len(), 3); + assert!(results.slots.iter().all(Option::is_none)); + } + + #[test] + fn batch_collection_rejects_premature_driver_end() { + let batch = OutboundBatch::from_driver( + 3, + stream::iter([OutboundBatchDriverEvent::Item(batch_item(2))]), + ); + + let failure = block_on(batch.collect()).expect_err("premature EOF must fail"); + assert_eq!( + failure.error.to_string(), + "internal error: outbound batch driver ended before every slot resolved" + ); + assert!(matches!(failure.slots.first(), Some(None))); + assert!(matches!(failure.slots.get(1), Some(None))); + assert!(matches!(failure.slots.get(2), Some(Some(_)))); + } + + #[test] + fn batch_collection_preserves_driver_failure_and_completed_slots() { + let batch = OutboundBatch::from_driver( + 3, + stream::iter([ + OutboundBatchDriverEvent::Item(batch_item(1)), + OutboundBatchDriverEvent::Failed(EdgeError::internal(anyhow::anyhow!( + "Fastly omitted a pending handle from selection" + ))), + ]), + ); + + let failure = block_on(batch.collect()).expect_err("driver failure must surface"); + assert_eq!( + failure.error.to_string(), + "internal error: Fastly omitted a pending handle from selection" + ); + assert!(matches!(failure.slots.first(), Some(None))); + assert!(matches!(failure.slots.get(1), Some(Some(_)))); + assert!(matches!(failure.slots.get(2), Some(None))); + } + + #[test] + fn batch_collection_validates_events_after_the_final_slot() { + for (event, diagnostic) in [ + ( + OutboundBatchDriverEvent::Failed(EdgeError::internal(anyhow::anyhow!( + "driver finalization failed" + ))), + "internal error: driver finalization failed", + ), + ( + OutboundBatchDriverEvent::Item(batch_item(0)), + "internal error: outbound batch driver emitted a duplicate slot index", + ), + ( + OutboundBatchDriverEvent::Item(batch_item(1)), + "internal error: outbound batch driver emitted an out-of-range slot index", + ), + ( + OutboundBatchDriverEvent::Cutoff, + "internal error: outbound batch driver emitted cutoff after every slot resolved", + ), + ] { + let batch = OutboundBatch::from_driver( + 1, + stream::iter([OutboundBatchDriverEvent::Item(batch_item(0)), event]), + ); + + let failure = block_on(batch.collect()).expect_err("trailing event must be checked"); + assert_eq!(failure.error.to_string(), diagnostic); + assert_eq!(failure.slots.len(), 1); + assert!(matches!(failure.slots.first(), Some(Some(_)))); + } + } + + #[test] + fn batch_zero_slot_driver_is_validated_before_completion() { + let completed = block_on(OutboundBatch::from_driver(0, stream::empty()).collect()) + .expect("empty driver completes"); + assert_eq!(completed.termination, OutboundBatchTermination::Completed); + let completed = block_on(OutboundBatch::cutoff(0).collect()).expect("no work to cut off"); + assert_eq!(completed.termination, OutboundBatchTermination::Completed); + + for (event, diagnostic) in [ + ( + OutboundBatchDriverEvent::Failed(EdgeError::internal(anyhow::anyhow!( + "empty driver failed" + ))), + "internal error: empty driver failed", + ), + ( + OutboundBatchDriverEvent::Item(batch_item(0)), + "internal error: outbound batch driver emitted an out-of-range slot index", + ), + ( + OutboundBatchDriverEvent::Cutoff, + "internal error: outbound batch driver emitted cutoff after every slot resolved", + ), + ] { + let failure = block_on(OutboundBatch::from_driver(0, stream::iter([event])).collect()) + .expect_err("zero slots do not hide malformed driver events"); + assert_eq!(failure.error.to_string(), diagnostic); + assert!(failure.slots.is_empty()); + } + } + + #[test] + fn batch_finalization_remains_pending_and_cancellation_safe_until_eof() { + let polls = Rc::new(Cell::new(0_usize)); + let source_polls = Rc::clone(&polls); + let source = stream::poll_fn(move |_context| { + let index = source_polls.get(); + source_polls.set(index + 1); + match index { + 0 => Poll::Ready(Some(OutboundBatchDriverEvent::Item(batch_item(0)))), + 1 => Poll::Pending, + _ => Poll::Ready(None), + } + }); + let mut batch = OutboundBatch::from_driver(1, source); + assert!(matches!( + block_on(batch.next()).expect("terminal item"), + OutboundBatchNext::Item(_) + )); + assert!(batch.next().now_or_never().is_none()); + assert!(matches!( + block_on(batch.next()).expect("validated EOF"), + OutboundBatchNext::Finished(OutboundBatchTermination::Completed) + )); + assert_eq!(polls.get(), 3); + assert!(matches!( + block_on(batch.next()).expect("cached termination"), + OutboundBatchNext::Finished(OutboundBatchTermination::Completed) + )); + assert_eq!(polls.get(), 3); + assert!(batch.cancel().is_empty()); + } + + #[test] + fn batch_cancel_releases_pending_finalization_after_every_slot_resolves() { + struct DropProbe(Rc>); + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.set(self.0.get() + 1); + } + } + let drops = Rc::new(Cell::new(0)); + let drop_probe = DropProbe(Rc::clone(&drops)); + let mut emitted = false; + let source = stream::poll_fn(move |_context| { + let _keep_driver_alive = &drop_probe; + if emitted { + Poll::Pending + } else { + emitted = true; + Poll::Ready(Some(OutboundBatchDriverEvent::Item(batch_item(0)))) + } + }); + let mut batch = OutboundBatch::from_driver(1, source); + assert!(matches!( + block_on(batch.next()).expect("slot"), + OutboundBatchNext::Item(_) + )); + assert!(batch.next().now_or_never().is_none()); + assert_eq!(drops.get(), 0); + assert!(batch.cancel().is_empty()); + assert_eq!(drops.get(), 1); + } + + #[test] + fn batch_collection_rejects_duplicate_driver_indices() { + let batch = OutboundBatch::from_driver( + 2, + stream::iter([ + OutboundBatchDriverEvent::Item(batch_item(0)), + OutboundBatchDriverEvent::Item(batch_item(0)), + ]), + ); + + let failure = block_on(batch.collect()).expect_err("duplicate index must fail"); + assert_eq!( + failure.error.to_string(), + "internal error: outbound batch driver emitted a duplicate slot index" + ); + assert!(matches!(failure.slots.first(), Some(Some(_)))); + assert!(matches!(failure.slots.get(1), Some(None))); + } + + #[test] + fn batch_collection_rejects_out_of_range_driver_indices() { + let batch = OutboundBatch::from_driver( + 2, + stream::iter([OutboundBatchDriverEvent::Item(batch_item(2))]), + ); + + let failure = block_on(batch.collect()).expect_err("out-of-range index must fail"); + assert_eq!( + failure.error.to_string(), + "internal error: outbound batch driver emitted an out-of-range slot index" + ); + assert!(failure.slots.iter().all(Option::is_none)); + } + + #[test] + fn batch_poisoned_repoll_reports_the_guard_diagnostic() { + let mut batch = OutboundBatch::from_driver( + 1, + stream::iter([OutboundBatchDriverEvent::Item(batch_item(1))]), + ); + + let first = block_on(batch.next()).expect_err("out-of-range index must poison driver"); + assert_eq!( + first.to_string(), + "internal error: outbound batch driver emitted an out-of-range slot index" + ); + let second = block_on(batch.next()).expect_err("poisoned driver must reject re-poll"); + assert_eq!( + second.to_string(), + "internal error: outbound batch driver was polled after an invariant failure" + ); + } + + #[test] + fn outbound_response_parts_preserve_method_headers_and_body() { + let mut headers = HeaderMap::new(); + headers.append("set-cookie", HeaderValue::from_static("a=1")); + headers.append("set-cookie", HeaderValue::from_static("b=2")); + let mut response = OutboundResponse::new( + Method::HEAD, + StatusCode::CREATED, + headers, + Body::from("payload"), + MonotonicClock::default(), + super::ContentTypeProvenance::Unknown, + ); + assert_eq!(response.status(), StatusCode::CREATED); + assert!(response.is_success()); + assert_eq!(response.body().as_bytes(), Some(b"payload".as_slice())); + assert_eq!(response.headers().get_all("set-cookie").iter().count(), 2); + response + .headers_mut() + .append("x-adapter", HeaderValue::from_static("yes")); + + let (method, status, headers, body, provenance) = response.into_parts(); + assert_eq!(provenance, super::ContentTypeProvenance::Unknown); + assert_eq!(method, Method::HEAD); + assert_eq!(status, StatusCode::CREATED); + assert_eq!( + headers.get("x-adapter"), + Some(&HeaderValue::from_static("yes")) + ); + assert_eq!(body.as_bytes(), Some(b"payload".as_slice())); + + let body = OutboundResponse::new( + Method::GET, + StatusCode::OK, + HeaderMap::new(), + Body::from("owned"), + MonotonicClock::default(), + super::ContentTypeProvenance::Unknown, + ) + .into_body(); + assert_eq!(body.as_bytes(), Some(b"owned".as_slice())); + } + + #[test] + fn payload_content_length_explicit_identity_rejects_before_body_poll() { + let mut headers = HeaderMap::new(); + headers.append("content-encoding", HeaderValue::from_static("identity")); + headers.append("content-length", HeaderValue::from_static("11")); + let error = enforce_payload_content_length( + &headers, + &ContentEncoding::Identity, + Some(20), + Some(10), + Some(20), + ) + .expect_err("decoded cap"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::DecodedBody, + .. + } + )); + } + + #[test] + fn payload_content_length_passthrough_uses_buffered_not_decoded_cap() { + let mut headers = HeaderMap::new(); + headers.append("content-length", HeaderValue::from_static("15")); + enforce_payload_content_length( + &headers, + &ContentEncoding::Passthrough(PassthroughReason::Unsupported(String::from("zstd"))), + Some(20), + Some(10), + Some(20), + ) + .expect("decoded cap bypassed"); + + let error = enforce_payload_content_length( + &headers, + &ContentEncoding::Passthrough(PassthroughReason::Unsupported(String::from("zstd"))), + Some(14), + Some(100), + Some(20), + ) + .expect_err("buffered cap"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::BufferedBody, + .. + } + )); + } + + #[test] + fn payload_content_length_rejects_before_body_poll() { + let mut headers = HeaderMap::new(); + headers.append("content-length", HeaderValue::from_static("21")); + let error = enforce_payload_content_length( + &headers, + &ContentEncoding::Identity, + Some(30), + Some(30), + Some(20), + ) + .expect_err("encoded cap"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::EncodedBody, + .. + } + )); + + for value in ["", "+1", "-1", "bad", "1, 1"] { + let mut headers = HeaderMap::new(); + headers.append( + "content-length", + HeaderValue::from_bytes(value.as_bytes()).expect("header"), + ); + enforce_payload_content_length(&headers, &ContentEncoding::Identity, None, None, None) + .expect_err("malformed content-length"); + } + + let mut headers = HeaderMap::new(); + headers.append( + "content-length", + HeaderValue::from_bytes(b"\xc2\xa05").expect("opaque whitespace"), + ); + enforce_payload_content_length(&headers, &ContentEncoding::Identity, None, None, None) + .expect_err("non-HTTP whitespace must be rejected"); + } + + #[test] + fn payload_content_length_skips_output_caps_for_compressed_body() { + let mut headers = HeaderMap::new(); + headers.append("content-length", HeaderValue::from_static("100")); + for encoding in [ContentEncoding::Brotli, ContentEncoding::Gzip] { + enforce_payload_content_length(&headers, &encoding, Some(1), Some(1), Some(100)) + .expect("wire length is not output length"); + } + } + + #[test] + fn rechunk_stream_is_lazy_and_ordered() { + let source_polls = Rc::new(Cell::new(0_u8)); + let observed_polls = Rc::clone(&source_polls); + let source = Body::from_stream(stream::poll_fn(move |_context| { + let poll = source_polls.get(); + source_polls.set(poll.saturating_add(1)); + Poll::Ready(match poll { + 0 => Some(Ok(Bytes::from_static(b"abcde"))), + 1 => Some(Ok(Bytes::new())), + 2 => Some(Err(EdgeError::bad_gateway("source"))), + _ => None, + }) + })) + .into_stream() + .expect("stream"); + let mut chunks = rechunk_stream(source, NonZeroU64::new(2)); + assert_eq!(observed_polls.get(), 0); + + assert_eq!( + block_on(chunks.next()).expect("first").expect("bytes"), + "ab" + ); + assert_eq!(observed_polls.get(), 1); + assert_eq!( + block_on(chunks.next()).expect("second").expect("bytes"), + "cd" + ); + assert_eq!(observed_polls.get(), 1); + assert_eq!(block_on(chunks.next()).expect("third").expect("bytes"), "e"); + assert_eq!(observed_polls.get(), 1); + assert_eq!( + block_on(chunks.next()).expect("empty").expect("bytes"), + Bytes::new() + ); + assert_eq!(observed_polls.get(), 2); + assert!(matches!( + block_on(chunks.next()).expect("error"), + Err(EdgeError::BadGateway { .. }) + )); + assert_eq!(observed_polls.get(), 3); + } + + #[test] + fn response_header_limiter_accumulates_field_sections() { + let mut limiter = ResponseHeaderLimiter::new(Some(12), Some(3)); + let mut first = HeaderMap::new(); + first.append("a", HeaderValue::from_static("123")); + limiter.observe(&first).expect("first section"); + + let mut second = HeaderMap::new(); + second.append("b", HeaderValue::from_static("456")); + limiter.observe(&second).expect("second section"); + + let mut third = HeaderMap::new(); + third.append("c", HeaderValue::from_static("789")); + limiter.observe(&third).expect("third section"); + + let mut fourth = HeaderMap::new(); + fourth.append("d", HeaderValue::from_static("0")); + let error = limiter.observe(&fourth).expect_err("cumulative count"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::HeaderCount, + .. + } + )); + + let mut byte_limiter = ResponseHeaderLimiter::new(Some(11), Some(4)); + byte_limiter.observe(&first).expect("first byte section"); + byte_limiter.observe(&second).expect("second byte section"); + let error = byte_limiter + .observe(&third) + .expect_err("cumulative byte limit"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::HeaderBytes, + .. + } + )); + } + + #[test] + fn proxy_header_is_included_in_exact_byte_and_count_limits() { + let cases = [ + ("axum", 20_u64), + ("cloudflare", 26_u64), + ("fastly", 22_u64), + ("spin", 20_u64), + ]; + + for (adapter, exact_bytes) in cases { + let value = HeaderValue::from_str(adapter).expect("adapter marker"); + let mut exact_headers = HeaderMap::new(); + let mut exact = ResponseHeaderLimiter::new(Some(exact_bytes), Some(1)); + insert_proxy_header(&mut exact_headers, &mut exact, value.clone()) + .expect("exact proxy header budget"); + assert_eq!(exact_headers.get(PROXY_HEADER), Some(&value)); + + let mut byte_headers = HeaderMap::new(); + let mut byte_limited = + ResponseHeaderLimiter::new(Some(exact_bytes.saturating_sub(1)), None); + let byte_error = + insert_proxy_header(&mut byte_headers, &mut byte_limited, value.clone()) + .expect_err("proxy header exceeds byte cap"); + assert!(matches!( + byte_error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::HeaderBytes, + .. + } + )); + + let mut count_headers = HeaderMap::new(); + let mut count_limited = ResponseHeaderLimiter::new(None, Some(0)); + let count_error = insert_proxy_header(&mut count_headers, &mut count_limited, value) + .expect_err("proxy header exceeds count cap"); + assert!(matches!( + count_error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::HeaderCount, + .. + } + )); + } + } + + #[test] + fn response_resource_header_limit_count_wins_tie() { + let mut limiter = ResponseHeaderLimiter::new(Some(0), Some(0)); + let mut headers = HeaderMap::new(); + headers.append("x", HeaderValue::from_static("y")); + let error = limiter.observe(&headers).expect_err("both limits"); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::HeaderCount, + .. + } + )); + } + + #[test] + fn decoded_limit_bypasses_raw_passthrough() { + let source = Body::from_stream(stream::iter([ + Ok(Bytes::from_static(b"ab")), + Ok(Bytes::from_static(b"cd")), + ])) + .into_stream() + .expect("stream"); + let error = block_on(async { + let mut limited = limit_decoded_stream(source, Some(3)); + assert_eq!(limited.next().await.expect("first").expect("bytes"), "ab"); + limited.next().await.expect("second").expect_err("over cap") + }); + assert!(matches!( + error, + EdgeError::ResponseTooLarge { + reason: ResponseLimitReason::DecodedBody, + .. + } + )); + + let raw = Body::from_stream(stream::iter([Ok(Bytes::from_static(b"raw-body"))])) + .into_stream() + .expect("raw passthrough"); + let bytes = block_on(async { raw.collect::>().await }); + assert_eq!(bytes[0].as_ref().expect("raw").as_ref(), b"raw-body"); + } + + #[test] + fn outbound_response_into_response_reapplies_normalization() { + let mut headers = HeaderMap::new(); + headers.append("connection", HeaderValue::from_static("x-private")); + headers.append("x-private", HeaderValue::from_static("secret")); + headers.append("set-cookie", HeaderValue::from_static("a=1")); + headers.append("set-cookie", HeaderValue::from_static("b=2")); + let body = Body::stream(stream::iter([Bytes::from_static(b"lazy")])); + let response = OutboundResponse::new( + Method::GET, + StatusCode::OK, + headers, + body, + MonotonicClock::default(), + super::ContentTypeProvenance::Unknown, + ) + .into_response() + .expect("response"); + + assert!(response.headers().get("connection").is_none()); + assert!(response.headers().get("x-private").is_none()); + assert_eq!(response.headers().get_all("set-cookie").iter().count(), 2); + assert!(response.into_body().is_stream()); + } + + #[test] + fn outbound_response_into_response_drops_bodyless_status_stream_without_polling() { + for (method, status, expected_content_length) in [ + (Method::GET, StatusCode::EARLY_HINTS, None), + (Method::GET, StatusCode::NO_CONTENT, None), + (Method::GET, StatusCode::RESET_CONTENT, Some("0")), + (Method::GET, StatusCode::NOT_MODIFIED, None), + (Method::HEAD, StatusCode::OK, None), + ] { + let polls = Rc::new(Cell::new(0_usize)); + let drops = Rc::new(Cell::new(0_usize)); + let observed_polls = Rc::clone(&polls); + let observed_drops = Rc::clone(&drops); + let drop_signal = BodyDropSignal(observed_drops); + let body = Body::from_stream(stream::poll_fn(move |_| { + let _keep_signal_alive = &drop_signal; + observed_polls.set(observed_polls.get().saturating_add(1)); + Poll::Ready(Some(Ok(Bytes::from_static(b"must-not-escape")))) + })); + + let response = OutboundResponse::new( + method, + status, + HeaderMap::new(), + body, + MonotonicClock::default(), + super::ContentTypeProvenance::Unknown, + ) + .into_response() + .expect("bodyless response"); + + assert_eq!(polls.get(), 0, "{status}"); + assert_eq!(drops.get(), 1, "{status}"); + assert_eq!(response.body().as_bytes(), Some([].as_slice()), "{status}"); + assert_eq!( + response + .headers() + .get("content-length") + .and_then(|value| value.to_str().ok()), + expected_content_length, + "{status}" + ); + } + } + + #[test] + fn outbound_response_json_error_classification() { + let response = response_with_body(Body::from(serde_json::json!({"ok": true}).to_string())); + let value: serde_json::Value = response.json().expect("json"); + assert_eq!(value["ok"], true); + + let malformed = response_with_body(Body::from("not-json")); + let error = malformed + .json::() + .expect_err("malformed JSON"); + assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Decode(BadGatewayDecodeReason::Json), + .. + } + )); + + let streamed = response_with_body(Body::stream(stream::iter([Bytes::from( + serde_json::json!({}).to_string(), + )]))) + .json::() + .expect_err("stream requires bounded helper"); + assert!(matches!( + streamed, + EdgeError::BadGateway { + reason: BadGatewayReason::Protocol, + .. + } + )); + + let streamed = response_with_body(Body::stream(stream::iter([Bytes::from( + serde_json::json!({}).to_string(), + )]))); + let value: serde_json::Value = block_on(streamed.json_bounded(2)).expect("bounded json"); + assert_eq!(value, serde_json::json!({})); + } + + #[test] + fn outbound_response_until_deadline_wins_ready_result() { + let deadline = Deadline::at_instant(MonotonicInstant::now()); + let response = response_with_body(Body::from_stream(stream::iter([Err( + EdgeError::bad_gateway("ready source error"), + )]))); + let error = block_on(response.into_bytes_bounded_until(100, deadline)) + .expect_err("expired deadline"); + assert!(matches!( + error, + EdgeError::GatewayTimeout { + cause: BudgetSource::Unspecified, + .. + } + )); + } + + #[test] + fn outbound_response_deadline_uses_its_injected_clock() { + let start = MonotonicInstant::now(); + let observed = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&observed); + let clock = MonotonicClock::new(move || *clock_now.lock().expect("clock lock")); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let response = OutboundResponse::new( + Method::GET, + StatusCode::OK, + HeaderMap::new(), + Body::from("ready"), + clock, + super::ContentTypeProvenance::Unknown, + ); + *observed.lock().expect("clock lock") = deadline; + + let error = + block_on(response.into_bytes_bounded_until(100, Deadline::at_instant(deadline))) + .expect_err("injected clock reached deadline"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[test] + fn response_content_type_provenance_precedes_filtering() { + use super::{ContentTypeIneligibility, ContentTypeProvenance}; + + let cases: &[(&[&[u8]], ContentTypeProvenance)] = &[ + ( + &[], + ContentTypeProvenance::Ineligible(ContentTypeIneligibility::Missing), + ), + ( + &[b"application/json"], + ContentTypeProvenance::SingleTextUnchanged, + ), + ( + &[b"caf\xc3\xa9"], + ContentTypeProvenance::SingleTextUnchanged, + ), + ( + &[b"not a MIME type"], + ContentTypeProvenance::SingleTextUnchanged, + ), + ( + &[b"application/json, text/plain"], + ContentTypeProvenance::SingleTextUnchanged, + ), + ( + &[b"application/json; note=\"x,y\""], + ContentTypeProvenance::SingleTextUnchanged, + ), + ( + &[b"application/json", b"application/json"], + ContentTypeProvenance::Ineligible(ContentTypeIneligibility::MultipleValues), + ), + ( + &[b"application/json", b"text/plain"], + ContentTypeProvenance::Ineligible(ContentTypeIneligibility::MultipleValues), + ), + ( + &[b"application/json", b"\xff"], + ContentTypeProvenance::Ineligible(ContentTypeIneligibility::MultipleValues), + ), + ( + &[b"\xff"], + ContentTypeProvenance::Ineligible(ContentTypeIneligibility::NonText), + ), + ]; + for (values, expected) in cases { + let mut headers = HeaderMap::new(); + for value in *values { + headers.append( + "content-type", + HeaderValue::from_bytes(value).expect("header bytes"), + ); + } + let normalized = normalize_response_headers(&Method::GET, StatusCode::OK, &mut headers) + .expect("normalized head"); + assert_eq!(normalized.content_type_provenance, *expected); + if *expected == ContentTypeProvenance::SingleTextUnchanged { + assert_eq!( + headers.get("content-type").expect("retained").as_bytes(), + values[0] + ); + } + assert!( + headers + .get_all("content-type") + .iter() + .all(|value| str::from_utf8(value.as_bytes()).is_ok()) + ); + } + + let mut headers = HeaderMap::new(); + headers.insert("content-type", HeaderValue::from_static("application/json")); + headers.insert("connection", HeaderValue::from_static("content-type")); + let normalized = normalize_response_headers(&Method::HEAD, StatusCode::OK, &mut headers) + .expect("bodyless normalized head"); + assert_eq!( + normalized.body_disposition, + ResponseBodyDisposition::FramingBodyless + ); + assert_eq!( + normalized.content_type_provenance, + ContentTypeProvenance::Ineligible(ContentTypeIneligibility::Removed) + ); + assert!(!headers.contains_key("content-type")); + } + + #[test] + fn response_content_type_provenance_survives_ownership_without_upgrades() { + use super::{ContentTypeIneligibility, ContentTypeProvenance}; + + for initial in [ + ContentTypeProvenance::SingleTextUnchanged, + ContentTypeProvenance::Ineligible(ContentTypeIneligibility::MultipleValues), + ContentTypeProvenance::Unknown, + ] { + let mut headers = HeaderMap::new(); + headers.insert("content-type", HeaderValue::from_static("application/json")); + let mut response = OutboundResponse::new( + Method::GET, + StatusCode::OK, + headers, + Body::empty(), + MonotonicClock::default(), + initial, + ); + assert_eq!(response.content_type_provenance(), initial); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("text/plain")); + let expected = if initial == ContentTypeProvenance::SingleTextUnchanged { + ContentTypeProvenance::Ineligible(ContentTypeIneligibility::Modified) + } else { + initial + }; + assert_eq!(response.content_type_provenance(), expected); + let response = response.into_response().expect("converted response"); + assert_eq!( + response.extensions().get::(), + Some(&expected) + ); + } + let response = OutboundResponse::new( + Method::GET, + StatusCode::OK, + HeaderMap::new(), + Body::empty(), + MonotonicClock::default(), + ContentTypeProvenance::Unknown, + ); + let (_, _, _, _, provenance) = response.into_parts(); + assert_eq!(provenance, ContentTypeProvenance::Unknown); + } + + #[test] + fn response_normalization_precedence_table() { + let mut nominated = HeaderMap::new(); + nominated.append( + "connection", + HeaderValue::from_static("content-encoding, content-length"), + ); + nominated.append("content-encoding", HeaderValue::from_static("gzip")); + nominated.append("content-length", HeaderValue::from_static("malformed")); + nominated.append("keep-alive", HeaderValue::from_static("timeout=5")); + assert_eq!( + normalize_response_headers(&Method::GET, StatusCode::OK, &mut nominated) + .expect("nominations precede semantics") + .body_disposition, + ResponseBodyDisposition::Payload + ); + assert!(nominated.get("content-encoding").is_none()); + assert!(nominated.get("content-length").is_none()); + assert!(nominated.get("keep-alive").is_none()); + + let mut ambiguous_encoding = HeaderMap::new(); + ambiguous_encoding.append("content-encoding", HeaderValue::from_static("gzip")); + ambiguous_encoding.append( + "content-encoding", + HeaderValue::from_bytes(&[0xff]).expect("opaque header value"), + ); + normalize_response_headers(&Method::GET, StatusCode::OK, &mut ambiguous_encoding) + .expect("invalid encoding sibling must remain visible to classification"); + assert_eq!( + classify_content_encoding(&ambiguous_encoding), + ContentEncoding::Passthrough(PassthroughReason::Stacked) + ); + assert_eq!( + ambiguous_encoding + .get_all("content-encoding") + .iter() + .count(), + 2 + ); + + for malformed in ["x-good,,x-other", "bad name", ",x-good", "x-good,"] { + let mut headers = HeaderMap::new(); + headers.append( + "connection", + HeaderValue::from_bytes(malformed.as_bytes()).expect("header"), + ); + let error = normalize_response_headers(&Method::GET, StatusCode::OK, &mut headers) + .expect_err("malformed connection"); + assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Protocol, + .. + } + )); + } + + for status in [ + StatusCode::CONTINUE, + StatusCode::NO_CONTENT, + StatusCode::NOT_MODIFIED, + ] { + let mut headers = HeaderMap::new(); + headers.append("content-length", HeaderValue::from_static("7")); + assert_eq!( + normalize_response_headers(&Method::GET, status, &mut headers) + .expect("bodyless") + .body_disposition, + ResponseBodyDisposition::FramingBodyless + ); + if status == StatusCode::NOT_MODIFIED { + assert_eq!(headers.get("content-length").expect("metadata"), "7"); + } else { + assert!(headers.get("content-length").is_none()); + } + } + + for (value, declared_body) in [(None, false), (Some("0"), false), (Some("7"), true)] { + let mut headers = HeaderMap::new(); + if let Some(value) = value { + headers.append( + "content-length", + HeaderValue::from_bytes(value.as_bytes()).expect("length"), + ); + } + assert_eq!( + normalize_response_headers(&Method::GET, StatusCode::RESET_CONTENT, &mut headers) + .expect("205") + .body_disposition, + ResponseBodyDisposition::ResetContent { declared_body } + ); + assert_eq!(headers.get("content-length").expect("normalized"), "0"); + } + + let mut conflicting = HeaderMap::new(); + conflicting.append("content-length", HeaderValue::from_static("7")); + conflicting.append("content-length", HeaderValue::from_static("8")); + normalize_response_headers(&Method::GET, StatusCode::OK, &mut conflicting) + .expect_err("conflicting content-length"); + + let mut idempotent = HeaderMap::new(); + idempotent.append("x-keep", HeaderValue::from_static("yes")); + normalize_response_headers(&Method::GET, StatusCode::OK, &mut idempotent).expect("first"); + let once = idempotent.clone(); + normalize_response_headers(&Method::GET, StatusCode::OK, &mut idempotent).expect("second"); + assert_eq!(idempotent, once); + } + + #[test] + fn response_normalization_rejects_non_utf8_content_length() { + let mut headers = HeaderMap::new(); + headers.append( + "content-length", + HeaderValue::from_bytes(&[0xff]).expect("opaque content-length"), + ); + + let error = normalize_response_headers(&Method::GET, StatusCode::OK, &mut headers) + .expect_err("non-UTF-8 content-length must fail closed"); + + assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Protocol, + .. + } + )); + } + + #[test] + fn head_and_not_modified_validate_and_retain_representation_length() { + for (method, status) in [ + (Method::HEAD, StatusCode::OK), + (Method::GET, StatusCode::NOT_MODIFIED), + ] { + for invalid in ["not-a-number", "18446744073709551616"] { + let mut headers = HeaderMap::new(); + headers.append( + "content-length", + HeaderValue::from_bytes(invalid.as_bytes()).expect("header value"), + ); + let error = normalize_response_headers(&method, status, &mut headers) + .expect_err("invalid bodyless content-length"); + assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Protocol, + .. + } + )); + } + + let mut conflicting = HeaderMap::new(); + conflicting.append("content-length", HeaderValue::from_static("7")); + conflicting.append("content-length", HeaderValue::from_static("8")); + let error = normalize_response_headers(&method, status, &mut conflicting) + .expect_err("conflicting bodyless content-length"); + assert!(matches!( + error, + EdgeError::BadGateway { + reason: BadGatewayReason::Protocol, + .. + } + )); + + let mut valid = HeaderMap::new(); + valid.append("content-length", HeaderValue::from_static("4294967296")); + assert_eq!( + normalize_response_headers(&method, status, &mut valid) + .expect("valid bodyless metadata") + .body_disposition, + ResponseBodyDisposition::FramingBodyless + ); + assert_eq!(valid.get("content-length").expect("length"), "4294967296"); + } + } + + #[test] + fn dispatch_validation_precedence_table() { + for method in [ + Method::GET, + Method::HEAD, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ] { + let request = + OutboundRequest::new(method, Uri::from_static("https://example.com/resource")) + .expect("portable request"); + validate_for_dispatch(&request).expect("portable method"); + } + + let request = OutboundRequest::new( + Method::CONNECT, + Uri::from_static("https://example.com/resource"), + ) + .expect("request"); + assert_eq!( + bad_request_message(validate_for_dispatch(&request).expect_err("custom method")), + "method CONNECT is not portable; supported: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" + ); + + let request = OutboundRequest::get("https://example.com") + .expect("request") + .body("not empty"); + assert_eq!( + bad_request_message(validate_for_dispatch(&request).expect_err("GET body")), + "GET/HEAD request must not carry a body" + ); + + let request = OutboundRequest::get("https://example.com") + .expect("request") + .body(Body::stream(stream::iter([Bytes::new()]))); + assert!(request.is_stream_body()); + assert_eq!( + bad_request_message(validate_for_dispatch(&request).expect_err("GET stream")), + "GET/HEAD request must not carry a streamed body; emptiness cannot be determined without consuming the stream" + ); + + let request = OutboundRequest::post("https://example.com") + .expect("request") + .body(Body::stream(stream::iter([Bytes::from_static(b"data")]))) + .stream_response(); + assert!(request.is_stream_body()); + assert!(request.is_stream_response()); + validate_for_dispatch(&request).expect("POST stream"); + + for bits in [9, 31] { + let request = OutboundRequest::get("https://example.com") + .expect("request") + .max_brotli_window_bits(bits); + assert!(validate_for_dispatch(&request).is_err(), "accepted {bits}"); + } + for bits in [10, 30] { + let request = OutboundRequest::get("https://example.com") + .expect("request") + .max_brotli_window_bits(bits); + validate_for_dispatch(&request).expect("valid Brotli window"); + } + } + + #[test] + fn request_normalization_is_idempotent() { + let mut request = OutboundRequest::post("https://example.com") + .expect("request") + .header("connection", "x-first, x-second") + .expect("connection") + .header("x-first", "one") + .expect("first") + .header("x-second", "two") + .expect("second") + .header("x-keep", "yes") + .expect("keep"); + + normalize_for_dispatch(&mut request).expect("first pass"); + let once = request.headers().clone(); + normalize_for_dispatch(&mut request).expect("second pass"); + assert_eq!(request.headers(), &once); + assert_eq!( + request.headers().get("x-keep"), + Some(&HeaderValue::from_static("yes")) + ); + } + + #[test] + fn request_normalization_strips_connection_nominations() { + let mut request = OutboundRequest::post("https://example.com") + .expect("request") + .header("connection", "x-first") + .expect("connection") + .header("connection", "x-second, X-Third") + .expect("connection") + .header("x-first", "one") + .expect("first") + .header("x-second", "two") + .expect("second") + .header("x-third", "three") + .expect("third") + .header("keep-alive", "timeout=5") + .expect("keep-alive") + .header("proxy-authenticate", "challenge") + .expect("proxy-authenticate") + .header("proxy-authorization", "secret") + .expect("proxy-authorization") + .header("te", "trailers") + .expect("te") + .header("trailer", "x-checksum") + .expect("trailer") + .header("transfer-encoding", "chunked") + .expect("transfer-encoding") + .header("upgrade", "websocket") + .expect("upgrade") + .header("host", "wrong.example") + .expect("host") + .header("content-length", "100") + .expect("content-length") + .header("x-keep", "yes") + .expect("keep"); + + normalize_for_dispatch(&mut request).expect("normalize"); + for name in [ + "connection", + "x-first", + "x-second", + "x-third", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "host", + "content-length", + ] { + assert!(request.headers().get(name).is_none(), "retained {name}"); + } + assert_eq!( + request.headers().get("x-keep"), + Some(&HeaderValue::from_static("yes")) + ); + } + + #[test] + fn request_normalization_rejects_malformed_connection() { + for value in ["x-valid,,x-other", "bad name", ",x-valid", "x-valid,"] { + let mut request = OutboundRequest::get("https://example.com").expect("request"); + request.headers_mut().append( + "connection", + HeaderValue::from_str(value).expect("header value"), + ); + assert!( + normalize_for_dispatch(&mut request).is_err(), + "accepted {value}" + ); + } + + let mut request = OutboundRequest::get("https://example.com").expect("request"); + request.headers_mut().append( + "connection", + HeaderValue::from_bytes(b"x-private,\xff").expect("opaque header"), + ); + normalize_for_dispatch(&mut request).expect_err("opaque connection nomination"); + + let mut request = OutboundRequest::get("https://example.com").expect("request"); + request.headers_mut().append( + "connection", + HeaderValue::from_bytes(b"\xc2\xa0x-private").expect("UTF-8 header value"), + ); + normalize_for_dispatch(&mut request).expect_err("non-HTTP whitespace nomination"); + + let mut headers = HeaderMap::new(); + headers.append( + "connection", + HeaderValue::from_bytes(b"\xc2\xa0x-private").expect("UTF-8 header value"), + ); + normalize_response_headers(&Method::GET, StatusCode::OK, &mut headers) + .expect_err("non-HTTP whitespace response nomination"); + } + + #[test] + fn request_normalization_drops_non_utf8_content_encoding() { + let mut request = OutboundRequest::post("https://example.com").expect("request"); + request.headers_mut().append( + "content-encoding", + HeaderValue::from_bytes(&[0xff]).expect("opaque header value"), + ); + + normalize_for_dispatch(&mut request).expect("normalize"); + + assert!(request.headers().get("content-encoding").is_none()); + } + + #[test] + #[expect( + clippy::cognitive_complexity, + reason = "the round-trip contract intentionally verifies every independent outbound field" + )] + fn outbound_request_defaults_and_parts_round_trip() { + let deadline = Deadline::after(Duration::from_secs(9)); + let request = OutboundRequest::post("https://example.com/items") + .expect("request") + .body("payload") + .cache_policy(OutboundCachePolicy::Bypass) + .deadline(deadline) + .header("x-test", "one") + .expect("header") + .host_authority_override("[2001:db8::1]:8443") + .expect("authority override") + .max_decoder_bytes(40 * 1024 * 1024) + .max_brotli_window_bits(23) + .max_chunk_bytes(NonZeroU64::new(4096).expect("nonzero")) + .max_decoded_response_bytes(2_000_000) + .max_encoded_response_bytes(1_000_000) + .max_request_body_bytes(3_000_000) + .max_response_bytes(4_000_000) + .max_response_header_bytes(32_000) + .max_response_header_count(80) + .timeout(Duration::from_secs(8)); + + let parts = request.into_parts(); + assert_eq!(parts.method, Method::POST); + assert_eq!(parts.uri, Uri::from_static("https://example.com/items")); + assert_eq!(parts.cache_policy, OutboundCachePolicy::Bypass); + assert_eq!( + parts + .host_authority_override + .as_ref() + .map(Authority::as_str), + Some("[2001:db8::1]:8443") + ); + assert_eq!(parts.body.as_bytes(), Some(b"payload".as_slice())); + assert_eq!( + parts.deadline.expect("deadline").instant(), + deadline.instant() + ); + assert_eq!( + parts.headers.get("x-test"), + Some(&HeaderValue::from_static("one")) + ); + assert_eq!(parts.max_decoder_bytes, 40 * 1024 * 1024); + assert_eq!(parts.max_brotli_window_bits, 23); + assert_eq!(parts.max_chunk_bytes.map(NonZeroU64::get), Some(4096)); + assert_eq!(parts.max_decoded_response_bytes, Some(2_000_000)); + assert_eq!(parts.max_encoded_response_bytes, Some(1_000_000)); + assert_eq!(parts.max_request_body_bytes, 3_000_000); + assert_eq!(parts.max_response_header_bytes, Some(32_000)); + assert_eq!(parts.max_response_header_count, Some(80)); + assert_eq!( + parts.response_mode, + ResponseMode::Buffered { + max_bytes: 4_000_000 + } + ); + assert_eq!(parts.timeout, Some(Duration::from_secs(8))); + + let request = OutboundRequest::from_parts(parts).expect("round trip"); + assert_eq!(request.method(), &Method::POST); + assert_eq!(request.host_authority(), "[2001:db8::1]:8443"); + assert_eq!(request.backend_target(), "example.com:443"); + assert_eq!(request.sni_hostname(), Some("example.com")); + assert_eq!(request.cert_host(), Some("example.com")); + assert_eq!( + request.uri(), + &Uri::from_static("https://example.com/items") + ); + + let defaults = OutboundRequest::get("https://example.com") + .expect("defaults") + .into_parts(); + assert_eq!(defaults.max_decoder_bytes, 0x0200_0000); + assert_eq!(defaults.max_brotli_window_bits, 24); + assert_eq!(defaults.max_request_body_bytes, 0x0080_0000); + assert_eq!(defaults.cache_policy, OutboundCachePolicy::PlatformDefault); + assert!(defaults.host_authority_override.is_none()); + assert_eq!( + defaults.response_mode, + ResponseMode::Buffered { + max_bytes: 0x0010_0000 + } + ); + assert!(defaults.deadline.is_none()); + assert!(defaults.max_decoded_response_bytes.is_none()); + assert!(defaults.max_encoded_response_bytes.is_none()); + assert!(defaults.max_response_header_bytes.is_none()); + assert!(defaults.max_response_header_count.is_none()); + assert!(defaults.timeout.is_none()); + } + + #[test] + fn outbound_request_policy_accepts_only_http_authorities() { + for authority in [ + "api.example.com", + "api.example.com:8443", + "api.example.com:65535", + "192.0.2.1:8080", + "[2001:db8::1]", + "[2001:db8::1]:8443", + ] { + let request = OutboundRequest::get("https://origin.example/resource") + .expect("request") + .host_authority_override(authority) + .expect("valid authority"); + assert!(request.has_host_authority_override()); + assert_eq!(request.host_authority(), authority); + assert_eq!(request.backend_target(), "origin.example:443"); + assert_eq!(request.sni_hostname(), Some("origin.example")); + assert_eq!(request.cert_host(), Some("origin.example")); + } + + for invalid in [ + "", + "user@api.example.com", + "https://api.example.com", + "api.example.com/path", + "api example.com", + "api.example.com\r\nx-added: value", + "2001:db8::1", + "api.example.com:", + "api.example.com:abc", + "api.example.com:65536", + "[2001:db8::1]:abc", + "[2001:db8::1]:65536", + ] { + let error = OutboundRequest::get("https://origin.example") + .expect("request") + .host_authority_override(invalid) + .expect_err("invalid authority"); + assert_eq!( + bad_request_message(error), + "invalid outbound host authority override" + ); + } + } + + #[test] + fn outbound_request_response_mode_setters_are_last_write_wins() { + let streamed = OutboundRequest::get("https://example.com") + .expect("streamed request") + .max_response_bytes(7) + .stream_response() + .into_parts(); + assert_eq!(streamed.response_mode, ResponseMode::Streamed); + + let buffered = OutboundRequest::get("https://example.com") + .expect("buffered request") + .stream_response() + .max_response_bytes(7) + .into_parts(); + assert_eq!( + buffered.response_mode, + ResponseMode::Buffered { max_bytes: 7 } + ); + } + + #[test] + fn outbound_request_canonicalizes_url_table() { + let cases = [ + ("HTTPS://EXAMPLE.com:443/a/../b", "https://example.com/b"), + ("http://example.com:80", "http://example.com/"), + ("https://example.com:8443", "https://example.com:8443/"), + ("https://127.0.0.1", "https://127.0.0.1/"), + ("https://[::1]:443/a", "https://[::1]/a"), + ("https://caf%C3%A9.example/", "https://xn--caf-dma.example/"), + ]; + + for (input, expected) in cases { + let request = OutboundRequest::get(input).expect(input); + assert_eq!(request.uri().to_string(), expected, "{input}"); + } + + let dns = OutboundRequest::get("https://example.com").expect("dns"); + assert_eq!(dns.backend_target(), "example.com:443"); + assert_eq!(dns.cert_host(), Some("example.com")); + assert_eq!(dns.host_authority(), "example.com"); + assert_eq!(dns.host_name(), "example.com"); + assert_eq!(dns.sni_hostname(), Some("example.com")); + + let ip = OutboundRequest::get("https://[::1]:8443").expect("ip"); + assert_eq!(ip.backend_target(), "[::1]:8443"); + assert_eq!(ip.cert_host(), Some("::1")); + assert_eq!(ip.host_authority(), "[::1]:8443"); + assert_eq!(ip.host_name(), "::1"); + assert_eq!(ip.sni_hostname(), None); + } + + #[test] + fn outbound_request_rejects_invalid_target_table() { + for target in [ + "ftp://example.com", + "/relative", + "https:///missing", + "https://user:pass@example.com", + "https://example.com/path#fragment", + ] { + assert!(OutboundRequest::get(target).is_err(), "accepted {target}"); + } + } + + #[test] + fn outbound_request_rejects_empty_userinfo() { + OutboundRequest::get("https://@example.com/").expect_err("empty userinfo"); + } + + #[test] + fn typed_outbound_request_rejects_empty_userinfo() { + let typed = "https://@example.com/" + .parse::() + .expect("typed URI retains empty userinfo syntax"); + OutboundRequest::new(Method::GET, typed.clone()).expect_err("typed empty userinfo"); + + let mut parts = OutboundRequest::get("https://example.com/") + .expect("baseline request") + .into_parts(); + parts.uri = typed; + OutboundRequest::from_parts(parts).expect_err("parts empty userinfo"); + } + + #[test] + fn outbound_request_rejects_backslash_authority_forms() { + for target in [ + r"https:\\@example.com/", + r"https:/\@example.com/", + r"https:\/\@example.com/", + r"https://example.com\path", + ] { + assert!(OutboundRequest::get(target).is_err(), "accepted {target}"); + } + } + + #[test] + fn outbound_request_preserves_percent_encoded_hash() { + let request = OutboundRequest::get("https://example.com/a%23b?q=%40user") + .expect("encoded delimiters"); + assert_eq!( + request.uri().to_string(), + "https://example.com/a%23b?q=%40user" + ); + } + + #[test] + fn outbound_request_from_request_normalizes_immediately() { + let request = request_builder() + .method(Method::POST) + .uri("/local") + .header("connection", "x-remove") + .header("x-remove", "gone") + .header("host", "local.invalid") + .header("content-length", "7") + .header("transfer-encoding", "chunked") + .header("x-keep", "yes") + .body(Body::from("payload")) + .expect("inbound request"); + + let outbound = + OutboundRequest::from_request(request, Uri::from_static("https://example.com/target")) + .expect("outbound request"); + assert_eq!(outbound.method(), &Method::POST); + assert_eq!( + outbound.headers().get("x-keep"), + Some(&HeaderValue::from_static("yes")) + ); + for removed in [ + "connection", + "x-remove", + "host", + "content-length", + "transfer-encoding", + ] { + assert!( + !outbound.headers().contains_key(removed), + "retained {removed}" + ); + } + assert_eq!( + outbound.into_parts().body.as_bytes(), + Some(b"payload".as_slice()) + ); + + let request = request_builder() + .method(Method::GET) + .uri("/local") + .header("x-keep", "yes") + .body(Body::empty()) + .expect("inbound request"); + let outbound = + OutboundRequest::from_request(request, Uri::from_static("https://example.com/target")) + .expect("outbound request"); + assert_eq!( + outbound.headers().get("x-keep"), + Some(&HeaderValue::from_static("yes")) + ); + } + + #[test] + fn outbound_normalization_strips_proxy_connection() { + let mut request = OutboundRequest::get("https://example.com/").expect("request"); + request + .headers_mut() + .insert("proxy-connection", HeaderValue::from_static("keep-alive")); + normalize_for_dispatch(&mut request).expect("request normalization"); + assert!(!request.headers().contains_key("proxy-connection")); + + let mut response_headers = HeaderMap::new(); + response_headers.insert("proxy-connection", HeaderValue::from_static("keep-alive")); + normalize_response_headers(&Method::GET, StatusCode::OK, &mut response_headers) + .expect("response normalization"); + assert!(!response_headers.contains_key("proxy-connection")); + } + + #[test] + fn outbound_request_rejects_invalid_header_bytes() { + let request = OutboundRequest::get("https://example.com").expect("request"); + request + .header(b"bad name", b"value") + .expect_err("invalid header name"); + + let request = OutboundRequest::get("https://example.com").expect("request"); + request + .header(b"x-test", [0xff]) + .expect_err("invalid header value"); + + let request = OutboundRequest::get("https://example.com").expect("request"); + request + .header(b"x-test", b"line\nbreak") + .expect_err("header value line break"); + } +} diff --git a/crates/edgezero-core/src/platform.rs b/crates/edgezero-core/src/platform.rs new file mode 100644 index 000000000..016b0033d --- /dev/null +++ b/crates/edgezero-core/src/platform.rs @@ -0,0 +1,1047 @@ +//! Target platform properties that affect application resource policy. + +use std::num::NonZeroU32; + +/// Where a platform resource fact comes from. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum PlatformResourceSource { + /// A default published by a named hosted runtime profile. + HostedDefault { + /// Hosted runtime profile that publishes the default. + provider: &'static str, + }, + /// A limit published for the named platform. + PlatformLimit { + /// Platform that publishes the limit. + provider: &'static str, + }, + /// A value supplied by a configurable runtime deployment. + RuntimeConfiguration { + /// Runtime whose deployment configuration supplied the value. + runtime: &'static str, + }, +} + +/// Why a platform resource fact is not known. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum PlatformUnknownReason { + /// The operator must supply the value. + OperatorConfigured, + /// The provider does not publish the value. + ProviderUnpublished, + /// The runtime deployment determines the value. + RuntimeConfigured, + /// No more specific reason was supplied. + Unspecified, +} + +/// A platform resource fact that preserves both provenance and unknown state. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum PlatformFact { + /// A known value and its source. + Known { + /// Published or configured value. + value: Value, + /// Where the value comes from. + source: PlatformResourceSource, + }, + /// A value that is not known. + Unknown { + /// Why the value is not known. + reason: PlatformUnknownReason, + }, +} + +impl PlatformFact { + /// Construct a known platform fact. + #[must_use] + #[inline] + pub const fn known(value: Value, source: PlatformResourceSource) -> Self { + Self::Known { value, source } + } + + /// Return the source of a known fact. + #[must_use] + #[inline] + pub const fn source(&self) -> Option { + match self { + Self::Known { source, .. } => Some(*source), + Self::Unknown { .. } => None, + } + } + + /// Construct an unknown platform fact. + #[must_use] + #[inline] + pub const fn unknown(reason: PlatformUnknownReason) -> Self { + Self::Unknown { reason } + } + + /// Return the reason for an unknown fact. + #[must_use] + #[inline] + pub const fn unknown_reason(&self) -> Option { + match self { + Self::Known { .. } => None, + Self::Unknown { reason } => Some(*reason), + } + } + + /// Return the known value, if present. + #[must_use] + #[inline] + pub fn value(self) -> Option { + match self { + Self::Known { value, .. } => Some(value), + Self::Unknown { .. } => None, + } + } +} + +/// What shares a target's published memory ceiling. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum MemoryCeilingScope { + /// Every request execution receives an independent budget. + PerExecution, + /// Concurrent requests served by one runtime instance share one budget. + PerInstance, +} + +/// A target's primary memory ceiling and its accounting semantics. +/// +/// `total_bytes` is the primary memory allowance. When `stack_bytes` is present, +/// the platform publishes that stack allowance separately and it is not +/// included in `total_bytes`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct MemoryCeiling { + scope: MemoryCeilingScope, + stack_bytes: Option, + total_bytes: u64, +} + +impl MemoryCeiling { + /// Construct a memory ceiling from exact byte counts. + #[must_use] + #[inline] + pub const fn new( + total_bytes: u64, + scope: MemoryCeilingScope, + stack_bytes: Option, + ) -> Self { + Self { + scope, + stack_bytes, + total_bytes, + } + } + + /// What shares this memory budget. + #[must_use] + #[inline] + pub const fn scope(self) -> MemoryCeilingScope { + self.scope + } + + /// Separately published stack allowance, in bytes. + #[must_use] + #[inline] + pub const fn stack_bytes(self) -> Option { + self.stack_bytes + } + + /// Primary memory allowance, in bytes, excluding a separately reported stack. + #[must_use] + #[inline] + pub const fn total_bytes(self) -> u64 { + self.total_bytes + } +} + +/// Maximum live inbound request executions charged to one memory ceiling. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct InboundRequestPopulationBound { + max_live_requests: NonZeroU32, +} + +impl InboundRequestPopulationBound { + /// Maximum live inbound requests charged to one memory ceiling. + #[must_use] + #[inline] + pub const fn max_live_requests(self) -> NonZeroU32 { + self.max_live_requests + } + + /// Construct an inbound request population bound. + #[must_use] + #[inline] + pub const fn new(max_live_requests: NonZeroU32) -> Self { + Self { max_live_requests } + } +} + +/// Whether host-owned ingress memory is charged to the published ceiling. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum HostIngressMemoryAccounting { + /// Host-owned ingress memory is charged to the published ceiling. + CountsTowardCeiling, + /// Host-owned ingress memory is outside the published ceiling. + OutsideCeiling, +} + +/// Application memory requirements declared for one platform memory domain. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct MemoryEnvelope { + fixed_bytes: u64, + per_live_request_bytes: u64, + scope: MemoryCeilingScope, + separate_stack_bytes: Option, +} + +impl MemoryEnvelope { + /// Bytes charged once to the memory domain. + #[must_use] + #[inline] + pub const fn fixed_bytes(self) -> u64 { + self.fixed_bytes + } + + /// Construct a memory envelope from exact byte counts. + #[must_use] + #[inline] + pub const fn new( + scope: MemoryCeilingScope, + fixed_bytes: u64, + per_live_request_bytes: u64, + separate_stack_bytes: Option, + ) -> Self { + Self { + fixed_bytes, + per_live_request_bytes, + scope, + separate_stack_bytes, + } + } + + /// Bytes charged for each live inbound request. + #[must_use] + #[inline] + pub const fn per_live_request_bytes(self) -> u64 { + self.per_live_request_bytes + } + + /// Scope in which this envelope's accounting applies. + #[must_use] + #[inline] + pub const fn scope(self) -> MemoryCeilingScope { + self.scope + } + + /// Required bytes from a separately published stack allowance. + #[must_use] + #[inline] + pub const fn separate_stack_bytes(self) -> Option { + self.separate_stack_bytes + } +} + +/// Result of validating an application memory envelope against platform facts. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum MemoryEnvelopeValidation { + /// A known memory resource cannot satisfy its requirement. + Exceeds { + /// Published bytes available for the resource. + available_bytes: u64, + /// Bytes required from the resource. + required_bytes: u64, + /// Resource whose requirement exceeds its allowance. + /// + /// Primary memory excess is reported before separate-stack excess when both apply. + resource: MemoryResource, + }, + /// All required facts are known and every requirement fits. + Fits { + /// Published primary memory allowance. + available_primary_bytes: u64, + /// Separately published stack allowance, when present. + available_separate_stack_bytes: Option, + /// Required primary memory. + required_primary_bytes: u64, + /// Required separately published stack memory, when present. + required_separate_stack_bytes: Option, + }, + /// No known requirement exceeds its allowance, but facts needed for a fit are missing. + Indeterminate { + /// Known primary allowance, when published. + available_primary_bytes: Option, + /// Checked minimum primary memory requirement. + minimum_required_primary_bytes: u64, + /// Missing facts in deterministic validation order: unknown memory ceiling, + /// unknown inbound request population, unknown host ingress memory accounting, + /// then missing separate-stack requirement. + reasons: Vec, + }, +} + +/// Invalid memory-envelope or platform-fact combinations. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] +pub enum MemoryEnvelopeValidationError { + /// Checked primary requirement arithmetic overflowed. + #[error("memory envelope primary requirement arithmetic overflow")] + ArithmeticOverflow { + /// Bytes charged once to the memory domain. + fixed_bytes: u64, + /// Known request population used in the calculation, if any. + max_live_requests: Option, + /// Bytes charged for each live inbound request. + per_live_request_bytes: u64, + }, + /// A per-execution memory domain published a population other than one. + #[error("invalid per-execution request population")] + NonUnitPerExecutionPopulation { + /// Published maximum live request population. + max_live_requests: NonZeroU32, + }, + /// The envelope and published ceiling use different accounting scopes. + #[error("memory envelope scope mismatch")] + ScopeMismatch { + /// Scope of the published ceiling. + ceiling_scope: MemoryCeilingScope, + /// Scope declared by the envelope. + envelope_scope: MemoryCeilingScope, + }, + /// The envelope requires a separate stack but the known ceiling does not publish one. + #[error("unexpected separate stack requirement")] + UnexpectedSeparateStackRequirement { + /// Required separate stack bytes. + required_bytes: u64, + }, +} + +/// Memory resource compared by envelope validation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum MemoryResource { + /// Primary memory allowance. + Primary, + /// Separately published stack allowance. + SeparateStack, +} + +/// Missing fact that prevents a complete memory-envelope validation result. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum MemoryValidationIndeterminateReason { + /// The platform publishes a separate stack allowance but no requirement was supplied. + MissingSeparateStackRequirement, + /// Host ingress memory accounting is unknown. + UnknownHostIngressMemoryAccounting { + /// Why the accounting fact is unknown. + reason: PlatformUnknownReason, + }, + /// The maximum live inbound request population is unknown. + UnknownInboundRequestPopulation { + /// Why the population fact is unknown. + reason: PlatformUnknownReason, + }, + /// The platform memory ceiling is unknown. + UnknownMemoryCeiling { + /// Why the ceiling fact is unknown. + reason: PlatformUnknownReason, + }, +} + +/// Immutable target properties supplied to application construction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct PlatformMetadata { + host_ingress_memory_accounting: PlatformFact, + inbound_request_population_bound: PlatformFact, + memory_ceiling: PlatformFact, +} + +impl PlatformMetadata { + /// The target's host ingress memory accounting fact. + #[must_use] + #[inline] + pub const fn host_ingress_memory_accounting(self) -> PlatformFact { + self.host_ingress_memory_accounting + } + + /// The target's live inbound request population bound fact. + #[must_use] + #[inline] + pub const fn inbound_request_population_bound( + self, + ) -> PlatformFact { + self.inbound_request_population_bound + } + + /// The target's published memory ceiling fact. + #[must_use] + #[inline] + pub const fn memory_ceiling(self) -> PlatformFact { + self.memory_ceiling + } + + /// Construct target metadata from explicit platform resource facts. + #[must_use] + #[inline] + pub const fn new( + memory_ceiling: PlatformFact, + inbound_request_population_bound: PlatformFact, + host_ingress_memory_accounting: PlatformFact, + ) -> Self { + Self { + host_ingress_memory_accounting, + inbound_request_population_bound, + memory_ceiling, + } + } + + /// Validate an application memory envelope against the target's published facts. + /// + /// # Errors + /// + /// Returns an error for contradictory scopes or stack shapes, a non-unit + /// per-execution population, or checked primary requirement overflow. + #[inline] + pub fn validate_memory_envelope( + self, + envelope: MemoryEnvelope, + ) -> Result { + let known_ceiling = self.memory_ceiling.value(); + let known_population = self + .inbound_request_population_bound + .value() + .map(InboundRequestPopulationBound::max_live_requests); + + if let Some(ceiling) = known_ceiling { + if envelope.scope() != ceiling.scope() { + return Err(MemoryEnvelopeValidationError::ScopeMismatch { + ceiling_scope: ceiling.scope(), + envelope_scope: envelope.scope(), + }); + } + if let (Some(required_bytes), None) = + (envelope.separate_stack_bytes(), ceiling.stack_bytes()) + { + return Err( + MemoryEnvelopeValidationError::UnexpectedSeparateStackRequirement { + required_bytes, + }, + ); + } + } + + if envelope.scope() == MemoryCeilingScope::PerExecution + && let Some(max_live_requests) = known_population + && max_live_requests != NonZeroU32::MIN + { + return Err( + MemoryEnvelopeValidationError::NonUnitPerExecutionPopulation { max_live_requests }, + ); + } + + let population_multiplier = match envelope.scope() { + MemoryCeilingScope::PerExecution => 1, + MemoryCeilingScope::PerInstance => { + known_population.map_or(1, |value| value.get().into()) + } + }; + let minimum_required_primary_bytes = envelope + .per_live_request_bytes() + .checked_mul(population_multiplier) + .and_then(|request_bytes| envelope.fixed_bytes().checked_add(request_bytes)) + .ok_or(MemoryEnvelopeValidationError::ArithmeticOverflow { + fixed_bytes: envelope.fixed_bytes(), + max_live_requests: known_population, + per_live_request_bytes: envelope.per_live_request_bytes(), + })?; + + if let Some(ceiling) = known_ceiling { + if minimum_required_primary_bytes > ceiling.total_bytes() { + return Ok(MemoryEnvelopeValidation::Exceeds { + available_bytes: ceiling.total_bytes(), + required_bytes: minimum_required_primary_bytes, + resource: MemoryResource::Primary, + }); + } + if let (Some(required_bytes), Some(available_bytes)) = + (envelope.separate_stack_bytes(), ceiling.stack_bytes()) + && required_bytes > available_bytes + { + return Ok(MemoryEnvelopeValidation::Exceeds { + available_bytes, + required_bytes, + resource: MemoryResource::SeparateStack, + }); + } + } + + let mut reasons = Vec::new(); + if let PlatformFact::Unknown { reason } = self.memory_ceiling { + reasons.push(MemoryValidationIndeterminateReason::UnknownMemoryCeiling { reason }); + } + if let PlatformFact::Unknown { reason } = self.inbound_request_population_bound { + reasons.push( + MemoryValidationIndeterminateReason::UnknownInboundRequestPopulation { reason }, + ); + } + if let PlatformFact::Unknown { reason } = self.host_ingress_memory_accounting { + reasons.push( + MemoryValidationIndeterminateReason::UnknownHostIngressMemoryAccounting { reason }, + ); + } + if known_ceiling.is_some_and(|ceiling| ceiling.stack_bytes().is_some()) + && envelope.separate_stack_bytes().is_none() + { + reasons.push(MemoryValidationIndeterminateReason::MissingSeparateStackRequirement); + } + + if let Some(ceiling) = known_ceiling + && reasons.is_empty() + { + return Ok(MemoryEnvelopeValidation::Fits { + available_primary_bytes: ceiling.total_bytes(), + available_separate_stack_bytes: ceiling.stack_bytes(), + required_primary_bytes: minimum_required_primary_bytes, + required_separate_stack_bytes: envelope.separate_stack_bytes(), + }); + } + + Ok(MemoryEnvelopeValidation::Indeterminate { + available_primary_bytes: known_ceiling.map(MemoryCeiling::total_bytes), + minimum_required_primary_bytes, + reasons, + }) + } +} + +impl Default for PlatformMetadata { + #[inline] + fn default() -> Self { + Self::new( + PlatformFact::unknown(PlatformUnknownReason::Unspecified), + PlatformFact::unknown(PlatformUnknownReason::Unspecified), + PlatformFact::unknown(PlatformUnknownReason::Unspecified), + ) + } +} + +#[cfg(test)] +mod tests { + use std::error::Error; + use std::num::NonZeroU32; + + use super::*; + + const TEST_SOURCE: PlatformResourceSource = PlatformResourceSource::PlatformLimit { + provider: "test-platform", + }; + + fn known(value: Value) -> PlatformFact { + PlatformFact::known(value, TEST_SOURCE) + } + + fn population(max_live_requests: u32) -> InboundRequestPopulationBound { + InboundRequestPopulationBound::new( + NonZeroU32::new(max_live_requests).expect("test population must be non-zero"), + ) + } + + #[test] + fn platform_fact_distinguishes_known_and_unknown_metadata() { + const CEILING: MemoryCeiling = + MemoryCeiling::new(128_000_000, MemoryCeilingScope::PerInstance, None); + const SOURCE: PlatformResourceSource = PlatformResourceSource::PlatformLimit { + provider: "test-platform", + }; + const KNOWN: PlatformFact = PlatformFact::known(CEILING, SOURCE); + const UNKNOWN: PlatformFact = + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished); + + assert_eq!(KNOWN.value(), Some(CEILING)); + assert_eq!(KNOWN.source(), Some(SOURCE)); + assert_eq!(KNOWN.unknown_reason(), None); + assert_eq!(UNKNOWN.value(), None); + assert_eq!(UNKNOWN.source(), None); + assert_eq!( + UNKNOWN.unknown_reason(), + Some(PlatformUnknownReason::ProviderUnpublished) + ); + } + + #[test] + fn platform_fact_accessors_support_non_copy_values() { + assert_eq!( + PlatformFact::known(String::from("value"), TEST_SOURCE).source(), + Some(TEST_SOURCE) + ); + assert_eq!( + PlatformFact::known(String::from("value"), TEST_SOURCE).unknown_reason(), + None + ); + assert_eq!( + PlatformFact::known(String::from("value"), TEST_SOURCE).value(), + Some(String::from("value")) + ); + } + + #[test] + fn memory_envelope_accessors_preserve_declared_shape() { + const ENVELOPE: MemoryEnvelope = + MemoryEnvelope::new(MemoryCeilingScope::PerInstance, 8_192, 4_096, Some(1_024)); + + assert_eq!(ENVELOPE.scope(), MemoryCeilingScope::PerInstance); + assert_eq!(ENVELOPE.fixed_bytes(), 8_192); + assert_eq!(ENVELOPE.per_live_request_bytes(), 4_096); + assert_eq!(ENVELOPE.separate_stack_bytes(), Some(1_024)); + } + + #[test] + fn validation_error_messages_are_stable_and_category_only() { + let errors = [ + MemoryEnvelopeValidationError::ArithmeticOverflow { + fixed_bytes: 123, + max_live_requests: Some(population(2).max_live_requests()), + per_live_request_bytes: 456, + }, + MemoryEnvelopeValidationError::NonUnitPerExecutionPopulation { + max_live_requests: population(2).max_live_requests(), + }, + MemoryEnvelopeValidationError::ScopeMismatch { + ceiling_scope: MemoryCeilingScope::PerExecution, + envelope_scope: MemoryCeilingScope::PerInstance, + }, + MemoryEnvelopeValidationError::UnexpectedSeparateStackRequirement { + required_bytes: 789, + }, + ]; + + for error in &errors { + let _: &dyn Error = error; + } + assert_eq!( + errors[0].to_string(), + "memory envelope primary requirement arithmetic overflow" + ); + assert_eq!( + errors[1].to_string(), + "invalid per-execution request population" + ); + assert_eq!(errors[2].to_string(), "memory envelope scope mismatch"); + assert_eq!( + errors[3].to_string(), + "unexpected separate stack requirement" + ); + } + + #[test] + fn validation_fits_per_execution_with_counted_host_memory() { + let metadata = PlatformMetadata::new( + known(MemoryCeiling::new( + 100, + MemoryCeilingScope::PerExecution, + Some(20), + )), + known(population(1)), + known(HostIngressMemoryAccounting::CountsTowardCeiling), + ); + let envelope = MemoryEnvelope::new(MemoryCeilingScope::PerExecution, 40, 30, Some(15)); + + assert_eq!(population(1).max_live_requests(), NonZeroU32::MIN); + let validation: crate::MemoryEnvelopeValidation = metadata + .validate_memory_envelope(envelope) + .expect("valid envelope should produce a validation result"); + assert_eq!( + validation, + crate::MemoryEnvelopeValidation::Fits { + available_primary_bytes: 100, + available_separate_stack_bytes: Some(20), + required_primary_bytes: 70, + required_separate_stack_bytes: Some(15), + } + ); + } + + #[test] + fn validation_fits_per_instance_with_host_memory_outside_ceiling() { + let metadata = PlatformMetadata::new( + known(MemoryCeiling::new( + 1_000, + MemoryCeilingScope::PerInstance, + None, + )), + known(population(3)), + known(HostIngressMemoryAccounting::OutsideCeiling), + ); + let envelope = MemoryEnvelope::new(MemoryCeilingScope::PerInstance, 100, 200, None); + + assert_eq!( + metadata.validate_memory_envelope(envelope), + Ok(crate::MemoryEnvelopeValidation::Fits { + available_primary_bytes: 1_000, + available_separate_stack_bytes: None, + required_primary_bytes: 700, + required_separate_stack_bytes: None, + }) + ); + } + + #[test] + fn validation_known_minimum_excess_precedes_unknown_facts() { + let primary_metadata = PlatformMetadata::new( + known(MemoryCeiling::new( + 25, + MemoryCeilingScope::PerInstance, + Some(5), + )), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + ); + let primary_envelope = MemoryEnvelope::new(MemoryCeilingScope::PerInstance, 20, 10, None); + + assert_eq!( + primary_metadata.validate_memory_envelope(primary_envelope), + Ok(crate::MemoryEnvelopeValidation::Exceeds { + available_bytes: 25, + required_bytes: 30, + resource: crate::MemoryResource::Primary, + }) + ); + + let stack_metadata = PlatformMetadata::new( + known(MemoryCeiling::new( + 100, + MemoryCeilingScope::PerInstance, + Some(5), + )), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + ); + let stack_envelope = MemoryEnvelope::new(MemoryCeilingScope::PerInstance, 20, 10, Some(6)); + + assert_eq!( + stack_metadata.validate_memory_envelope(stack_envelope), + Ok(crate::MemoryEnvelopeValidation::Exceeds { + available_bytes: 5, + required_bytes: 6, + resource: crate::MemoryResource::SeparateStack, + }) + ); + } + + #[test] + fn validation_missing_published_stack_requirement_is_indeterminate() { + let metadata = PlatformMetadata::new( + known(MemoryCeiling::new( + 100, + MemoryCeilingScope::PerExecution, + Some(20), + )), + known(population(1)), + known(HostIngressMemoryAccounting::OutsideCeiling), + ); + let envelope = MemoryEnvelope::new(MemoryCeilingScope::PerExecution, 20, 10, None); + + assert_eq!( + metadata.validate_memory_envelope(envelope), + Ok(crate::MemoryEnvelopeValidation::Indeterminate { + available_primary_bytes: Some(100), + minimum_required_primary_bytes: 30, + reasons: vec![ + crate::MemoryValidationIndeterminateReason::MissingSeparateStackRequirement, + ], + }) + ); + } + + #[test] + fn validation_rejects_non_unit_per_execution_population() { + let metadata = PlatformMetadata::new( + known(MemoryCeiling::new( + 100, + MemoryCeilingScope::PerExecution, + None, + )), + known(population(2)), + known(HostIngressMemoryAccounting::OutsideCeiling), + ); + let envelope = MemoryEnvelope::new(MemoryCeilingScope::PerExecution, 20, 10, None); + let validation: Result< + crate::MemoryEnvelopeValidation, + crate::MemoryEnvelopeValidationError, + > = metadata.validate_memory_envelope(envelope); + + assert_eq!( + validation, + Err( + crate::MemoryEnvelopeValidationError::NonUnitPerExecutionPopulation { + max_live_requests: population(2).max_live_requests(), + }, + ) + ); + } + + #[test] + fn validation_rejects_primary_arithmetic_overflow() { + let metadata = PlatformMetadata::new( + known(MemoryCeiling::new( + u64::MAX, + MemoryCeilingScope::PerInstance, + None, + )), + known(population(1)), + known(HostIngressMemoryAccounting::CountsTowardCeiling), + ); + let envelope = MemoryEnvelope::new(MemoryCeilingScope::PerInstance, u64::MAX, 1, None); + + assert_eq!( + metadata.validate_memory_envelope(envelope), + Err(crate::MemoryEnvelopeValidationError::ArithmeticOverflow { + fixed_bytes: u64::MAX, + max_live_requests: Some(NonZeroU32::MIN), + per_live_request_bytes: 1, + }) + ); + + let multiplication_metadata = PlatformMetadata::new( + known(MemoryCeiling::new( + u64::MAX, + MemoryCeilingScope::PerInstance, + None, + )), + known(population(2)), + known(HostIngressMemoryAccounting::CountsTowardCeiling), + ); + let multiplication_envelope = + MemoryEnvelope::new(MemoryCeilingScope::PerInstance, 0, u64::MAX, None); + + assert_eq!( + multiplication_metadata.validate_memory_envelope(multiplication_envelope), + Err(crate::MemoryEnvelopeValidationError::ArithmeticOverflow { + fixed_bytes: 0, + max_live_requests: Some(population(2).max_live_requests()), + per_live_request_bytes: u64::MAX, + }) + ); + } + + #[test] + fn validation_rejects_scope_mismatch() { + let metadata = PlatformMetadata::new( + known(MemoryCeiling::new( + 100, + MemoryCeilingScope::PerExecution, + None, + )), + known(population(1)), + known(HostIngressMemoryAccounting::OutsideCeiling), + ); + let envelope = MemoryEnvelope::new(MemoryCeilingScope::PerInstance, 20, 10, None); + + assert_eq!( + metadata.validate_memory_envelope(envelope), + Err(crate::MemoryEnvelopeValidationError::ScopeMismatch { + ceiling_scope: MemoryCeilingScope::PerExecution, + envelope_scope: MemoryCeilingScope::PerInstance, + }) + ); + } + + #[test] + fn validation_rejects_stack_shape_mismatch() { + let metadata = PlatformMetadata::new( + known(MemoryCeiling::new( + 100, + MemoryCeilingScope::PerExecution, + None, + )), + known(population(1)), + known(HostIngressMemoryAccounting::OutsideCeiling), + ); + let envelope = MemoryEnvelope::new(MemoryCeilingScope::PerExecution, 20, 10, Some(5)); + + assert_eq!( + metadata.validate_memory_envelope(envelope), + Err( + crate::MemoryEnvelopeValidationError::UnexpectedSeparateStackRequirement { + required_bytes: 5, + }, + ) + ); + } + + #[test] + fn validation_rejects_unknown_population_lower_bound_overflow() { + let metadata = PlatformMetadata::new( + known(MemoryCeiling::new( + u64::MAX, + MemoryCeilingScope::PerInstance, + None, + )), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + known(HostIngressMemoryAccounting::OutsideCeiling), + ); + let envelope = MemoryEnvelope::new(MemoryCeilingScope::PerInstance, u64::MAX, 1, None); + + assert_eq!( + metadata.validate_memory_envelope(envelope), + Err(crate::MemoryEnvelopeValidationError::ArithmeticOverflow { + fixed_bytes: u64::MAX, + max_live_requests: None, + per_live_request_bytes: 1, + }) + ); + } + + #[test] + fn validation_reports_primary_or_stack_excess() { + let metadata = PlatformMetadata::new( + known(MemoryCeiling::new( + 100, + MemoryCeilingScope::PerExecution, + Some(20), + )), + known(population(1)), + known(HostIngressMemoryAccounting::CountsTowardCeiling), + ); + + assert_eq!( + metadata.validate_memory_envelope(MemoryEnvelope::new( + MemoryCeilingScope::PerExecution, + 80, + 30, + Some(15), + )), + Ok(crate::MemoryEnvelopeValidation::Exceeds { + available_bytes: 100, + required_bytes: 110, + resource: crate::MemoryResource::Primary, + }) + ); + assert_eq!( + metadata.validate_memory_envelope(MemoryEnvelope::new( + MemoryCeilingScope::PerExecution, + 40, + 30, + Some(21), + )), + Ok(crate::MemoryEnvelopeValidation::Exceeds { + available_bytes: 20, + required_bytes: 21, + resource: crate::MemoryResource::SeparateStack, + }) + ); + assert_eq!( + metadata.validate_memory_envelope(MemoryEnvelope::new( + MemoryCeilingScope::PerExecution, + 80, + 30, + Some(21), + )), + Ok(crate::MemoryEnvelopeValidation::Exceeds { + available_bytes: 100, + required_bytes: 110, + resource: crate::MemoryResource::Primary, + }) + ); + } + + #[test] + fn validation_unknown_ceiling_is_indeterminate() { + let metadata = PlatformMetadata::new( + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + PlatformFact::unknown(PlatformUnknownReason::RuntimeConfigured), + PlatformFact::unknown(PlatformUnknownReason::OperatorConfigured), + ); + let envelope = MemoryEnvelope::new(MemoryCeilingScope::PerInstance, 10, 20, None); + + assert_eq!( + metadata.validate_memory_envelope(envelope), + Ok(crate::MemoryEnvelopeValidation::Indeterminate { + available_primary_bytes: None, + minimum_required_primary_bytes: 30, + reasons: vec![ + crate::MemoryValidationIndeterminateReason::UnknownMemoryCeiling { + reason: PlatformUnknownReason::ProviderUnpublished, + }, + crate::MemoryValidationIndeterminateReason::UnknownInboundRequestPopulation { + reason: PlatformUnknownReason::RuntimeConfigured, + }, + crate::MemoryValidationIndeterminateReason::UnknownHostIngressMemoryAccounting { + reason: PlatformUnknownReason::OperatorConfigured, + }, + ], + }) + ); + } + + #[test] + fn validation_unknown_host_accounting_is_indeterminate() { + let metadata = PlatformMetadata::new( + known(MemoryCeiling::new( + 100, + MemoryCeilingScope::PerExecution, + None, + )), + known(population(1)), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + ); + let envelope = MemoryEnvelope::new(MemoryCeilingScope::PerExecution, 20, 10, None); + + assert_eq!( + metadata.validate_memory_envelope(envelope), + Ok(crate::MemoryEnvelopeValidation::Indeterminate { + available_primary_bytes: Some(100), + minimum_required_primary_bytes: 30, + reasons: vec![ + crate::MemoryValidationIndeterminateReason::UnknownHostIngressMemoryAccounting { + reason: PlatformUnknownReason::ProviderUnpublished, + }, + ], + }) + ); + } + + #[test] + fn validation_unknown_population_is_indeterminate() { + let metadata = PlatformMetadata::new( + known(MemoryCeiling::new( + 100, + MemoryCeilingScope::PerInstance, + Some(20), + )), + PlatformFact::unknown(PlatformUnknownReason::ProviderUnpublished), + PlatformFact::unknown(PlatformUnknownReason::RuntimeConfigured), + ); + let envelope = MemoryEnvelope::new(MemoryCeilingScope::PerInstance, 10, 20, None); + + assert_eq!( + metadata.validate_memory_envelope(envelope), + Ok(crate::MemoryEnvelopeValidation::Indeterminate { + available_primary_bytes: Some(100), + minimum_required_primary_bytes: 30, + reasons: vec![ + crate::MemoryValidationIndeterminateReason::UnknownInboundRequestPopulation { + reason: PlatformUnknownReason::ProviderUnpublished, + }, + crate::MemoryValidationIndeterminateReason::UnknownHostIngressMemoryAccounting { + reason: PlatformUnknownReason::RuntimeConfigured, + }, + crate::MemoryValidationIndeterminateReason::MissingSeparateStackRequirement, + ], + }) + ); + } +} diff --git a/crates/edgezero-core/src/proxy.rs b/crates/edgezero-core/src/proxy.rs deleted file mode 100644 index 1a5537772..000000000 --- a/crates/edgezero-core/src/proxy.rs +++ /dev/null @@ -1,656 +0,0 @@ -use std::fmt; -use std::sync::Arc; - -use async_trait::async_trait; - -use crate::body::Body; -use crate::error::EdgeError; -use crate::http::{ - Extensions, HeaderMap, Method, Request, Response, StatusCode, Uri, response_builder, -}; - -/// Header name attached to proxied responses to identify which adapter -/// forwarded the request (e.g. "fastly", "cloudflare", "spin"). -pub const PROXY_HEADER: &str = "x-edgezero-proxy"; - -#[async_trait(?Send)] -pub trait ProxyClient: Send + Sync { - async fn send(&self, request: ProxyRequest) -> Result; -} - -#[derive(Clone)] -pub struct ProxyHandle { - client: Arc, -} - -impl ProxyHandle { - #[must_use] - #[inline] - pub fn client(&self) -> Arc { - Arc::clone(&self.client) - } - - /// # Errors - /// Returns [`EdgeError`] if the underlying [`ProxyClient`] fails or the - /// response cannot be assembled. - #[inline] - pub async fn forward(&self, request: ProxyRequest) -> Result { - let response = self.client.send(request).await?; - response.into_response() - } - - #[inline] - pub fn new(client: Arc) -> Self { - Self { client } - } - - #[inline] - pub fn with_client(client: C) -> Self - where - C: ProxyClient + 'static, - { - Self { - client: Arc::new(client), - } - } -} - -/// Outbound request description for a proxy operation. -pub struct ProxyRequest { - body: Body, - extensions: Extensions, - headers: HeaderMap, - method: Method, - uri: Uri, -} - -impl fmt::Debug for ProxyRequest { - #[inline] - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ProxyRequest") - .field("method", &self.method) - .field("uri", &self.uri) - .field("headers", &self.headers) - .finish_non_exhaustive() - } -} - -impl ProxyRequest { - #[inline] - pub fn body(&self) -> &Body { - &self.body - } - - #[inline] - pub fn body_mut(&mut self) -> &mut Body { - &mut self.body - } - - #[inline] - pub fn extensions(&self) -> &Extensions { - &self.extensions - } - - #[inline] - pub fn extensions_mut(&mut self) -> &mut Extensions { - &mut self.extensions - } - - #[inline] - pub fn from_request(request: Request, uri: Uri) -> Self { - let (parts, body) = request.into_parts(); - Self { - body, - extensions: parts.extensions, - headers: parts.headers, - method: parts.method, - uri, - } - } - - #[inline] - pub fn headers(&self) -> &HeaderMap { - &self.headers - } - - #[inline] - pub fn headers_mut(&mut self) -> &mut HeaderMap { - &mut self.headers - } - - #[inline] - pub fn into_parts(self) -> (Method, Uri, HeaderMap, Body, Extensions) { - ( - self.method, - self.uri, - self.headers, - self.body, - self.extensions, - ) - } - - #[inline] - pub fn method(&self) -> &Method { - &self.method - } - - #[inline] - pub fn new(method: Method, uri: Uri) -> Self { - Self { - body: Body::empty(), - extensions: Extensions::new(), - headers: HeaderMap::new(), - method, - uri, - } - } - - #[inline] - pub fn uri(&self) -> &Uri { - &self.uri - } -} - -pub struct ProxyResponse { - body: Body, - extensions: Extensions, - headers: HeaderMap, - status: StatusCode, -} - -impl fmt::Debug for ProxyResponse { - #[inline] - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ProxyResponse") - .field("status", &self.status) - .finish_non_exhaustive() - } -} - -impl ProxyResponse { - #[inline] - pub fn body(&self) -> &Body { - &self.body - } - - #[inline] - pub fn body_mut(&mut self) -> &mut Body { - &mut self.body - } - - #[inline] - pub fn extensions(&self) -> &Extensions { - &self.extensions - } - - #[inline] - pub fn extensions_mut(&mut self) -> &mut Extensions { - &mut self.extensions - } - - #[inline] - pub fn headers(&self) -> &HeaderMap { - &self.headers - } - - #[inline] - pub fn headers_mut(&mut self) -> &mut HeaderMap { - &mut self.headers - } - - /// # Errors - /// Returns [`EdgeError::internal`] if the underlying `http::Response::builder()` - /// rejects a header — should be unreachable since we only store names/values - /// that were already validated, but propagation lets a faulty upstream stream - /// fail the request instead of crashing the worker. - #[inline] - pub fn into_response(self) -> Result { - let mut builder = response_builder().status(self.status); - for (name, value) in &self.headers { - builder = builder.header(name, value); - } - builder.body(self.body).map_err(EdgeError::internal) - } - - #[inline] - pub fn new(status: StatusCode, body: Body) -> Self { - Self { - body, - extensions: Extensions::new(), - headers: HeaderMap::new(), - status, - } - } - - #[inline] - pub fn status(&self) -> StatusCode { - self.status - } -} - -pub struct ProxyService { - client: C, -} - -impl ProxyService { - #[inline] - pub fn new(client: C) -> Self { - Self { client } - } -} - -impl ProxyService -where - C: ProxyClient, -{ - /// # Errors - /// Returns [`EdgeError`] if the underlying [`ProxyClient`] fails or the - /// response cannot be assembled. - #[inline] - pub async fn forward(&self, request: ProxyRequest) -> Result { - let response = self.client.send(request).await?; - response.into_response() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::body::Body; - use crate::http::header::HeaderName; - use crate::http::{HeaderValue, Method, StatusCode, Uri, request_builder}; - use bytes::Bytes; - use futures::executor::block_on; - use futures_util::{StreamExt as _, stream}; - - struct EchoBodyClient; - - struct EchoHeadersClient; - - struct EchoMethodClient; - - struct ErrorClient; - - struct StreamingClient; - - struct TestClient; - - #[async_trait(?Send)] - impl ProxyClient for EchoBodyClient { - async fn send(&self, request: ProxyRequest) -> Result { - let (_, _, _, body, _) = request.into_parts(); - Ok(ProxyResponse::new(StatusCode::OK, body)) - } - } - - #[async_trait(?Send)] - impl ProxyClient for EchoHeadersClient { - async fn send(&self, request: ProxyRequest) -> Result { - let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); - // Echo back headers with x-echo- prefix - for (name, value) in request.headers() { - let echo_name = format!("x-echo-{}", name.as_str()); - if let Ok(header_name) = echo_name.parse::() { - resp.headers_mut().insert(header_name, value.clone()); - } - } - Ok(resp) - } - } - - #[async_trait(?Send)] - impl ProxyClient for EchoMethodClient { - async fn send(&self, request: ProxyRequest) -> Result { - let method_str = request.method().as_str(); - Ok(ProxyResponse::new( - StatusCode::OK, - Body::from(method_str.to_owned()), - )) - } - } - - #[async_trait(?Send)] - impl ProxyClient for ErrorClient { - async fn send(&self, _request: ProxyRequest) -> Result { - Err(EdgeError::bad_request("connection failed")) - } - } - - #[async_trait(?Send)] - impl ProxyClient for StreamingClient { - async fn send(&self, request: ProxyRequest) -> Result { - let (_method, _uri, _headers, _body, _ext) = request.into_parts(); - let chunks = stream::iter(vec![ - Bytes::from_static(b"stream-one"), - Bytes::from_static(b"stream-two"), - ]); - Ok(ProxyResponse::new(StatusCode::OK, Body::stream(chunks))) - } - } - - #[async_trait(?Send)] - impl ProxyClient for TestClient { - async fn send(&self, request: ProxyRequest) -> Result { - let (method, uri, headers, _body, _) = request.into_parts(); - assert_eq!(method, Method::GET); - assert_eq!(uri, Uri::from_static("https://example.com")); - assert_eq!( - headers.get("x-demo"), - Some(&HeaderValue::from_static("true")) - ); - - let chunks = stream::iter(vec![ - Bytes::from_static(b"hello"), - Bytes::from_static(b" world"), - ]); - Ok(ProxyResponse::new(StatusCode::OK, Body::stream(chunks))) - } - } - - fn collect_body(body: Body) -> Vec { - match body { - Body::Once(bytes) => bytes.to_vec(), - Body::Stream(mut stream) => block_on(async { - let mut data = Vec::new(); - while let Some(result) = stream.next().await { - let chunk = result.expect("chunk"); - data.extend_from_slice(&chunk); - } - data - }), - } - } - - #[test] - fn proxy_forward_preserves_streaming_body() { - let request = request_builder() - .method(Method::GET) - .uri("/local-stream") - .body(Body::empty()) - .expect("request"); - - let target = Uri::from_static("https://example.com/stream"); - let proxy_request = ProxyRequest::from_request(request, target); - let service = ProxyService::new(StreamingClient); - let response = block_on(service.forward(proxy_request)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); - - let body = response.into_body(); - let collected = collect_body(body); - assert_eq!(collected, b"stream-onestream-two"); - } - - #[test] - fn proxy_forward_roundtrips() { - let request = request_builder() - .method(Method::GET) - .uri("/local") - .header("x-demo", "true") - .body(Body::empty()) - .expect("request"); - - let target = Uri::from_static("https://example.com"); - let proxy_request = ProxyRequest::from_request(request, target); - let service = ProxyService::new(TestClient); - let response = block_on(service.forward(proxy_request)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); - } - - #[test] - fn proxy_forwards_request_body() { - let service = ProxyService::new(EchoBodyClient); - let request = request_builder() - .method(Method::POST) - .uri("/test") - .body(Body::from("request body content")) - .expect("request"); - - let proxy_req = - ProxyRequest::from_request(request, Uri::from_static("https://example.com")); - let response = block_on(service.forward(proxy_req)).expect("response"); - - let body_bytes = collect_body(response.into_body()); - assert_eq!(body_bytes, b"request body content"); - } - - #[test] - fn proxy_forwards_request_headers() { - let service = ProxyService::new(EchoHeadersClient); - let request = request_builder() - .method(Method::GET) - .uri("/test") - .header("x-custom-header", "custom-value") - .header("authorization", "Bearer token123") - .body(Body::empty()) - .expect("request"); - - let proxy_req = - ProxyRequest::from_request(request, Uri::from_static("https://example.com")); - let response = block_on(service.forward(proxy_req)).expect("response"); - - assert_eq!( - response - .headers() - .get("x-echo-x-custom-header") - .and_then(|value| value.to_str().ok()), - Some("custom-value") - ); - assert_eq!( - response - .headers() - .get("x-echo-authorization") - .and_then(|value| value.to_str().ok()), - Some("Bearer token123") - ); - } - - #[test] - fn proxy_forwards_various_methods() { - let service = ProxyService::new(EchoMethodClient); - - for method in [ - Method::GET, - Method::POST, - Method::PUT, - Method::DELETE, - Method::PATCH, - Method::HEAD, - Method::OPTIONS, - ] { - let req = ProxyRequest::new(method.clone(), Uri::from_static("https://example.com")); - let response = block_on(service.forward(req)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); - } - } - - #[test] - fn proxy_handle_forward_returns_response() { - let handle = ProxyHandle::with_client(TestClient); - let request = request_builder() - .method(Method::GET) - .uri("/test") - .header("x-demo", "true") - .body(Body::empty()) - .expect("request"); - - let proxy_req = - ProxyRequest::from_request(request, Uri::from_static("https://example.com")); - let response = block_on(handle.forward(proxy_req)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); - } - - #[test] - fn proxy_handle_new_wraps_client() { - let client = Arc::new(TestClient); - let handle = ProxyHandle::new(client); - assert!(Arc::strong_count(&handle.client()) >= 1); - } - - #[test] - fn proxy_handle_propagates_client_errors() { - let handle = ProxyHandle::with_client(ErrorClient); - let req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - block_on(handle.forward(req)).expect_err("ErrorClient propagates an error"); - } - - #[test] - fn proxy_handle_with_client_creates_arc() { - let handle = ProxyHandle::with_client(TestClient); - assert!(Arc::strong_count(&handle.client()) >= 1); - } - - #[test] - fn proxy_request_body_mut_allows_modification() { - let mut req = ProxyRequest::new(Method::POST, Uri::from_static("https://example.com")); - *req.body_mut() = Body::from("new body content"); - assert!(matches!( - req.body(), - Body::Once(bytes) if bytes.as_ref() == b"new body content" - )); - } - - #[test] - fn proxy_request_debug_format() { - let mut req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - req.headers_mut() - .insert("x-debug", HeaderValue::from_static("test")); - let debug = format!("{req:?}"); - assert!(debug.contains("ProxyRequest")); - assert!(debug.contains("GET")); - assert!(debug.contains("example.com")); - } - - #[test] - fn proxy_request_extensions_mut_allows_modification() { - let mut req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - req.extensions_mut().insert("custom-data".to_owned()); - assert_eq!( - req.extensions().get::(), - Some(&"custom-data".to_owned()) - ); - } - - #[test] - fn proxy_request_from_request_preserves_all_parts() { - let request = request_builder() - .method(Method::POST) - .uri("/original") - .header("x-custom", "value") - .body(Body::from("request body")) - .expect("request"); - - let target = Uri::from_static("https://backend.example.com/api"); - let proxy_req = ProxyRequest::from_request(request, target.clone()); - - assert_eq!(proxy_req.method(), &Method::POST); - assert_eq!(proxy_req.uri(), &target); - assert_eq!( - proxy_req - .headers() - .get("x-custom") - .and_then(|value| value.to_str().ok()), - Some("value") - ); - } - - #[test] - fn proxy_request_headers_mut_allows_modification() { - let mut req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - req.headers_mut() - .insert("authorization", HeaderValue::from_static("Bearer token")); - assert!(req.headers().get("authorization").is_some()); - } - - #[test] - fn proxy_request_into_parts_destructures() { - let mut req = ProxyRequest::new( - Method::DELETE, - Uri::from_static("https://example.com/resource"), - ); - req.headers_mut() - .insert("x-test", HeaderValue::from_static("value")); - *req.body_mut() = Body::from("body"); - - let (method, uri, headers, body, _extensions) = req.into_parts(); - assert_eq!(method, Method::DELETE); - assert_eq!(uri, Uri::from_static("https://example.com/resource")); - assert!(headers.get("x-test").is_some()); - assert!(matches!( - &body, - Body::Once(bytes) if bytes.as_ref() == b"body" - )); - } - - #[test] - fn proxy_request_new_creates_empty_request() { - let req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - assert_eq!(req.method(), &Method::GET); - assert_eq!(req.uri(), &Uri::from_static("https://example.com")); - assert!(req.headers().is_empty()); - assert!(matches!(req.body(), Body::Once(bytes) if bytes.is_empty())); - } - - #[test] - fn proxy_response_body_mut_allows_modification() { - let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); - *resp.body_mut() = Body::from("updated body"); - assert!(matches!( - resp.body(), - Body::Once(bytes) if bytes.as_ref() == b"updated body" - )); - } - - #[test] - fn proxy_response_debug_format() { - let resp = ProxyResponse::new(StatusCode::NOT_FOUND, Body::empty()); - let debug = format!("{resp:?}"); - assert!(debug.contains("ProxyResponse")); - assert!(debug.contains("404")); - } - - #[test] - fn proxy_response_extensions_mut_allows_modification() { - let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); - resp.extensions_mut().insert(42_i32); - assert_eq!(resp.extensions().get::(), Some(&42_i32)); - } - - #[test] - fn proxy_response_headers_mut_allows_modification() { - let mut resp = ProxyResponse::new(StatusCode::OK, Body::empty()); - resp.headers_mut() - .insert("content-type", HeaderValue::from_static("application/json")); - assert!(resp.headers().get("content-type").is_some()); - } - - #[test] - fn proxy_response_into_response_converts() { - let mut resp = ProxyResponse::new(StatusCode::CREATED, Body::from("created")); - resp.headers_mut() - .insert("x-custom", HeaderValue::from_static("header")); - - let http_resp = resp.into_response().expect("response"); - assert_eq!(http_resp.status(), StatusCode::CREATED); - assert!(http_resp.headers().get("x-custom").is_some()); - } - - #[test] - fn proxy_response_new_creates_response() { - let resp = ProxyResponse::new(StatusCode::OK, Body::from("response body")); - assert_eq!(resp.status(), StatusCode::OK); - assert!(matches!( - resp.body(), - Body::Once(bytes) if bytes.as_ref() == b"response body" - )); - } - - #[test] - fn proxy_service_propagates_client_errors() { - let service = ProxyService::new(ErrorClient); - let req = ProxyRequest::new(Method::GET, Uri::from_static("https://example.com")); - let result = block_on(service.forward(req)); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert_eq!(err.status(), StatusCode::BAD_REQUEST); - } -} diff --git a/crates/edgezero-core/src/response_egress.rs b/crates/edgezero-core/src/response_egress.rs new file mode 100644 index 000000000..f7857b722 --- /dev/null +++ b/crates/edgezero-core/src/response_egress.rs @@ -0,0 +1,2221 @@ +//! Portable response-egress policy and exactly-once lifecycle reporting. + +use std::fmt; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::Duration; + +use crate::http::{HeaderMap, Method, Response, StatusCode, Version}; +use crate::response_egress_framing::{PreparedResponseEgress, prepare_response_egress}; +use crate::router::RouteMetadata; +use crate::time::{DEADLINE_FAR_FUTURE, Deadline, MonotonicClock, MonotonicInstant}; + +/// Portable response-write budget used when an application installs no policy. +pub const DEFAULT_RESPONSE_WRITE_BUDGET: Duration = Duration::from_secs(30); + +/// Internal safety budget used only when transmitting a bounded precommit fallback. +pub const RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET: Duration = Duration::from_secs(1); + +/// Bounded error metadata visible to the detached-egress decision factory. +/// +/// Raw parser failures that occur before `EdgeZero` captures this metadata remain outside the +/// portable response-egress lifecycle. +#[derive(Clone, Copy, Debug)] +pub struct DetachedResponseEgressHead<'head> { + error_kind: &'static str, + request_method: &'head Method, + request_start: MonotonicInstant, + status: StatusCode, +} + +impl<'head> DetachedResponseEgressHead<'head> { + /// Returns the stable public error category without diagnostic text. + #[must_use] + #[inline] + pub const fn error_kind(&self) -> &'static str { + self.error_kind + } + + #[must_use] + #[inline] + pub(crate) const fn new( + request_method: &'head Method, + request_start: MonotonicInstant, + status: StatusCode, + error_kind: &'static str, + ) -> Self { + Self { + error_kind, + request_method, + request_start, + status, + } + } + + /// Returns the request method captured before body polling. + #[must_use] + #[inline] + pub const fn request_method(&self) -> &'head Method { + self.request_method + } + + /// Returns the request start in the application's monotonic clock domain. + #[must_use] + #[inline] + pub const fn request_start(&self) -> MonotonicInstant { + self.request_start + } + + /// Returns the status of the bounded error response. + #[must_use] + #[inline] + pub const fn status(&self) -> StatusCode { + self.status + } + + /// Builds a response-write deadline relative to this head's captured request start. + /// + /// The duration is clamped to [`DEADLINE_FAR_FUTURE`]. Arithmetic overflow fails closed by + /// returning a deadline at the captured request start. + #[must_use] + #[inline] + pub fn write_deadline_after(&self, duration: Duration) -> Deadline { + let bounded = duration.min(DEADLINE_FAR_FUTURE); + Deadline::at_instant( + self.request_start + .checked_add(bounded) + .unwrap_or(self.request_start), + ) + } +} + +/// Application decision for a normalized ingress error before response construction. +#[non_exhaustive] +pub enum DetachedResponseEgressDecision { + /// Terminates the request through the adapter's non-response abort/error boundary. + Abort, + /// Constructs detached egress and retains the supplied completion until terminal delivery. + Send { + completion: ResponseEgressCompletion, + deadline: Deadline, + }, +} + +/// Application-selected upper bound for one response's egress lifetime. +/// +/// Insert this value into the response extensions before returning the response. `EdgeZero` +/// resolves it when egress begins, exposes the resulting absolute deadline to the egress policy +/// callback, and enforces the earlier of that deadline and the callback-selected write deadline. +#[derive(Clone, Copy, Debug)] +pub struct ResponseEgressDeadline(ResponseEgressDeadlineKind); + +#[derive(Clone, Copy, Debug)] +enum ResponseEgressDeadlineKind { + After(Duration), + At(Deadline), +} + +impl ResponseEgressDeadline { + /// Selects a deadline relative to the instant response egress begins. + /// + /// The duration is clamped to [`DEADLINE_FAR_FUTURE`]. Arithmetic overflow fails closed by + /// resolving to the sampled egress-start instant. + #[must_use] + #[inline] + pub const fn after(duration: Duration) -> Self { + Self(ResponseEgressDeadlineKind::After(duration)) + } + + /// Selects an absolute deadline in the application's monotonic clock domain. + #[must_use] + #[inline] + pub const fn at(deadline: Deadline) -> Self { + Self(ResponseEgressDeadlineKind::At(deadline)) + } + + #[must_use] + #[inline] + fn resolve_at(self, egress_started_at: MonotonicInstant) -> Deadline { + match self.0 { + ResponseEgressDeadlineKind::At(deadline) => deadline, + ResponseEgressDeadlineKind::After(duration) => Deadline::at_instant( + egress_started_at + .checked_add(duration.min(DEADLINE_FAR_FUTURE)) + .unwrap_or(egress_started_at), + ), + } + } +} + +/// One finite absolute deadline selected for a response-conversion attempt. +#[derive(Clone, Copy, Debug)] +pub struct ResponseEgressPolicy { + pub write_deadline: Deadline, +} + +impl ResponseEgressPolicy { + /// Clamps this policy to the portable far-future bound relative to egress start. + /// + /// An already-expired deadline remains valid and is not moved forward. + /// + /// # Errors + /// Returns [`ResponseEgressOutcome::ConversionError`] if the clamp instant cannot be + /// represented by the monotonic clock. + #[inline] + pub fn normalize_at( + mut self, + egress_started_at: MonotonicInstant, + ) -> Result { + let maximum = egress_started_at + .checked_add(DEADLINE_FAR_FUTURE) + .ok_or(ResponseEgressOutcome::ConversionError)?; + if self.write_deadline.instant() > maximum { + self.write_deadline = Deadline::at_instant(maximum); + } + Ok(self) + } +} + +/// Immutable response metadata visible to the synchronous egress policy callback. +/// +/// This type deliberately has no response-body or mutation access. +#[derive(Clone, Copy, Debug)] +pub struct ResponseEgressHead<'head> { + application_deadline: Option, + headers: &'head HeaderMap, + request_start: MonotonicInstant, + route: Option<&'head RouteMetadata>, + status: StatusCode, + version: Version, +} + +impl<'head> ResponseEgressHead<'head> { + /// Returns the response-owned deadline after resolving it to an absolute instant. + #[must_use] + #[inline] + pub fn application_deadline(&self) -> Option { + self.application_deadline + } + + #[must_use] + #[inline] + pub fn headers(&self) -> &'head HeaderMap { + self.headers + } + + /// Creates a body-blind response-head view for an adapter conversion attempt. + #[must_use] + #[inline] + pub fn new( + status: StatusCode, + version: Version, + headers: &'head HeaderMap, + application_deadline: Option, + request_start: MonotonicInstant, + route: Option<&'head RouteMetadata>, + ) -> Self { + Self { + application_deadline, + headers, + request_start, + route, + status, + version, + } + } + + #[must_use] + #[inline] + pub fn request_start(&self) -> MonotonicInstant { + self.request_start + } + + #[must_use] + #[inline] + pub fn route(&self) -> Option<&'head RouteMetadata> { + self.route + } + + #[must_use] + #[inline] + pub fn status(&self) -> StatusCode { + self.status + } + + #[must_use] + #[inline] + pub fn version(&self) -> Version { + self.version + } +} + +/// Synchronous, body-blind policy callback retained by an application. +pub type ResponseEgressPolicyCallback = Arc< + dyn for<'head> Fn(&ResponseEgressHead<'head>, MonotonicInstant) -> ResponseEgressPolicy + + Send + + Sync + + 'static, +>; + +/// Terminal result of one response-conversion attempt. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ResponseEgressOutcome { + ClientDisconnected, + Completed, + ConversionError, + DeadlineExceeded, + HostHandoff, + RequestCancelled, + SourceError, + TransportError, + Unspecified, +} + +/// Identifies which bounded response body contributed the reported byte count. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ResponseEgressBodyKind { + Application, + Fallback, +} + +/// Records whether a selected fallback reached its finish boundary. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ResponseEgressFallbackDisposition { + Aborted, + Completed, +} + +/// Bounded terminal report for one response-conversion attempt. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResponseEgressReport { + pub body_kind: ResponseEgressBodyKind, + pub bytes_written: u64, + pub elapsed: Duration, + pub fallback_disposition: Option, + pub outcome: ResponseEgressOutcome, + pub request_start: MonotonicInstant, + pub route: Option, +} + +type ResponseEgressCompletionCallback = Box; + +/// Non-clone owner of one response-scoped terminal callback. +/// +/// The callback moves with the ingress/egress lifecycle and runs at most once. Use [`Self::empty`] +/// when no application resource needs to remain live through response transmission. +/// +/// ```compile_fail +/// use edgezero_core::ResponseEgressCompletion; +/// +/// let completion = ResponseEgressCompletion::empty(); +/// let _duplicate = completion.clone(); +/// ``` +/// +/// ```compile_fail +/// use edgezero_core::{Extensions, ResponseEgressCompletion}; +/// +/// let mut extensions = Extensions::new(); +/// extensions.insert(ResponseEgressCompletion::empty()); +/// ``` +pub struct ResponseEgressCompletion { + callback: Option, +} + +/// Non-clone installer for one application resource retained through response egress. +/// +/// Create a paired installer and completion with [`ResponseEgressCompletion::late_bound`]. The +/// installer remains usable after the completion closes only to report [`ResponseEgressResourceInstallError::Closed`]. +/// +/// ```compile_fail +/// use edgezero_core::ResponseEgressCompletion; +/// +/// let (resource, _completion) = ResponseEgressCompletion::late_bound::>(); +/// let _duplicate = resource.clone(); +/// ``` +pub struct ResponseEgressResource { + state: Arc>>, +} + +impl fmt::Debug for ResponseEgressResource { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ResponseEgressResource") + .finish_non_exhaustive() + } +} + +impl ResponseEgressResource +where + T: Send + 'static, +{ + /// Installs the response-scoped resource exactly once. + /// + /// A rejected resource is dropped before this method returns and is never returned to the + /// caller. `Closed` covers both terminal completion and completion abandonment. + /// + /// # Errors + /// + /// Returns [`ResponseEgressResourceInstallError::AlreadyInstalled`] when the slot already + /// owns a resource, or [`ResponseEgressResourceInstallError::Closed`] after terminal egress or + /// completion abandonment. + #[inline] + pub fn install(&self, resource: T) -> Result<(), ResponseEgressResourceInstallError> { + let mut candidate = Some(resource); + let install_error = { + let mut state = self.state(); + if state.closed { + Some(ResponseEgressResourceInstallError::Closed) + } else if state.resource.is_some() { + Some(ResponseEgressResourceInstallError::AlreadyInstalled) + } else { + state.resource = candidate.take(); + None + } + }; + drop(candidate); + match install_error { + Some(rejection) => Err(rejection), + None => Ok(()), + } + } + + fn state(&self) -> MutexGuard<'_, ResponseEgressResourceState> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +/// Stable reason a late-bound response-egress resource was rejected. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] +pub enum ResponseEgressResourceInstallError { + #[error("a response-egress resource is already installed")] + AlreadyInstalled, + #[error("the response-egress resource owner is closed")] + Closed, +} + +struct ResponseEgressResourceOwner { + state: Option>>>, +} + +impl ResponseEgressResourceOwner { + fn close(mut self) { + let Some(state) = self.state.take() else { + return; + }; + close_response_egress_resource(&state); + } +} + +impl Drop for ResponseEgressResourceOwner { + fn drop(&mut self) { + let Some(state) = self.state.take() else { + return; + }; + close_response_egress_resource(&state); + } +} + +struct ResponseEgressResourceState { + closed: bool, + resource: Option, +} + +impl ResponseEgressCompletion { + fn complete(&mut self, report: &ResponseEgressReport) { + let Some(callback) = self.callback.take() else { + return; + }; + let result = catch_unwind(AssertUnwindSafe(|| callback(report))); + if result.is_err() { + log::error!("response-egress completion panicked after terminal transition"); + } + } + + /// Creates a completion with no response-scoped callback. + #[must_use] + #[inline] + pub const fn empty() -> Self { + Self { callback: None } + } + + /// Joins this completion with `other` into one response-scoped completion. + /// + /// At terminal egress, both callbacks receive the same borrowed report and run at most once + /// in deterministic left-to-right order: this completion first, then `other`. Dropping the + /// joined completion before terminal egress abandons both callbacks and releases resources + /// captured by either callback exactly once. + /// + /// On targets where panics unwind, each callback has an independent panic boundary, so a + /// panic in the left callback does not suppress the right callback. On panic-abort targets, + /// the process may terminate during the left callback before the right callback runs. + #[must_use] + #[inline] + pub fn join(mut self, mut other: Self) -> Self { + Self::new(move |report| { + self.complete(report); + other.complete(report); + }) + } + + /// Creates one late-bound application-resource installer and its paired completion. + /// + /// The completion closes the installer at terminal egress or when abandoned. An installed + /// resource remains live until that close operation and is then dropped exactly once. + #[must_use] + #[inline] + pub fn late_bound() -> (ResponseEgressResource, Self) + where + T: Send + 'static, + { + let state = Arc::new(Mutex::new(ResponseEgressResourceState { + closed: false, + resource: None, + })); + let resource = ResponseEgressResource { + state: Arc::clone(&state), + }; + let owner = ResponseEgressResourceOwner { state: Some(state) }; + let completion = Self::new(move |_report| owner.close()); + (resource, completion) + } + + /// Retains one response-scoped callback until terminal egress. + #[must_use] + #[inline] + pub fn new(complete: Complete) -> Self + where + Complete: FnOnce(&ResponseEgressReport) + Send + 'static, + { + Self { + callback: Some(Box::new(complete)), + } + } +} + +/// Synchronous factory for one detached normalized-ingress-error disposition. +pub type DetachedResponseEgressDecisionFactory = Arc< + dyn for<'head> Fn(&DetachedResponseEgressHead<'head>) -> DetachedResponseEgressDecision + + Send + + Sync + + 'static, +>; + +/// Synchronous observer for terminal response-egress reports. +pub trait ResponseEgressObserver: Send + Sync + 'static { + fn complete(&self, report: &ResponseEgressReport); +} + +struct NoopResponseEgressObserver; + +impl ResponseEgressObserver for NoopResponseEgressObserver { + #[inline] + fn complete(&self, _report: &ResponseEgressReport) {} +} + +/// Cloneable handle to an application response-egress observer. +#[derive(Clone)] +pub struct ResponseEgressObserverHandle { + observer: Arc, +} + +/// Adapter-facing response plus immutable ingress metadata and app-owned egress hooks. +#[doc(hidden)] +pub struct ResponseEgressEnvelope { + clock: MonotonicClock, + completion: ResponseEgressCompletion, + observer: ResponseEgressObserverHandle, + policy: ResponseEgressPolicyCallback, + request_method: Method, + request_start: MonotonicInstant, + response: Response, + route: Option, +} + +pub(crate) struct ResponseEgressRequestMetadata { + request_method: Method, + request_start: MonotonicInstant, + route: Option, +} + +impl ResponseEgressRequestMetadata { + #[inline] + pub(crate) const fn new( + request_method: Method, + request_start: MonotonicInstant, + route: Option, + ) -> Self { + Self { + request_method, + request_start, + route, + } + } +} + +/// Live response-egress state returned when application response preparation fails precommit. +#[doc(hidden)] +pub struct ResponseEgressBeginFailure { + attempt: ResponseEgressAttempt, + cause: ResponseEgressOutcome, + clock: MonotonicClock, +} + +impl ResponseEgressBeginFailure { + /// Consumes the failure into the sole live attempt, its clock, and original cause. + #[must_use] + #[inline] + pub fn into_parts(self) -> (ResponseEgressAttempt, MonotonicClock, ResponseEgressOutcome) { + (self.attempt, self.clock, self.cause) + } +} + +impl ResponseEgressEnvelope { + /// Starts an owned response-egress attempt without pre-terminalizing fallback-eligible errors. + /// + /// # Errors + /// Returns the sole live attempt, its injected clock, and a bounded precommit cause when the + /// policy panics on an unwind-capable target or its deadline cannot be normalized. A target + /// compiled with panic-abort cannot contain application callback panics. + #[inline] + pub fn begin( + mut self, + ) -> Result< + ( + PreparedResponseEgress, + ResponseEgressPolicy, + ResponseEgressAttempt, + MonotonicClock, + ), + Box, + > { + let egress_started_at = self.clock.now(); + let application_deadline = self + .response + .extensions_mut() + .remove::() + .map(|deadline| deadline.resolve_at(egress_started_at)); + let head = ResponseEgressHead::new( + self.response.status(), + self.response.version(), + self.response.headers(), + application_deadline, + self.request_start, + self.route.as_ref(), + ); + let attempt = ResponseEgressAttempt::new( + &head, + egress_started_at, + self.completion, + self.observer, + self.clock.clone(), + ); + let Ok(selected_policy) = + catch_unwind(AssertUnwindSafe(|| (self.policy)(&head, egress_started_at))) + else { + log::error!("response-egress policy panicked before conversion"); + return Err(Box::new(ResponseEgressBeginFailure { + attempt, + cause: ResponseEgressOutcome::ConversionError, + clock: self.clock, + })); + }; + let normalized_policy = match selected_policy.normalize_at(egress_started_at) { + Ok(normalized) => normalized, + Err(outcome) => { + return Err(Box::new(ResponseEgressBeginFailure { + attempt, + cause: outcome, + clock: self.clock, + })); + } + }; + let bounded_policy = ResponseEgressPolicy { + write_deadline: Deadline::at_instant(application_deadline.map_or( + normalized_policy.write_deadline.instant(), + |deadline| { + deadline + .instant() + .min(normalized_policy.write_deadline.instant()) + }, + )), + }; + let Ok(prepared) = prepare_response_egress(&self.request_method, self.response) else { + log::error!("response-egress framing validation failed before commit"); + return Err(Box::new(ResponseEgressBeginFailure { + attempt, + cause: ResponseEgressOutcome::ConversionError, + clock: self.clock, + })); + }; + Ok((prepared, bounded_policy, attempt, self.clock)) + } + + pub(crate) fn detached( + response: Response, + request_start: MonotonicInstant, + request_method: Method, + completion: ResponseEgressCompletion, + clock: MonotonicClock, + ) -> Self { + Self::new( + response, + ResponseEgressRequestMetadata::new(request_method, request_start, None), + completion, + Arc::new(default_response_egress_policy), + ResponseEgressObserverHandle::default(), + clock, + ) + } + + pub(crate) fn new( + response: Response, + metadata: ResponseEgressRequestMetadata, + completion: ResponseEgressCompletion, + policy: ResponseEgressPolicyCallback, + observer: ResponseEgressObserverHandle, + clock: MonotonicClock, + ) -> Self { + Self { + clock, + completion, + observer, + policy, + request_method: metadata.request_method, + request_start: metadata.request_start, + response, + route: metadata.route, + } + } + + /// Returns the original request method used for response framing. + #[must_use] + #[inline] + pub fn request_method(&self) -> &Method { + &self.request_method + } + + /// Borrows the application response before egress policy and framing are evaluated. + #[doc(hidden)] + #[must_use] + #[inline] + pub fn response_mut(&mut self) -> &mut Response { + &mut self.response + } +} + +impl ResponseEgressObserverHandle { + fn complete(&self, report: &ResponseEgressReport) { + let result = catch_unwind(AssertUnwindSafe(|| self.observer.complete(report))); + if result.is_err() { + log::error!("response-egress observer panicked after terminal transition"); + } + } + + #[must_use] + #[inline] + pub fn new(observer: Observer) -> Self + where + Observer: ResponseEgressObserver, + { + Self { + observer: Arc::new(observer), + } + } +} + +impl Default for ResponseEgressObserverHandle { + #[inline] + fn default() -> Self { + Self::new(NoopResponseEgressObserver) + } +} + +enum AttemptState { + Initial, + Terminal(ResponseEgressReport), + Writing, +} + +/// Adapter-facing exactly-once response-egress completion guard. +/// +/// The guard is intentionally non-clone. Dropping it before a terminal signal reports a +/// conversion error from the initial state or a transport error from the writing state. +#[doc(hidden)] +pub struct ResponseEgressAttempt { + body_kind: ResponseEgressBodyKind, + bytes_written: u64, + clock: MonotonicClock, + completion: ResponseEgressCompletion, + egress_started_at: MonotonicInstant, + fallback_cause: Option, + fallback_disposition: Option, + observer: ResponseEgressObserverHandle, + request_start: MonotonicInstant, + route: Option, + state: AttemptState, +} + +impl ResponseEgressAttempt { + /// Accounts payload bytes accepted at the adapter's documented write boundary. + /// + /// Returns `false` outside the writing state. Overflow terminalizes the attempt as + /// [`ResponseEgressOutcome::TransportError`] and also returns `false`. + #[inline] + pub fn account_bytes(&mut self, bytes: u64, observed_at: MonotonicInstant) -> bool { + if !matches!(self.state, AttemptState::Writing) { + return false; + } + let Some(total) = self.bytes_written.checked_add(bytes) else { + self.fail_current_body(ResponseEgressOutcome::TransportError, observed_at); + return false; + }; + self.bytes_written = total; + true + } + + /// Selects the bounded adapter fallback while retaining the original precommit cause. + #[inline] + pub fn begin_fallback(&mut self, cause: ResponseEgressOutcome) -> bool { + if !matches!(self.state, AttemptState::Initial) + || self.fallback_cause.is_some() + || !matches!( + cause, + ResponseEgressOutcome::ConversionError | ResponseEgressOutcome::DeadlineExceeded + ) + { + return false; + } + self.body_kind = ResponseEgressBodyKind::Fallback; + self.fallback_cause = Some(cause); + true + } + + /// Moves an initial attempt into the writing state. + /// + /// Returns `false` after writing has already begun or the attempt is terminal. + #[inline] + pub fn begin_writing(&mut self) -> bool { + if !matches!(self.state, AttemptState::Initial) { + return false; + } + self.state = AttemptState::Writing; + true + } + + /// Successfully completes a writing attempt. + /// + /// Calling this in the initial state terminalizes as `ConversionError`; a converter must + /// enter writing even for an empty response. Later terminal signals return `false`. + #[inline] + pub fn complete(&mut self, observed_at: MonotonicInstant) -> bool { + if self.body_kind == ResponseEgressBodyKind::Fallback { + return false; + } + match self.state { + AttemptState::Initial => { + self.transition(ResponseEgressOutcome::ConversionError, observed_at) + } + AttemptState::Writing => self.transition(ResponseEgressOutcome::Completed, observed_at), + AttemptState::Terminal(_) => false, + } + } + + fn fail_current_body( + &mut self, + application_outcome: ResponseEgressOutcome, + observed_at: MonotonicInstant, + ) -> bool { + if let Some(cause) = self.fallback_cause { + self.fallback_disposition = Some(ResponseEgressFallbackDisposition::Aborted); + self.transition(cause, observed_at) + } else { + self.transition(application_outcome, observed_at) + } + } + + /// Settles a selected fallback while preserving its original precommit cause. + #[inline] + pub fn finish_fallback( + &mut self, + disposition: ResponseEgressFallbackDisposition, + observed_at: MonotonicInstant, + ) -> bool { + let Some(cause) = self.fallback_cause else { + return false; + }; + let valid_state = match disposition { + ResponseEgressFallbackDisposition::Aborted => { + matches!(self.state, AttemptState::Initial | AttemptState::Writing) + } + ResponseEgressFallbackDisposition::Completed => { + matches!(self.state, AttemptState::Writing) + } + }; + if !valid_state { + return false; + } + self.fallback_disposition = Some(disposition); + self.transition(cause, observed_at) + } + + #[must_use] + #[inline] + pub fn new( + head: &ResponseEgressHead<'_>, + egress_started_at: MonotonicInstant, + completion: ResponseEgressCompletion, + observer: ResponseEgressObserverHandle, + clock: MonotonicClock, + ) -> Self { + Self { + body_kind: ResponseEgressBodyKind::Application, + bytes_written: 0, + clock, + completion, + egress_started_at, + fallback_cause: None, + fallback_disposition: None, + observer, + request_start: head.request_start(), + route: head.route().cloned(), + state: AttemptState::Initial, + } + } + + /// Terminalizes an attempt with the supplied outcome. + /// + /// `Completed` follows [`Self::complete`] state validation. + #[inline] + pub fn terminate( + &mut self, + outcome: ResponseEgressOutcome, + observed_at: MonotonicInstant, + ) -> bool { + if self.body_kind == ResponseEgressBodyKind::Fallback { + return false; + } + if matches!( + outcome, + ResponseEgressOutcome::Completed | ResponseEgressOutcome::HostHandoff + ) { + if outcome == ResponseEgressOutcome::HostHandoff + && matches!(self.state, AttemptState::Writing) + { + self.transition(outcome, observed_at) + } else { + self.complete(observed_at) + } + } else { + self.transition(outcome, observed_at) + } + } + + fn transition( + &mut self, + mut outcome: ResponseEgressOutcome, + observed_at: MonotonicInstant, + ) -> bool { + if matches!(self.state, AttemptState::Terminal(_)) { + return false; + } + + let elapsed = + if let Some(elapsed) = observed_at.checked_duration_since(self.egress_started_at) { + elapsed + } else { + log::error!("response-egress monotonic clock moved backwards"); + outcome = ResponseEgressOutcome::Unspecified; + Duration::ZERO + }; + let report = ResponseEgressReport { + bytes_written: self.bytes_written, + body_kind: self.body_kind, + elapsed, + fallback_disposition: self.fallback_disposition, + outcome, + request_start: self.request_start, + route: self.route.clone(), + }; + self.state = AttemptState::Terminal(report); + if let AttemptState::Terminal(terminal_report) = &self.state { + self.completion.complete(terminal_report); + self.observer.complete(terminal_report); + true + } else { + false + } + } +} + +impl Drop for ResponseEgressAttempt { + #[inline] + fn drop(&mut self) { + let outcome = match self.state { + AttemptState::Initial => ResponseEgressOutcome::ConversionError, + AttemptState::Writing => ResponseEgressOutcome::TransportError, + AttemptState::Terminal(_) => return, + }; + self.fail_current_body(outcome, self.clock.now()); + } +} + +fn close_response_egress_resource(shared: &Mutex>) { + let resource = { + let mut state = shared.lock().unwrap_or_else(PoisonError::into_inner); + state.closed = true; + state.resource.take() + }; + drop(resource); +} + +/// Selects the portable default response-write policy. +#[must_use] +#[inline] +pub fn default_response_egress_policy( + _head: &ResponseEgressHead<'_>, + egress_started_at: MonotonicInstant, +) -> ResponseEgressPolicy { + let deadline = egress_started_at + .checked_add(DEFAULT_RESPONSE_WRITE_BUDGET) + .unwrap_or(egress_started_at); + ResponseEgressPolicy { + write_deadline: Deadline::at_instant(deadline), + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::error::Error; + use std::panic::{AssertUnwindSafe, catch_unwind}; + use std::ptr::from_ref; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Barrier, Mutex}; + use std::thread; + use std::time::Duration; + + use super::*; + use crate::body::Body; + use crate::http::{HeaderMap, HeaderValue, Method, StatusCode, Version, response_builder}; + use crate::router::RouteMetadata; + use crate::time::{DEADLINE_FAR_FUTURE, Deadline, MonotonicClock, MonotonicInstant}; + + #[derive(Clone, Default)] + struct RecordingObserver { + reports: Arc>>, + } + + impl RecordingObserver { + fn reports(&self) -> Vec { + self.reports.lock().expect("reports lock").clone() + } + } + + impl ResponseEgressObserver for RecordingObserver { + fn complete(&self, report: &ResponseEgressReport) { + self.reports + .lock() + .expect("reports lock") + .push(report.clone()); + } + } + + struct PanickingObserver; + + impl ResponseEgressObserver for PanickingObserver { + fn complete(&self, _report: &ResponseEgressReport) { + panic!("observer panic"); + } + } + + #[derive(Clone)] + struct OrderedObserver(Arc>>); + + impl ResponseEgressObserver for OrderedObserver { + fn complete(&self, _report: &ResponseEgressReport) { + self.0.lock().expect("ordered events lock").push("observer"); + } + } + + struct CompletionDropProbe(Arc>); + + impl Drop for CompletionDropProbe { + fn drop(&mut self) { + let mut drops = self.0.lock().expect("completion drops lock"); + *drops = (*drops).saturating_add(1); + } + } + + struct ResourceDropProbe(Arc); + + impl Drop for ResourceDropProbe { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + + fn head<'head>( + headers: &'head HeaderMap, + request_start: MonotonicInstant, + route: Option<&'head RouteMetadata>, + ) -> ResponseEgressHead<'head> { + ResponseEgressHead::new( + StatusCode::CREATED, + Version::HTTP_2, + headers, + None, + request_start, + route, + ) + } + + fn completed_report(request_start: MonotonicInstant) -> ResponseEgressReport { + ResponseEgressReport { + body_kind: ResponseEgressBodyKind::Application, + bytes_written: 0, + elapsed: Duration::ZERO, + fallback_disposition: None, + outcome: ResponseEgressOutcome::Completed, + request_start, + route: None, + } + } + + fn attempt( + observer: &RecordingObserver, + started_at: MonotonicInstant, + ) -> ResponseEgressAttempt { + let headers = HeaderMap::new(); + let head = head(&headers, started_at, None); + ResponseEgressAttempt::new( + &head, + started_at, + ResponseEgressCompletion::empty(), + ResponseEgressObserverHandle::new(observer.clone()), + MonotonicClock::default(), + ) + } + + #[test] + fn default_policy_is_finite_and_exactly_thirty_seconds() { + let headers = HeaderMap::new(); + let started_at = MonotonicInstant::now(); + let head = head(&headers, started_at, None); + let policy = default_response_egress_policy(&head, started_at); + + assert_eq!( + policy.write_deadline.instant(), + started_at + .checked_add(DEFAULT_RESPONSE_WRITE_BUDGET) + .expect("default deadline") + ); + assert_eq!(DEFAULT_RESPONSE_WRITE_BUDGET, Duration::from_secs(30)); + } + + #[test] + fn detached_response_egress_head_deadline_is_anchored_to_request_start() { + let request_start = MonotonicInstant::now() + .checked_sub(Duration::from_mins(1)) + .expect("earlier request start"); + let head = DetachedResponseEgressHead::new( + &Method::GET, + request_start, + StatusCode::BAD_REQUEST, + "bad_request", + ); + + assert_eq!( + head.write_deadline_after(Duration::from_secs(2)).instant(), + request_start + .checked_add(Duration::from_secs(2)) + .expect("relative deadline") + ); + } + + #[test] + fn detached_response_egress_head_deadline_clamps_to_far_future() { + let request_start = MonotonicInstant::now(); + let head = DetachedResponseEgressHead::new( + &Method::GET, + request_start, + StatusCode::BAD_REQUEST, + "bad_request", + ); + + assert_eq!( + head.write_deadline_after(Duration::MAX).instant(), + request_start + .checked_add(DEADLINE_FAR_FUTURE) + .expect("maximum deadline") + ); + } + + #[test] + fn detached_response_egress_head_deadline_overflow_fails_closed() { + let origin = MonotonicInstant::now(); + let mut low = 0_u64; + let mut high = u64::MAX; + while low < high { + let midpoint = low + (high - low).div_ceil(2); + if origin.checked_add(Duration::from_secs(midpoint)).is_some() { + low = midpoint; + } else { + high = midpoint - 1; + } + } + let request_start = origin + .checked_add(Duration::from_secs(low)) + .expect("latest representable instant"); + assert!(request_start.checked_add(Duration::from_secs(1)).is_none()); + let head = DetachedResponseEgressHead::new( + &Method::GET, + request_start, + StatusCode::BAD_REQUEST, + "bad_request", + ); + + assert_eq!( + head.write_deadline_after(Duration::from_secs(1)).instant(), + request_start + ); + } + + #[test] + fn policy_normalization_clamps_far_future_and_preserves_expiry() { + let started_at = MonotonicInstant::now(); + let maximum = started_at + .checked_add(DEADLINE_FAR_FUTURE) + .expect("maximum deadline"); + let beyond = maximum + .checked_add(Duration::from_secs(1)) + .expect("deadline beyond maximum"); + let clamped = ResponseEgressPolicy { + write_deadline: Deadline::at_instant(beyond), + } + .normalize_at(started_at) + .expect("normalization"); + assert_eq!(clamped.write_deadline.instant(), maximum); + + let expired = ResponseEgressPolicy { + write_deadline: Deadline::at_instant(started_at), + } + .normalize_at(started_at) + .expect("expired policy remains valid"); + assert_eq!(expired.write_deadline.instant(), started_at); + } + + #[test] + fn head_is_body_blind_and_exposes_only_immutable_metadata() { + let mut headers = HeaderMap::new(); + headers.insert("x-test", HeaderValue::from_static("visible")); + let request_start = MonotonicInstant::now(); + let route = RouteMetadata::new(Method::GET, "/items/{id}"); + let head = head(&headers, request_start, Some(&route)); + + assert_eq!(head.status(), StatusCode::CREATED); + assert_eq!(head.version(), Version::HTTP_2); + assert_eq!(head.headers(), &headers); + assert_eq!(head.request_start(), request_start); + assert_eq!(head.route(), Some(&route)); + } + + #[test] + fn observer_trait_is_object_safe_and_handle_has_a_noop_default() { + fn assert_object_safe(_observer: &dyn ResponseEgressObserver) {} + + let observer = RecordingObserver::default(); + assert_object_safe(&observer); + let _: ResponseEgressObserverHandle = ResponseEgressObserverHandle::default(); + } + + #[test] + fn late_bound_resource_is_send_and_sync_for_send_not_sync_values() { + fn assert_error() {} + fn assert_send_sync() {} + + assert_error::(); + assert_send_sync::>>(); + } + + #[test] + fn late_bound_install_and_close_race_releases_candidate_once() { + let drops = Arc::new(AtomicUsize::new(0)); + let barrier = Arc::new(Barrier::new(2)); + let close_barrier = Arc::clone(&barrier); + let (resource, completion) = ResponseEgressCompletion::late_bound(); + + let install_result = thread::scope(|scope| { + let close = scope.spawn(move || { + close_barrier.wait(); + drop(completion); + }); + barrier.wait(); + let result = resource.install(ResourceDropProbe(Arc::clone(&drops))); + close.join().expect("close thread"); + result + }); + + assert!(matches!( + install_result, + Ok(()) | Err(ResponseEgressResourceInstallError::Closed) + )); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + + #[test] + fn late_bound_poisoned_state_recovers_and_releases_once() { + let drops = Arc::new(AtomicUsize::new(0)); + let (resource, completion) = ResponseEgressCompletion::late_bound(); + let poisoned_state = Arc::clone(&resource.state); + let poison_result = catch_unwind(AssertUnwindSafe(move || { + let _guard = poisoned_state.lock().expect("unpoisoned state"); + panic!("poison response resource state"); + })); + assert!(poison_result.is_err()); + + resource + .install(ResourceDropProbe(Arc::clone(&drops))) + .expect("poison recovery installation"); + drop(completion); + + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + + #[test] + fn late_bound_install_before_terminal_releases_at_terminal_once() { + let drops = Arc::new(AtomicUsize::new(0)); + let (resource, mut completion) = ResponseEgressCompletion::late_bound(); + resource + .install(ResourceDropProbe(Arc::clone(&drops))) + .expect("first installation"); + + assert_eq!(drops.load(Ordering::SeqCst), 0); + completion.complete(&completed_report(MonotonicInstant::now())); + completion.complete(&completed_report(MonotonicInstant::now())); + assert_eq!(drops.load(Ordering::SeqCst), 1); + + drop(completion); + drop(resource); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + + #[test] + fn late_bound_terminal_before_install_rejects_and_releases_candidate() { + let drops = Arc::new(AtomicUsize::new(0)); + let (resource, mut completion) = ResponseEgressCompletion::late_bound(); + completion.complete(&completed_report(MonotonicInstant::now())); + + assert_eq!( + resource.install(ResourceDropProbe(Arc::clone(&drops))), + Err(ResponseEgressResourceInstallError::Closed) + ); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + + #[test] + fn late_bound_duplicate_install_rejects_and_releases_only_candidate() { + let installed_drops = Arc::new(AtomicUsize::new(0)); + let rejected_drops = Arc::new(AtomicUsize::new(0)); + let (resource, completion) = ResponseEgressCompletion::late_bound(); + resource + .install(ResourceDropProbe(Arc::clone(&installed_drops))) + .expect("first installation"); + + assert_eq!( + resource.install(ResourceDropProbe(Arc::clone(&rejected_drops))), + Err(ResponseEgressResourceInstallError::AlreadyInstalled) + ); + assert_eq!(installed_drops.load(Ordering::SeqCst), 0); + assert_eq!(rejected_drops.load(Ordering::SeqCst), 1); + + drop(completion); + assert_eq!(installed_drops.load(Ordering::SeqCst), 1); + } + + #[test] + fn late_bound_abandonment_before_install_closes_surviving_installer() { + let drops = Arc::new(AtomicUsize::new(0)); + let (resource, completion) = ResponseEgressCompletion::late_bound(); + drop(completion); + + assert_eq!( + resource.install(ResourceDropProbe(Arc::clone(&drops))), + Err(ResponseEgressResourceInstallError::Closed) + ); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + + #[test] + fn late_bound_abandonment_after_install_releases_with_surviving_installer() { + let drops = Arc::new(AtomicUsize::new(0)); + let (resource, completion) = ResponseEgressCompletion::late_bound(); + resource + .install(ResourceDropProbe(Arc::clone(&drops))) + .expect("first installation"); + + drop(completion); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!( + resource.install(ResourceDropProbe(Arc::clone(&drops))), + Err(ResponseEgressResourceInstallError::Closed) + ); + assert_eq!(drops.load(Ordering::SeqCst), 2); + } + + #[test] + fn late_bound_competing_terminal_signals_release_once() { + let drops = Arc::new(AtomicUsize::new(0)); + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let headers = HeaderMap::new(); + let head = head(&headers, started_at, None); + let (resource, completion) = ResponseEgressCompletion::late_bound(); + resource + .install(ResourceDropProbe(Arc::clone(&drops))) + .expect("first installation"); + let mut attempt = ResponseEgressAttempt::new( + &head, + started_at, + completion, + ResponseEgressObserverHandle::new(observer.clone()), + MonotonicClock::default(), + ); + + assert!(attempt.begin_writing()); + assert!(attempt.complete(started_at)); + assert!(!attempt.complete(started_at)); + assert!(!attempt.terminate(ResponseEgressOutcome::TransportError, started_at)); + drop(attempt); + + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!(observer.reports().len(), 1); + } + + #[test] + fn late_bound_unbegun_envelope_releases_without_reporting() { + let drops = Arc::new(AtomicUsize::new(0)); + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::empty()) + .expect("response"); + let (resource, completion) = ResponseEgressCompletion::late_bound(); + resource + .install(ResourceDropProbe(Arc::clone(&drops))) + .expect("first installation"); + + drop(ResponseEgressEnvelope::new( + response, + ResponseEgressRequestMetadata::new(Method::GET, started_at, None), + completion, + Arc::new(default_response_egress_policy), + ResponseEgressObserverHandle::new(observer.clone()), + MonotonicClock::default(), + )); + + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert!(observer.reports().is_empty()); + } + + #[test] + fn response_completion_runs_once_before_the_global_observer() { + let events = Arc::new(Mutex::new(Vec::new())); + let completion_events = Arc::clone(&events); + let observer = OrderedObserver(Arc::clone(&events)); + let started_at = MonotonicInstant::now(); + let headers = HeaderMap::new(); + let head = head(&headers, started_at, None); + let mut attempt = ResponseEgressAttempt::new( + &head, + started_at, + ResponseEgressCompletion::new(move |_| { + completion_events + .lock() + .expect("ordered events lock") + .push("completion"); + }), + ResponseEgressObserverHandle::new(observer), + MonotonicClock::default(), + ); + + assert!(attempt.begin_writing()); + assert!(attempt.complete(started_at)); + assert!(!attempt.complete(started_at)); + assert!(!attempt.terminate(ResponseEgressOutcome::TransportError, started_at)); + drop(attempt); + + assert_eq!( + events.lock().expect("ordered events lock").as_slice(), + &["completion", "observer"] + ); + } + + #[test] + fn response_completion_panic_does_not_suppress_the_global_observer() { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let headers = HeaderMap::new(); + let head = head(&headers, started_at, None); + let result = catch_unwind(AssertUnwindSafe(|| { + let mut attempt = ResponseEgressAttempt::new( + &head, + started_at, + ResponseEgressCompletion::new(|_| panic!("completion panic")), + ResponseEgressObserverHandle::new(observer.clone()), + MonotonicClock::default(), + ); + assert!(attempt.terminate(ResponseEgressOutcome::ConversionError, started_at)); + })); + + result.unwrap_or_else(|_| panic!("completion panic escaped terminal transition")); + assert_eq!(observer.reports().len(), 1); + } + + #[test] + fn response_egress_completion_join_runs_left_to_right_once_with_same_report() { + let events = Arc::new(Mutex::new(Vec::new())); + let left_events = Arc::clone(&events); + let right_events = Arc::clone(&events); + let mut completion = ResponseEgressCompletion::new(move |report| { + left_events.lock().expect("completion events lock").push(( + "left", + from_ref(report).addr(), + report.clone(), + )); + }) + .join(ResponseEgressCompletion::new(move |report| { + right_events.lock().expect("completion events lock").push(( + "right", + from_ref(report).addr(), + report.clone(), + )); + })); + let report = completed_report(MonotonicInstant::now()); + + completion.complete(&report); + completion.complete(&report); + + let recorded_events = events.lock().expect("completion events lock"); + assert_eq!(recorded_events.len(), 2); + assert_eq!(recorded_events[0].0, "left"); + assert_eq!(recorded_events[1].0, "right"); + assert_eq!(recorded_events[0].1, recorded_events[1].1); + assert_eq!(recorded_events[0].2, report); + assert_eq!(recorded_events[1].2, report); + } + + #[cfg(panic = "unwind")] + #[test] + fn response_egress_completion_join_left_panic_does_not_suppress_right() { + let right_calls = Arc::new(AtomicUsize::new(0)); + let observed_right_calls = Arc::clone(&right_calls); + let mut completion = ResponseEgressCompletion::new(|_| panic!("left completion panic")) + .join(ResponseEgressCompletion::new(move |_| { + observed_right_calls.fetch_add(1, Ordering::SeqCst); + })); + let report = completed_report(MonotonicInstant::now()); + + let result = catch_unwind(AssertUnwindSafe(|| completion.complete(&report))); + + result.unwrap_or_else(|_| panic!("left completion panic escaped join")); + assert_eq!(right_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn response_egress_completion_join_abandonment_releases_both_resources_once() { + let left_drops = Arc::new(AtomicUsize::new(0)); + let right_drops = Arc::new(AtomicUsize::new(0)); + let left_probe = ResourceDropProbe(Arc::clone(&left_drops)); + let right_probe = ResourceDropProbe(Arc::clone(&right_drops)); + let completion = ResponseEgressCompletion::new(move |_| { + let _retain_until_completion = &left_probe; + }) + .join(ResponseEgressCompletion::new(move |_| { + let _retain_until_completion = &right_probe; + })); + + drop(completion); + + assert_eq!(left_drops.load(Ordering::SeqCst), 1); + assert_eq!(right_drops.load(Ordering::SeqCst), 1); + } + + #[test] + fn response_egress_completion_join_repeated_terminal_attempts_run_each_once() { + let left_calls = Arc::new(AtomicUsize::new(0)); + let right_calls = Arc::new(AtomicUsize::new(0)); + let observed_left_calls = Arc::clone(&left_calls); + let observed_right_calls = Arc::clone(&right_calls); + let completion = ResponseEgressCompletion::new(move |_| { + observed_left_calls.fetch_add(1, Ordering::SeqCst); + }) + .join(ResponseEgressCompletion::new(move |_| { + observed_right_calls.fetch_add(1, Ordering::SeqCst); + })); + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let headers = HeaderMap::new(); + let head = head(&headers, started_at, None); + let mut attempt = ResponseEgressAttempt::new( + &head, + started_at, + completion, + ResponseEgressObserverHandle::new(observer.clone()), + MonotonicClock::default(), + ); + + assert!(attempt.begin_writing()); + assert!(attempt.complete(started_at)); + assert!(!attempt.complete(started_at)); + assert!(!attempt.terminate(ResponseEgressOutcome::TransportError, started_at)); + drop(attempt); + + assert_eq!(left_calls.load(Ordering::SeqCst), 1); + assert_eq!(right_calls.load(Ordering::SeqCst), 1); + assert_eq!(observer.reports().len(), 1); + } + + #[test] + fn unbegun_envelope_drops_completion_without_a_terminal_report() { + let observer = RecordingObserver::default(); + let callback_calls = Arc::new(Mutex::new(0_usize)); + let completion_drops = Arc::new(Mutex::new(0_usize)); + let observed_calls = Arc::clone(&callback_calls); + let probe = CompletionDropProbe(Arc::clone(&completion_drops)); + let started_at = MonotonicInstant::now(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::empty()) + .expect("response"); + + drop(ResponseEgressEnvelope::new( + response, + ResponseEgressRequestMetadata::new(Method::GET, started_at, None), + ResponseEgressCompletion::new(move |_| { + let _keep_probe_until_completion = &probe; + let mut calls = observed_calls.lock().expect("completion calls lock"); + *calls += 1; + }), + Arc::new(default_response_egress_policy), + ResponseEgressObserverHandle::new(observer.clone()), + MonotonicClock::default(), + )); + + assert_eq!(*callback_calls.lock().expect("completion calls lock"), 0); + assert_eq!(*completion_drops.lock().expect("completion drops lock"), 1); + assert!(observer.reports().is_empty()); + } + + #[test] + fn every_outcome_is_reported_without_collapsing_variants_from_valid_states() { + let outcomes = [ + ResponseEgressOutcome::Completed, + ResponseEgressOutcome::ClientDisconnected, + ResponseEgressOutcome::ConversionError, + ResponseEgressOutcome::DeadlineExceeded, + ResponseEgressOutcome::HostHandoff, + ResponseEgressOutcome::RequestCancelled, + ResponseEgressOutcome::SourceError, + ResponseEgressOutcome::TransportError, + ResponseEgressOutcome::Unspecified, + ]; + + for outcome in outcomes { + let states = if matches!( + outcome, + ResponseEgressOutcome::Completed | ResponseEgressOutcome::HostHandoff + ) { + &[true][..] + } else { + &[false, true][..] + }; + for &writing in states { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let mut attempt = attempt(&observer, started_at); + if writing { + assert!(attempt.begin_writing()); + } + if outcome == ResponseEgressOutcome::Completed { + assert!(attempt.complete(started_at)); + } else { + assert!(attempt.terminate(outcome, started_at)); + } + assert_eq!(observer.reports()[0].outcome, outcome); + } + } + } + + #[test] + fn completed_cannot_skip_the_writing_state() { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let mut attempt = attempt(&observer, started_at); + + assert!(attempt.complete(started_at)); + assert_eq!(observer.reports().len(), 1); + assert_eq!( + observer.reports()[0].outcome, + ResponseEgressOutcome::ConversionError + ); + } + + #[test] + fn host_handoff_cannot_skip_the_writing_state() { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let mut attempt = attempt(&observer, started_at); + + assert!(attempt.terminate(ResponseEgressOutcome::HostHandoff, started_at)); + assert_eq!(observer.reports().len(), 1); + assert_eq!( + observer.reports()[0].outcome, + ResponseEgressOutcome::ConversionError + ); + } + + #[test] + fn writing_completion_records_metadata_bytes_and_elapsed_once() { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let terminal_at = started_at + .checked_add(Duration::from_millis(25)) + .expect("terminal instant"); + let request_start = started_at + .checked_sub(Duration::from_millis(10)) + .expect("request start"); + let route = RouteMetadata::new(Method::POST, "/submit"); + let headers = HeaderMap::new(); + let head = head(&headers, request_start, Some(&route)); + let mut attempt = ResponseEgressAttempt::new( + &head, + started_at, + ResponseEgressCompletion::empty(), + ResponseEgressObserverHandle::new(observer.clone()), + MonotonicClock::default(), + ); + + assert!(attempt.begin_writing()); + assert!(attempt.account_bytes(0, started_at)); + assert!(attempt.account_bytes(7, started_at)); + assert!(attempt.complete(terminal_at)); + assert!(!attempt.complete(terminal_at)); + assert!(!attempt.terminate(ResponseEgressOutcome::TransportError, terminal_at)); + drop(attempt); + + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].bytes_written, 7); + assert_eq!(reports[0].body_kind, ResponseEgressBodyKind::Application); + assert_eq!(reports[0].elapsed, Duration::from_millis(25)); + assert_eq!(reports[0].fallback_disposition, None); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::Completed); + assert_eq!(reports[0].request_start, request_start); + assert_eq!(reports[0].route, Some(route)); + } + + #[test] + fn drop_and_duplicate_signals_preserve_exactly_once_terminal_state() { + let initial_observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + drop(attempt(&initial_observer, started_at)); + assert_eq!(initial_observer.reports().len(), 1); + assert_eq!( + initial_observer.reports()[0].outcome, + ResponseEgressOutcome::ConversionError + ); + + let writing_observer = RecordingObserver::default(); + let mut writing = attempt(&writing_observer, started_at); + assert!(writing.begin_writing()); + assert!(!writing.begin_writing()); + drop(writing); + assert_eq!(writing_observer.reports().len(), 1); + assert_eq!( + writing_observer.reports()[0].outcome, + ResponseEgressOutcome::TransportError + ); + + let disconnected_observer = RecordingObserver::default(); + let mut disconnected = attempt(&disconnected_observer, started_at); + assert!(disconnected.begin_writing()); + assert!(disconnected.terminate(ResponseEgressOutcome::ClientDisconnected, started_at)); + drop(disconnected); + assert_eq!(disconnected_observer.reports().len(), 1); + } + + #[test] + fn dropped_attempt_uses_its_injected_clock() { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let completed_at = started_at + .checked_add(Duration::from_millis(17)) + .expect("completed instant"); + let clock = MonotonicClock::new(move || completed_at); + let headers = HeaderMap::new(); + let head = head(&headers, started_at, None); + + drop(ResponseEgressAttempt::new( + &head, + started_at, + ResponseEgressCompletion::empty(), + ResponseEgressObserverHandle::new(observer.clone()), + clock, + )); + + assert_eq!(observer.reports()[0].elapsed, Duration::from_millis(17)); + } + + #[test] + fn byte_overflow_terminalizes_as_transport_error() { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let mut attempt = attempt(&observer, started_at); + assert!(attempt.begin_writing()); + assert!(attempt.account_bytes(u64::MAX, started_at)); + assert!(!attempt.account_bytes(1, started_at)); + assert!(!attempt.complete(started_at)); + + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].bytes_written, u64::MAX); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::TransportError); + } + + #[test] + fn zero_byte_completion_has_zero_accounting() { + let completed_observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let mut completed = attempt(&completed_observer, started_at); + assert!(completed.begin_writing()); + assert!(completed.complete(started_at)); + assert_eq!(completed_observer.reports()[0].bytes_written, 0); + } + + #[test] + fn backwards_clock_is_zero_elapsed_unspecified_and_still_once() { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let earlier = started_at + .checked_sub(Duration::from_millis(1)) + .expect("earlier instant"); + let mut attempt = attempt(&observer, started_at); + assert!(attempt.begin_writing()); + assert!(attempt.complete(earlier)); + drop(attempt); + + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].elapsed, Duration::ZERO); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::Unspecified); + } + + #[test] + fn observer_panics_do_not_escape_terminal_or_drop_paths() { + let direct = catch_unwind(AssertUnwindSafe(|| { + let started_at = MonotonicInstant::now(); + let headers = HeaderMap::new(); + let head = head(&headers, started_at, None); + let mut attempt = ResponseEgressAttempt::new( + &head, + started_at, + ResponseEgressCompletion::empty(), + ResponseEgressObserverHandle::new(PanickingObserver), + MonotonicClock::default(), + ); + assert!(attempt.terminate(ResponseEgressOutcome::ConversionError, started_at)); + })); + direct.unwrap_or_else(|_| panic!("observer panic escaped direct completion")); + + let dropped = catch_unwind(AssertUnwindSafe(|| { + let started_at = MonotonicInstant::now(); + let headers = HeaderMap::new(); + let head = head(&headers, started_at, None); + drop(ResponseEgressAttempt::new( + &head, + started_at, + ResponseEgressCompletion::empty(), + ResponseEgressObserverHandle::new(PanickingObserver), + MonotonicClock::default(), + )); + })); + dropped.unwrap_or_else(|_| panic!("observer panic escaped guard drop")); + } + + #[test] + fn fallback_reports_its_bytes_and_disposition_with_original_cause() { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let mut fallback = attempt(&observer, started_at); + + assert!(fallback.begin_fallback(ResponseEgressOutcome::ConversionError)); + assert!(fallback.begin_writing()); + assert!(fallback.account_bytes(7, started_at)); + assert!( + fallback.finish_fallback(ResponseEgressFallbackDisposition::Completed, started_at,) + ); + + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].bytes_written, 7); + assert_eq!(reports[0].body_kind, ResponseEgressBodyKind::Fallback); + assert_eq!( + reports[0].fallback_disposition, + Some(ResponseEgressFallbackDisposition::Completed) + ); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::ConversionError); + } + + #[test] + fn fallback_selection_is_one_shot_and_preserves_its_first_cause() { + let observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let mut fallback = attempt(&observer, started_at); + + assert!(fallback.begin_fallback(ResponseEgressOutcome::ConversionError)); + assert!(!fallback.begin_fallback(ResponseEgressOutcome::DeadlineExceeded)); + assert!(fallback.begin_writing()); + assert!(fallback.account_bytes(3, started_at)); + assert!(fallback.finish_fallback(ResponseEgressFallbackDisposition::Aborted, started_at,)); + + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].bytes_written, 3); + assert_eq!( + reports[0].fallback_disposition, + Some(ResponseEgressFallbackDisposition::Aborted) + ); + assert_eq!(reports[0].outcome, ResponseEgressOutcome::ConversionError); + } + + #[test] + fn fallback_drop_and_accounting_overflow_abort_with_original_cause() { + let drop_observer = RecordingObserver::default(); + let started_at = MonotonicInstant::now(); + let mut dropped = attempt(&drop_observer, started_at); + assert!(!dropped.begin_fallback(ResponseEgressOutcome::SourceError)); + assert!(dropped.begin_fallback(ResponseEgressOutcome::DeadlineExceeded)); + drop(dropped); + let dropped_report = &drop_observer.reports()[0]; + assert_eq!( + dropped_report.outcome, + ResponseEgressOutcome::DeadlineExceeded + ); + assert_eq!( + dropped_report.fallback_disposition, + Some(ResponseEgressFallbackDisposition::Aborted) + ); + + let overflow_observer = RecordingObserver::default(); + let mut overflow = attempt(&overflow_observer, started_at); + assert!(overflow.begin_fallback(ResponseEgressOutcome::ConversionError)); + assert!(overflow.begin_writing()); + assert!(overflow.account_bytes(u64::MAX, started_at)); + assert!(!overflow.account_bytes(1, started_at)); + let overflow_report = &overflow_observer.reports()[0]; + assert_eq!(overflow_report.bytes_written, u64::MAX); + assert_eq!( + overflow_report.outcome, + ResponseEgressOutcome::ConversionError + ); + assert_eq!( + overflow_report.fallback_disposition, + Some(ResponseEgressFallbackDisposition::Aborted) + ); + } + + #[test] + fn policy_panic_returns_live_attempt_for_one_fallback_lifecycle() { + let observer = RecordingObserver::default(); + let request_start = MonotonicInstant::now(); + let started_at = request_start + .checked_add(Duration::from_millis(1)) + .expect("egress start"); + let clock = MonotonicClock::new(move || started_at); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from("private token=secret")) + .expect("response"); + let envelope = ResponseEgressEnvelope::new( + response, + ResponseEgressRequestMetadata::new(Method::GET, request_start, None), + ResponseEgressCompletion::empty(), + Arc::new(|_, _| panic!("private token=secret")), + ResponseEgressObserverHandle::new(observer.clone()), + clock, + ); + assert_eq!(envelope.request_method(), &Method::GET); + + let Err(failure) = envelope.begin() else { + panic!("policy panic must select fallback"); + }; + assert!(observer.reports().is_empty()); + let (mut attempt, failure_clock, cause) = failure.into_parts(); + assert_eq!(cause, ResponseEgressOutcome::ConversionError); + assert_eq!(failure_clock.now(), started_at); + assert!(attempt.begin_fallback(cause)); + assert!(attempt.begin_writing()); + assert!(attempt.finish_fallback(ResponseEgressFallbackDisposition::Completed, started_at,)); + + let reports = observer.reports(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].body_kind, ResponseEgressBodyKind::Fallback); + assert_eq!(reports[0].bytes_written, 0); + } + + #[test] + fn framing_failure_drops_source_unpolled_and_returns_one_live_attempt() { + use std::cell::Cell; + use std::rc::Rc; + use std::task::Poll; + + use futures_util::stream::poll_fn; + + struct DropSignal(Rc>); + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.set(self.0.get() + 1); + } + } + + let observer = RecordingObserver::default(); + let request_start = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || request_start); + let polls = Rc::new(Cell::new(0_usize)); + let drops = Rc::new(Cell::new(0_usize)); + let observed_polls = Rc::clone(&polls); + let drop_signal = DropSignal(Rc::clone(&drops)); + let body = Body::from_stream(poll_fn(move |_| { + let _keep_signal_alive = &drop_signal; + observed_polls.set(observed_polls.get() + 1); + Poll::Ready(Some(Ok(bytes::Bytes::from_static(b"private")))) + })); + let response = response_builder() + .status(StatusCode::OK) + .header("trailer", "x-checksum") + .body(body) + .expect("response"); + let envelope = ResponseEgressEnvelope::new( + response, + ResponseEgressRequestMetadata::new(Method::GET, request_start, None), + ResponseEgressCompletion::empty(), + Arc::new(default_response_egress_policy), + ResponseEgressObserverHandle::new(observer.clone()), + clock, + ); + + let Err(failure) = envelope.begin() else { + panic!("framing failure must retain the attempt"); + }; + assert_eq!(polls.get(), 0); + assert_eq!(drops.get(), 1); + assert!(observer.reports().is_empty()); + let (mut attempt, _, cause) = failure.into_parts(); + assert_eq!(cause, ResponseEgressOutcome::ConversionError); + assert!(attempt.begin_fallback(cause)); + assert!( + attempt.finish_fallback(ResponseEgressFallbackDisposition::Aborted, request_start,) + ); + assert_eq!(observer.reports().len(), 1); + } + + #[test] + fn response_carried_deadline_is_visible_to_policy_and_clamps_write_lifetime() { + let observer = RecordingObserver::default(); + let request_start = MonotonicInstant::now(); + let egress_started_at = request_start + .checked_add(Duration::from_millis(10)) + .expect("egress start"); + let application_deadline = Deadline::at_instant( + request_start + .checked_add(Duration::from_millis(50)) + .expect("application deadline"), + ); + let callback_deadline = Deadline::at_instant( + request_start + .checked_add(Duration::from_millis(100)) + .expect("callback deadline"), + ); + let mut response = response_builder() + .status(StatusCode::OK) + .body(Body::from("body")) + .expect("response"); + response + .extensions_mut() + .insert(ResponseEgressDeadline::at(application_deadline)); + let envelope = ResponseEgressEnvelope::new( + response, + ResponseEgressRequestMetadata::new(Method::GET, request_start, None), + ResponseEgressCompletion::empty(), + Arc::new(move |head, observed_start| { + assert_eq!(observed_start, egress_started_at); + assert_eq!( + head.application_deadline() + .map(|deadline| deadline.instant()), + Some(application_deadline.instant()) + ); + ResponseEgressPolicy { + write_deadline: callback_deadline, + } + }), + ResponseEgressObserverHandle::new(observer), + MonotonicClock::new(move || egress_started_at), + ); + + let Ok((prepared, policy, _attempt, _clock)) = envelope.begin() else { + panic!("egress preparation failed"); + }; + + assert_eq!( + policy.write_deadline.instant(), + application_deadline.instant() + ); + assert!( + prepared + .into_response() + .extensions() + .get::() + .is_none() + ); + } + + #[test] + fn response_relative_deadline_resolves_from_injected_egress_start() { + let request_start = MonotonicInstant::now(); + let egress_started_at = request_start + .checked_add(Duration::from_millis(10)) + .expect("egress start"); + let expected_deadline = egress_started_at + .checked_add(Duration::from_millis(40)) + .expect("relative deadline"); + let callback_deadline = Deadline::at_instant( + egress_started_at + .checked_add(Duration::from_millis(100)) + .expect("callback deadline"), + ); + let mut response = response_builder() + .status(StatusCode::OK) + .body(Body::from("body")) + .expect("response"); + response + .extensions_mut() + .insert(ResponseEgressDeadline::after(Duration::from_millis(40))); + let envelope = ResponseEgressEnvelope::new( + response, + ResponseEgressRequestMetadata::new(Method::GET, request_start, None), + ResponseEgressCompletion::empty(), + Arc::new(move |head, observed_start| { + assert_eq!(observed_start, egress_started_at); + assert_eq!( + head.application_deadline() + .map(|deadline| deadline.instant()), + Some(expected_deadline) + ); + ResponseEgressPolicy { + write_deadline: callback_deadline, + } + }), + ResponseEgressObserverHandle::default(), + MonotonicClock::new(move || egress_started_at), + ); + + let Ok((prepared, policy, _attempt, _clock)) = envelope.begin() else { + panic!("egress preparation failed"); + }; + + assert_eq!(policy.write_deadline.instant(), expected_deadline); + assert!( + prepared + .into_response() + .extensions() + .get::() + .is_none() + ); + } + + #[test] + fn response_relative_deadline_zero_expires_at_egress_start() { + let egress_started_at = MonotonicInstant::now(); + let deadline = ResponseEgressDeadline::after(Duration::ZERO).resolve_at(egress_started_at); + + assert_eq!(deadline.instant(), egress_started_at); + assert!(deadline.is_expired_at(egress_started_at)); + } + + #[test] + fn response_relative_deadline_clamps_to_portable_far_future() { + let egress_started_at = MonotonicInstant::now(); + let expected = egress_started_at + .checked_add(DEADLINE_FAR_FUTURE) + .expect("portable far-future deadline"); + let deadline = ResponseEgressDeadline::after(Duration::MAX).resolve_at(egress_started_at); + + assert_eq!(deadline.instant(), expected); + } + + #[test] + fn response_absolute_deadline_is_not_reanchored_at_egress_start() { + let absolute = MonotonicInstant::now(); + let egress_started_at = absolute + .checked_add(Duration::from_secs(1)) + .expect("egress start"); + let deadline = ResponseEgressDeadline::at(Deadline::at_instant(absolute)) + .resolve_at(egress_started_at); + + assert_eq!(deadline.instant(), absolute); + assert!(deadline.is_expired_at(egress_started_at)); + } + + #[test] + fn adapter_response_hook_runs_before_policy_and_framing() { + let request_start = MonotonicInstant::now(); + let observed_status = Arc::new(Mutex::new(None)); + let policy_status = Arc::clone(&observed_status); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from("body")) + .expect("response"); + let mut envelope = ResponseEgressEnvelope::new( + response, + ResponseEgressRequestMetadata::new(Method::HEAD, request_start, None), + ResponseEgressCompletion::empty(), + Arc::new(move |head, started_at| { + *policy_status.lock().expect("policy status") = Some(head.status()); + ResponseEgressPolicy { + write_deadline: Deadline::at_instant( + started_at + .checked_add(Duration::from_secs(1)) + .expect("write deadline"), + ), + } + }), + ResponseEgressObserverHandle::default(), + MonotonicClock::new(move || request_start), + ); + + *envelope.response_mut().status_mut() = StatusCode::CREATED; + let Ok((prepared, _policy, _attempt, _clock)) = envelope.begin() else { + panic!("egress preparation failed"); + }; + let prepared_response = prepared.into_response(); + + assert_eq!( + *observed_status.lock().expect("observed status"), + Some(StatusCode::CREATED) + ); + assert_eq!(prepared_response.status(), StatusCode::CREATED); + assert!( + prepared_response + .into_body() + .into_bytes() + .expect("buffered body") + .is_empty() + ); + } +} diff --git a/crates/edgezero-core/src/response_egress_framing.rs b/crates/edgezero-core/src/response_egress_framing.rs new file mode 100644 index 000000000..533d0c91a --- /dev/null +++ b/crates/edgezero-core/src/response_egress_framing.rs @@ -0,0 +1,882 @@ +use std::mem; + +use crate::body::{Body, BodyStream}; +use crate::error::{BadGatewayDecodeReason, BadGatewayReason, EdgeError, ResponseLimitReason}; +use crate::http::header::{ + CONNECTION, CONTENT_LENGTH, PROXY_AUTHENTICATE, PROXY_AUTHORIZATION, TE, TRAILER, + TRANSFER_ENCODING, UPGRADE, +}; +use crate::http::{HeaderMap, HeaderName, HeaderValue, Method, Response, StatusCode}; +use futures_util::StreamExt as _; +use futures_util::stream::unfold; + +/// Typed streamed-body violation used by adapter coordinators for terminal classification. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] +pub enum ResponseEgressBodyLengthError { + #[error("response body exceeded declared content-length")] + Exceeded, + #[error("response body ended before declared content-length")] + Incomplete, +} + +/// Category-only response framing failure reported before platform commitment. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] +pub enum ResponseEgressFramingError { + #[error("response body length does not match content-length")] + ContentLengthMismatch, + #[error("invalid connection response header")] + InvalidConnectionHeader, + #[error("invalid response content-length")] + InvalidContentLength, + #[error("unsupported final response status")] + UnsupportedStatus, + #[error("response trailers are unsupported")] + UnsupportedTrailers, + #[error("successful connect response requires a tunnel lifecycle")] + UnsupportedTunnel, +} + +/// Canonical response head and body ready for one adapter-owned writer. +#[doc(hidden)] +pub struct PreparedResponseEgress { + declared_length: Option, + response: Response, + transmits_body: bool, +} + +impl PreparedResponseEgress { + #[must_use] + #[inline] + pub fn declared_length(&self) -> Option { + self.declared_length + } + + #[must_use] + #[inline] + pub fn into_response(self) -> Response { + self.response + } + + #[must_use] + #[inline] + pub fn transmits_body(&self) -> bool { + self.transmits_body + } +} + +/// Validates and normalizes response framing before an adapter commits the head. +/// +/// # Errors +/// Returns a category-only framing error for invalid or conflicting field values and for a +/// buffered body whose length disagrees with an explicit `Content-Length`. +#[doc(hidden)] +#[inline] +pub fn prepare_response_egress( + request_method: &Method, + mut response: Response, +) -> Result { + let mut declared_length = parse_content_length(response.headers())?; + if response.status().is_informational() { + return Err(ResponseEgressFramingError::UnsupportedStatus); + } + if *request_method == Method::CONNECT && response.status().is_success() { + return Err(ResponseEgressFramingError::UnsupportedTunnel); + } + + let suppress_body = if response.status() == StatusCode::NO_CONTENT { + response.headers_mut().remove(CONTENT_LENGTH); + declared_length = None; + true + } else if response.status() == StatusCode::RESET_CONTENT { + if declared_length.is_some_and(|length| length != 0) { + return Err(ResponseEgressFramingError::ContentLengthMismatch); + } + true + } else { + response.status() == StatusCode::NOT_MODIFIED || *request_method == Method::HEAD + }; + + if suppress_body { + let original_body = mem::take(response.body_mut()); + drop(original_body); + } + + if response.headers().contains_key(TRAILER) { + return Err(ResponseEgressFramingError::UnsupportedTrailers); + } + let content_length_was_nominated = strip_connection_fields(response.headers_mut())?; + response.headers_mut().remove(TRANSFER_ENCODING); + if content_length_was_nominated { + declared_length = None; + } + + if let Some(length) = declared_length { + response.headers_mut().remove(CONTENT_LENGTH); + let canonical = HeaderValue::from_str(&length.to_string()) + .map_err(|_error| ResponseEgressFramingError::InvalidContentLength)?; + response.headers_mut().insert(CONTENT_LENGTH, canonical); + if !suppress_body { + match response.body() { + Body::Once(bytes) => { + let actual = u64::try_from(bytes.len()) + .map_err(|_error| ResponseEgressFramingError::ContentLengthMismatch)?; + if actual != length { + return Err(ResponseEgressFramingError::ContentLengthMismatch); + } + } + Body::Stream(_) => { + let body = mem::take(response.body_mut()); + if let Body::Stream(stream) = body { + *response.body_mut() = + Body::from_stream(limit_declared_stream(stream, length)); + } + } + } + } + } + + Ok(PreparedResponseEgress { + declared_length, + response, + transmits_body: !suppress_body, + }) +} + +fn limit_declared_stream(stream: BodyStream, length: u64) -> BodyStream { + unfold( + (Some(stream), length), + |(optional_source, remaining)| async move { + let mut source = optional_source?; + match source.next().await { + Some(Ok(bytes)) => { + let Ok(chunk_length) = u64::try_from(bytes.len()) else { + drop(source); + return Some(( + Err(EdgeError::internal(ResponseEgressBodyLengthError::Exceeded)), + (None, remaining), + )); + }; + let Some(next_remaining) = remaining.checked_sub(chunk_length) else { + drop(source); + return Some(( + Err(EdgeError::internal(ResponseEgressBodyLengthError::Exceeded)), + (None, remaining), + )); + }; + Some((Ok(bytes), (Some(source), next_remaining))) + } + Some(Err(error)) => { + drop(source); + Some((Err(error), (None, remaining))) + } + None if remaining == 0 => None, + None => { + drop(source); + Some(( + Err(EdgeError::internal( + ResponseEgressBodyLengthError::Incomplete, + )), + (None, remaining), + )) + } + } + }, + ) + .boxed_local() +} + +/// Returns the typed streamed-body length violation carried by an internal body error. +#[must_use] +#[doc(hidden)] +#[inline] +pub fn response_egress_body_length_error( + error: &EdgeError, +) -> Option { + let EdgeError::Internal { source } = error else { + return None; + }; + source + .downcast_ref::() + .copied() +} + +/// Returns a fixed diagnostic category for a post-commit response body source failure. +/// +/// The result never includes an application/provider message and is therefore safe for adapter +/// logs after the response head has already been committed. +#[must_use] +#[doc(hidden)] +#[inline] +pub fn response_egress_source_error_category(error: &EdgeError) -> &'static str { + if let Some(length_error) = response_egress_body_length_error(error) { + return match length_error { + ResponseEgressBodyLengthError::Exceeded => "declared_length_exceeded", + ResponseEgressBodyLengthError::Incomplete => "declared_length_incomplete", + }; + } + match error { + EdgeError::BadGateway { reason, .. } => match reason { + BadGatewayReason::Decode(decode) => match decode { + BadGatewayDecodeReason::Brotli => "bad_gateway_decode_brotli", + BadGatewayDecodeReason::Deflate => "bad_gateway_decode_deflate", + BadGatewayDecodeReason::Gzip => "bad_gateway_decode_gzip", + BadGatewayDecodeReason::Json => "bad_gateway_decode_json", + }, + BadGatewayReason::Protocol => "bad_gateway_protocol", + BadGatewayReason::Transport => "bad_gateway_transport", + BadGatewayReason::Unreachable => "bad_gateway_unreachable", + BadGatewayReason::Unspecified => "bad_gateway_unspecified", + }, + EdgeError::BadRequest { .. } => "bad_request", + EdgeError::ConfigOutOfDate { .. } => "config_out_of_date", + EdgeError::GatewayTimeout { .. } => "gateway_timeout", + EdgeError::Internal { .. } => "internal", + EdgeError::MethodNotAllowed { .. } => "method_not_allowed", + EdgeError::NotFound { .. } => "not_found", + EdgeError::NotImplemented { .. } => "not_implemented", + EdgeError::RequestHeaderFieldsTooLarge { .. } => "request_header_fields_too_large", + EdgeError::RequestTimeout { .. } => "request_timeout", + EdgeError::ResponseTooLarge { reason, .. } => match reason { + ResponseLimitReason::BrotliWindow => "response_too_large_brotli_window", + ResponseLimitReason::BufferedBody => "response_too_large_buffered_body", + ResponseLimitReason::DecodedBody => "response_too_large_decoded_body", + ResponseLimitReason::DecoderMemory => "response_too_large_decoder_memory", + ResponseLimitReason::EncodedBody => "response_too_large_encoded_body", + ResponseLimitReason::HeaderBytes => "response_too_large_header_bytes", + ResponseLimitReason::HeaderCount => "response_too_large_header_count", + ResponseLimitReason::Unspecified => "response_too_large_unspecified", + }, + EdgeError::ServiceUnavailable { .. } => "service_unavailable", + EdgeError::StoreExtraction { .. } => "store_extraction", + EdgeError::UriTooLong { .. } => "uri_too_long", + EdgeError::Validation { .. } => "validation", + } +} + +fn parse_content_length(headers: &HeaderMap) -> Result, ResponseEgressFramingError> { + let mut parsed = None; + for header_value in headers.get_all(CONTENT_LENGTH) { + let text = header_value + .to_str() + .map_err(|_error| ResponseEgressFramingError::InvalidContentLength)?; + for member in text.split(',') { + let candidate = member.trim(); + if candidate.is_empty() || !candidate.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(ResponseEgressFramingError::InvalidContentLength); + } + let parsed_value = candidate + .parse::() + .map_err(|_error| ResponseEgressFramingError::InvalidContentLength)?; + if parsed.is_some_and(|existing| existing != parsed_value) { + return Err(ResponseEgressFramingError::InvalidContentLength); + } + parsed = Some(parsed_value); + } + } + Ok(parsed) +} + +fn strip_connection_fields(headers: &mut HeaderMap) -> Result { + let mut nominated = Vec::new(); + for header_value in headers.get_all(CONNECTION) { + let text = header_value + .to_str() + .map_err(|_error| ResponseEgressFramingError::InvalidConnectionHeader)?; + for token in text.split(',') { + let name = HeaderName::from_bytes(token.trim().as_bytes()) + .map_err(|_error| ResponseEgressFramingError::InvalidConnectionHeader)?; + nominated.push(name); + } + } + let content_length_was_nominated = nominated.contains(&CONTENT_LENGTH); + for name in nominated { + headers.remove(name); + } + for name in [ + CONNECTION, + PROXY_AUTHENTICATE, + PROXY_AUTHORIZATION, + TE, + TRAILER, + TRANSFER_ENCODING, + UPGRADE, + ] { + headers.remove(name); + } + headers.remove("keep-alive"); + headers.remove("proxy-connection"); + Ok(content_length_was_nominated) +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::collections::VecDeque; + use std::rc::Rc; + use std::task::Poll; + + use bytes::Bytes; + use futures::executor::block_on; + use futures_util::StreamExt as _; + use futures_util::stream::{self, poll_fn}; + + use crate::body::Body; + use crate::error::{BadGatewayDecodeReason, BadGatewayReason, EdgeError}; + use crate::http::{Method, Response, StatusCode, response_builder}; + + use super::{ + ResponseEgressBodyLengthError, ResponseEgressFramingError, prepare_response_egress, + response_egress_body_length_error, response_egress_source_error_category, + }; + + struct DropSignal(Rc>); + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.set(self.0.get().saturating_add(1)); + } + } + + fn tracked_body(source_items: Vec>, drops: &Rc>) -> Body { + let signal = DropSignal(Rc::clone(drops)); + let mut pending_items = VecDeque::from(source_items); + Body::from_stream(poll_fn(move |_context| { + let _keep_signal_alive = &signal; + Poll::Ready(pending_items.pop_front()) + })) + } + + fn prepare_error( + method: &Method, + response: Response, + failure_message: &str, + ) -> ResponseEgressFramingError { + let Err(error) = prepare_response_egress(method, response) else { + panic!("{failure_message}"); + }; + error + } + + #[test] + fn normalizes_duplicate_content_length_and_strips_connection_fields() { + let source_response = response_builder() + .status(StatusCode::OK) + .header("content-length", "5, 5") + .header("content-length", "5") + .header("connection", "x-private, keep-alive") + .header("x-private", "secret") + .header("keep-alive", "timeout=5") + .body(Body::from("hello")) + .expect("response"); + + let prepared = + prepare_response_egress(&Method::GET, source_response).expect("prepared response"); + assert!(prepared.transmits_body()); + assert_eq!(prepared.declared_length(), Some(5)); + let converted_response = prepared.into_response(); + assert_eq!( + converted_response + .headers() + .get("content-length") + .expect("length"), + "5" + ); + assert!(!converted_response.headers().contains_key("connection")); + assert!(!converted_response.headers().contains_key("x-private")); + assert!(!converted_response.headers().contains_key("keep-alive")); + } + + #[test] + fn connection_nominated_content_length_stays_removed() { + let source_response = response_builder() + .status(StatusCode::OK) + .header("connection", "content-length") + .header("content-length", "5") + .body(Body::from("hello")) + .expect("response"); + + let prepared = + prepare_response_egress(&Method::GET, source_response).expect("prepared response"); + assert_eq!(prepared.declared_length(), None); + assert!( + !prepared + .into_response() + .headers() + .contains_key("content-length") + ); + } + + #[test] + fn strips_every_hop_by_hop_field_across_connection_lines() { + let source_response = response_builder() + .status(StatusCode::OK) + .header("connection", "x-first") + .header("connection", "x-second, upgrade") + .header("x-first", "private") + .header("x-second", "private") + .header("keep-alive", "timeout=5") + .header("proxy-authenticate", "challenge") + .header("proxy-authorization", "credential") + .header("proxy-connection", "keep-alive") + .header("te", "trailers") + .header("transfer-encoding", "chunked") + .header("upgrade", "websocket") + .body(Body::from("body")) + .expect("response"); + + let converted_response = prepare_response_egress(&Method::GET, source_response) + .expect("prepared response") + .into_response(); + for name in [ + "connection", + "x-first", + "x-second", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "transfer-encoding", + "upgrade", + ] { + assert!( + !converted_response.headers().contains_key(name), + "retained {name}" + ); + } + } + + #[test] + fn accepts_whitespace_and_rejects_buffered_length_mismatch() { + let spaced = response_builder() + .status(StatusCode::OK) + .header("content-length", " 5 , 5 ") + .body(Body::from("hello")) + .expect("response"); + assert_eq!( + prepare_response_egress(&Method::GET, spaced) + .expect("whitespace is valid") + .declared_length(), + Some(5) + ); + + let mismatch = response_builder() + .status(StatusCode::OK) + .header("content-length", "4") + .body(Body::from("hello")) + .expect("response"); + let error = prepare_error( + &Method::GET, + mismatch, + "buffered mismatch must fail before commit", + ); + assert_eq!(error, ResponseEgressFramingError::ContentLengthMismatch); + } + + #[test] + fn exact_declared_stream_reaches_clean_eof() { + let response = response_builder() + .status(StatusCode::OK) + .header("content-length", "4") + .body(Body::from_stream(stream::iter([ + Ok(Bytes::from_static(b"ab")), + Ok(Bytes::from_static(b"cd")), + ]))) + .expect("response"); + let mut body = prepare_response_egress(&Method::GET, response) + .expect("prepared response") + .into_response() + .into_body() + .into_stream() + .expect("stream"); + + block_on(async { + assert_eq!(body.next().await.expect("first").expect("chunk"), "ab"); + assert_eq!(body.next().await.expect("second").expect("chunk"), "cd"); + assert!(body.next().await.is_none()); + }); + } + + #[test] + fn head_suppression_releases_stream_without_polling_and_preserves_length() { + struct DropSignal(Rc>); + + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.set(self.0.get() + 1); + } + } + + let polls = Rc::new(Cell::new(0_usize)); + let drops = Rc::new(Cell::new(0_usize)); + let observed_polls = Rc::clone(&polls); + let signal = DropSignal(Rc::clone(&drops)); + let body = Body::from_stream(poll_fn(move |_| { + let _keep_signal_alive = &signal; + observed_polls.set(observed_polls.get() + 1_usize); + Poll::Ready(Some(Ok(Bytes::from_static(b"body")))) + })); + let response = response_builder() + .status(StatusCode::OK) + .header("content-length", "99") + .body(body) + .expect("response"); + + let prepared = prepare_response_egress(&Method::HEAD, response).expect("prepared response"); + + assert!(!prepared.transmits_body()); + assert_eq!(prepared.declared_length(), Some(99)); + assert_eq!(polls.get(), 0_usize); + assert_eq!(drops.get(), 1_usize); + assert_eq!( + prepared + .into_response() + .body() + .as_bytes() + .expect("suppressed body"), + b"" + ); + } + + #[test] + fn declared_stream_length_rejects_overrun_and_early_eof() { + let overrun = response_builder() + .status(StatusCode::OK) + .header("content-length", "3") + .body(Body::from_stream(stream::iter([ + Ok(Bytes::from_static(b"ab")), + Ok(Bytes::from_static(b"cd")), + ]))) + .expect("response"); + let mut overrun_stream = prepare_response_egress(&Method::GET, overrun) + .expect("prepared response") + .into_response() + .into_body() + .into_stream() + .expect("stream body"); + block_on(async { + assert_eq!( + overrun_stream + .next() + .await + .expect("first item") + .expect("first chunk"), + Bytes::from_static(b"ab") + ); + let error = overrun_stream + .next() + .await + .expect("overrun item") + .expect_err("overrun error"); + assert_eq!( + response_egress_body_length_error(&error), + Some(ResponseEgressBodyLengthError::Exceeded) + ); + assert!(overrun_stream.next().await.is_none()); + }); + + let early_eof = response_builder() + .status(StatusCode::OK) + .header("content-length", "3") + .body(Body::from_stream(stream::iter([Ok(Bytes::from_static( + b"ab", + ))]))) + .expect("response"); + let mut early_eof_stream = prepare_response_egress(&Method::GET, early_eof) + .expect("prepared response") + .into_response() + .into_body() + .into_stream() + .expect("stream body"); + block_on(async { + assert_eq!( + early_eof_stream + .next() + .await + .expect("chunk") + .expect("valid first chunk"), + Bytes::from_static(b"ab") + ); + let error = early_eof_stream + .next() + .await + .expect("early EOF item") + .expect_err("early EOF error"); + assert_eq!( + response_egress_body_length_error(&error), + Some(ResponseEgressBodyLengthError::Incomplete) + ); + assert!(early_eof_stream.next().await.is_none()); + }); + } + + #[test] + fn terminal_stream_error_releases_source_before_yield() { + let overrun_drops = Rc::new(Cell::new(0_usize)); + let overrun = response_builder() + .status(StatusCode::OK) + .header("content-length", "1") + .body(tracked_body( + vec![Ok(Bytes::from_static(b"over"))], + &overrun_drops, + )) + .expect("response"); + let mut overrun_stream = prepare_response_egress(&Method::GET, overrun) + .expect("prepared response") + .into_response() + .into_body() + .into_stream() + .expect("stream body"); + block_on(overrun_stream.next()) + .expect("overrun result") + .expect_err("overrun error"); + assert_eq!(overrun_drops.get(), 1_usize); + + let source_drops = Rc::new(Cell::new(0_usize)); + let source_error = response_builder() + .status(StatusCode::OK) + .header("content-length", "1") + .body(tracked_body( + vec![Err(EdgeError::bad_gateway("source failed"))], + &source_drops, + )) + .expect("response"); + let mut source_stream = prepare_response_egress(&Method::GET, source_error) + .expect("prepared response") + .into_response() + .into_body() + .into_stream() + .expect("stream body"); + block_on(source_stream.next()) + .expect("source result") + .expect_err("source error"); + assert_eq!(source_drops.get(), 1_usize); + + let eof_drops = Rc::new(Cell::new(0_usize)); + let early_eof = response_builder() + .status(StatusCode::OK) + .header("content-length", "1") + .body(tracked_body(Vec::new(), &eof_drops)) + .expect("response"); + let mut eof_stream = prepare_response_egress(&Method::GET, early_eof) + .expect("prepared response") + .into_response() + .into_body() + .into_stream() + .expect("stream body"); + block_on(eof_stream.next()) + .expect("early EOF result") + .expect_err("early EOF error"); + assert_eq!(eof_drops.get(), 1_usize); + } + + #[test] + fn rejects_application_trailer_declaration_before_commit() { + let response = response_builder() + .status(StatusCode::OK) + .header("trailer", "x-checksum") + .body(Body::from("body")) + .expect("response"); + + let error = prepare_error(&Method::GET, response, "trailers must be rejected"); + assert_eq!( + error, + super::ResponseEgressFramingError::UnsupportedTrailers + ); + } + + #[test] + fn rejects_every_invalid_content_length_shape_before_suppression() { + for value in ["", "+1", "-1", "1a", "1,,1", "1, 2", "18446744073709551616"] { + let response = response_builder() + .status(StatusCode::NO_CONTENT) + .header("content-length", value) + .body(Body::empty()) + .expect("response"); + let error = prepare_error( + &Method::GET, + response, + "invalid content-length was accepted", + ); + assert_eq!(error, ResponseEgressFramingError::InvalidContentLength); + } + } + + #[test] + fn applies_status_suppression_before_method_suppression() { + let no_content = response_builder() + .status(StatusCode::NO_CONTENT) + .header("content-length", "9") + .body(Body::from("ignored")) + .expect("response"); + let no_content_prepared = + prepare_response_egress(&Method::HEAD, no_content).expect("204 response"); + assert!(!no_content_prepared.transmits_body()); + assert_eq!(no_content_prepared.declared_length(), None); + assert!( + !no_content_prepared + .into_response() + .headers() + .contains_key("content-length") + ); + + let reset_content = response_builder() + .status(StatusCode::RESET_CONTENT) + .header("content-length", "1") + .body(Body::empty()) + .expect("response"); + let reset_error = prepare_error( + &Method::GET, + reset_content, + "205 with nonzero length must fail", + ); + assert_eq!( + reset_error, + ResponseEgressFramingError::ContentLengthMismatch + ); + + let not_modified = response_builder() + .status(StatusCode::NOT_MODIFIED) + .header("content-length", "9") + .body(Body::from("ignored")) + .expect("response"); + let not_modified_prepared = + prepare_response_egress(&Method::GET, not_modified).expect("304 response"); + assert!(!not_modified_prepared.transmits_body()); + assert_eq!(not_modified_prepared.declared_length(), Some(9)); + + let zero_reset_content = response_builder() + .status(StatusCode::RESET_CONTENT) + .header("content-length", "0") + .body(Body::empty()) + .expect("response"); + let zero_reset_prepared = + prepare_response_egress(&Method::GET, zero_reset_content).expect("zero-length 205"); + assert!(!zero_reset_prepared.transmits_body()); + assert_eq!(zero_reset_prepared.declared_length(), Some(0)); + + let nominated_nonzero = response_builder() + .status(StatusCode::RESET_CONTENT) + .header("connection", "content-length") + .header("content-length", "1") + .body(Body::empty()) + .expect("response"); + let nominated_error = prepare_error( + &Method::GET, + nominated_nonzero, + "205 validation must precede connection nomination stripping", + ); + assert_eq!( + nominated_error, + ResponseEgressFramingError::ContentLengthMismatch + ); + } + + #[test] + fn rejects_tunnel_and_informational_responses_and_removes_transfer_encoding() { + for status in [StatusCode::CONTINUE, StatusCode::SWITCHING_PROTOCOLS] { + let informational = response_builder() + .status(status) + .body(Body::empty()) + .expect("response"); + let informational_error = prepare_error( + &Method::GET, + informational, + "final informational response must fail", + ); + assert_eq!( + informational_error, + ResponseEgressFramingError::UnsupportedStatus + ); + } + + let tunnel = response_builder() + .status(StatusCode::OK) + .body(Body::empty()) + .expect("response"); + let tunnel_error = prepare_error(&Method::CONNECT, tunnel, "successful CONNECT must fail"); + assert_eq!(tunnel_error, ResponseEgressFramingError::UnsupportedTunnel); + + let transfer_encoded = response_builder() + .status(StatusCode::OK) + .header("transfer-encoding", "chunked") + .body(Body::from("body")) + .expect("response"); + let transfer_response = prepare_response_egress(&Method::GET, transfer_encoded) + .expect("response") + .into_response(); + assert!( + !transfer_response + .headers() + .contains_key("transfer-encoding") + ); + + let informational_with_bad_connection = response_builder() + .status(StatusCode::CONTINUE) + .header("connection", "not a valid field name") + .header("trailer", "x-checksum") + .body(Body::empty()) + .expect("response"); + let informational_precedence_error = prepare_error( + &Method::GET, + informational_with_bad_connection, + "status rejection must win", + ); + assert_eq!( + informational_precedence_error, + ResponseEgressFramingError::UnsupportedStatus + ); + + let tunnel_with_trailer = response_builder() + .status(StatusCode::OK) + .header("trailer", "x-checksum") + .body(Body::empty()) + .expect("response"); + let tunnel_precedence_error = prepare_error( + &Method::CONNECT, + tunnel_with_trailer, + "tunnel rejection must win", + ); + assert_eq!( + tunnel_precedence_error, + ResponseEgressFramingError::UnsupportedTunnel + ); + } + + #[test] + fn source_error_diagnostics_are_typed_and_never_include_private_messages() { + let cases = [ + ( + EdgeError::internal(ResponseEgressBodyLengthError::Exceeded), + "declared_length_exceeded", + ), + ( + EdgeError::bad_gateway_with_reason( + "private token=secret", + BadGatewayReason::Decode(BadGatewayDecodeReason::Brotli), + ), + "bad_gateway_decode_brotli", + ), + ( + EdgeError::bad_gateway_with_reason( + "private token=secret", + BadGatewayReason::Decode(BadGatewayDecodeReason::Deflate), + ), + "bad_gateway_decode_deflate", + ), + ( + EdgeError::internal(anyhow::anyhow!("private token=secret")), + "internal", + ), + ]; + + for (error, expected) in cases { + let category = response_egress_source_error_category(&error); + assert_eq!(category, expected); + assert!(!category.contains("private")); + assert!(!category.contains("secret")); + } + } +} diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index e2e58a5c5..08d92e024 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -1,14 +1,15 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use std::task::{Context, Poll}; use matchit::Router as PathRouter; use tower_service::Service; -use crate::context::RequestContext; +use crate::context::{FallbackDrainOutcome, RequestContext, drain_body_discard}; use crate::error::EdgeError; use crate::handler::{BoxHandler, IntoHandler, IntrospectionNeeds}; use crate::http::{Extensions, HandlerFuture, Method, Request, Response}; +use crate::ingress::AdmittedIngress; use crate::introspection::{ManifestJson, RouteTable}; use crate::middleware::{BoxMiddleware, Middleware, Next}; use crate::params::PathParams; @@ -17,6 +18,7 @@ use crate::response::IntoResponse as _; struct RouteEntry { handler: BoxHandler, introspection_needs: IntrospectionNeeds, + metadata: RouteMetadata, } impl Clone for RouteEntry { @@ -24,46 +26,149 @@ impl Clone for RouteEntry { Self { handler: Arc::clone(&self.handler), introspection_needs: self.introspection_needs, + metadata: self.metadata.clone(), } } fn clone_from(&mut self, source: &Self) { self.handler = Arc::clone(&source.handler); self.introspection_needs = source.introspection_needs; + self.metadata.clone_from(&source.metadata); } } -#[derive(Clone, Debug)] -pub struct RouteInfo { +/// Stable identity for one registered route. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct RouteId { method: Method, - path: String, + pattern: String, } -impl RouteInfo { +impl RouteId { #[must_use] #[inline] pub fn method(&self) -> &Method { &self.method } - #[inline] - pub fn new>(method: Method, path: S) -> Self { + fn new>(method: Method, pattern: S) -> Self { Self { method, - path: path.into(), + pattern: pattern.into(), + } + } + + #[must_use] + #[inline] + pub fn pattern(&self) -> &str { + &self.pattern + } +} + +/// Canonical metadata for one registered route. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RouteMetadata { + class: Option>, + id: RouteId, +} + +impl RouteMetadata { + /// Opaque manifest-sourced route class used by admission policy. + #[must_use] + #[inline] + pub fn class(&self) -> Option<&str> { + self.class.as_deref() + } + + #[must_use] + #[inline] + pub fn id(&self) -> &RouteId { + &self.id + } + + #[must_use] + #[inline] + pub fn method(&self) -> &Method { + self.id.method() + } + + #[inline] + pub fn new>(method: Method, pattern: S) -> Self { + Self { + class: None, + id: RouteId::new(method, pattern), } } + fn new_with_class(method: Method, pattern: S, class: C) -> Self + where + C: Into>, + S: Into, + { + Self { + class: Some(class.into()), + id: RouteId::new(method, pattern), + } + } + + /// Registered route pattern. Preserved for existing route-introspection consumers. #[must_use] #[inline] pub fn path(&self) -> &str { - &self.path + self.pattern() + } + + #[must_use] + #[inline] + pub fn pattern(&self) -> &str { + self.id.pattern() + } +} + +/// Backward-compatible name for route-table entries; the value is canonical route metadata. +pub type RouteInfo = RouteMetadata; + +/// Stable result exposed to ingress admission before route dispatch. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum RouteResolution { + Matched(RouteMetadata), + MethodNotAllowed { allowed: Arc<[RouteMetadata]> }, + NotFound, +} + +enum ResolvedTarget { + Found(RouteEntry, PathParams), + MethodNotAllowed(Vec), + NotFound, +} + +/// Opaque route-match token bound to the router that created it. +pub struct ResolvedDispatch { + method: Method, + owner: Arc, + path: String, + resolution: RouteResolution, + target: ResolvedTarget, +} + +impl ResolvedDispatch { + #[must_use] + #[inline] + pub(crate) fn method(&self) -> &Method { + &self.method + } + + #[must_use] + #[inline] + pub fn resolution(&self) -> &RouteResolution { + &self.resolution } } enum RouteMatch<'route> { Found(&'route RouteEntry, PathParams), - MethodNotAllowed(Vec), + MethodNotAllowed(Vec), NotFound, } @@ -83,11 +188,15 @@ impl RouterBuilder { clippy::panic, reason = "duplicate route is a build-time programmer error, not a runtime condition" )] - fn add_route(&mut self, path: &str, method: Method, handler: H) + fn add_route(&mut self, path: &str, method: Method, class: Option>, handler: H) where H: IntoHandler, { - let router = self.routes.entry(method.clone()).or_default(); + let metadata = class.map_or_else( + || RouteMetadata::new(method.clone(), path), + |route_class| RouteMetadata::new_with_class(method.clone(), path, route_class), + ); + let router = self.routes.entry(method).or_default(); // The handler reports which introspection payloads its route needs; the // flag is read once here and consulted per request in `dispatch`. @@ -100,12 +209,12 @@ impl RouterBuilder { RouteEntry { handler: boxed, introspection_needs, + metadata: metadata.clone(), }, ) .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); - self.route_info - .push(RouteInfo::new(method, path.to_owned())); + self.route_info.push(metadata); } #[must_use] @@ -187,7 +296,25 @@ impl RouterBuilder { where H: IntoHandler, { - self.add_route(path, method, handler); + self.add_route(path, method, None, handler); + self + } + + /// Registers a route with opaque admission metadata from the manifest. + #[must_use] + #[inline] + pub fn route_with_class( + mut self, + path: &str, + method: Method, + class: C, + handler: H, + ) -> Self + where + C: Into>, + H: IntoHandler, + { + self.add_route(path, method, Some(class.into()), handler); self } @@ -227,45 +354,62 @@ struct RouterInner { } impl RouterInner { - async fn dispatch(&self, mut request: Request) -> Result { + async fn dispatch(&self, request: Request) -> Result { let method = request.method().clone(); let path = request.uri().path().to_owned(); match self.find_route(&method, &path) { RouteMatch::Found(entry, params) => { - // Inject only the introspection payloads this route asked for — - // nothing for the vast majority of routes that need none. - let needs = entry.introspection_needs; - if needs.manifest - && let Some(json) = &self.manifest_json - { - request - .extensions_mut() - .insert(ManifestJson(Arc::clone(json))); - } - if needs.routes { - request - .extensions_mut() - .insert(RouteTable(Arc::clone(&self.route_index))); - } - // App-owned state registered via RouterBuilder::with_state. - // Runs after introspection inserts; `extend` overwrites by - // TypeId, so app state wins last-write on any collision. - request - .extensions_mut() - .extend(self.state_extensions.clone()); - let ctx = RequestContext::new(request, params); - let next = Next::new(&self.middlewares, entry.handler.as_ref()); - next.run(ctx).await + self.dispatch_found(request, entry, params, None).await } - RouteMatch::MethodNotAllowed(mut allowed) => { - allowed.sort_by(|left, right| left.as_str().cmp(right.as_str())); - Err(EdgeError::method_not_allowed(&method, &allowed)) + RouteMatch::MethodNotAllowed(allowed) => { + let methods = allowed + .iter() + .map(|metadata| metadata.method().clone()) + .collect::>(); + Err(EdgeError::method_not_allowed(&method, &methods)) } RouteMatch::NotFound => Err(EdgeError::not_found(path)), } } + async fn dispatch_found( + &self, + mut request: Request, + entry: &RouteEntry, + params: PathParams, + ingress: Option, + ) -> Result { + // Inject only the introspection payloads this route asked for — + // nothing for the vast majority of routes that need none. + let needs = entry.introspection_needs; + if needs.manifest + && let Some(json) = &self.manifest_json + { + request + .extensions_mut() + .insert(ManifestJson(Arc::clone(json))); + } + if needs.routes { + request + .extensions_mut() + .insert(RouteTable(Arc::clone(&self.route_index))); + } + // App-owned state registered via RouterBuilder::with_state. + // Runs after introspection inserts; `extend` overwrites by TypeId, so app state wins. + request + .extensions_mut() + .extend(self.state_extensions.clone()); + let ctx = match ingress { + Some(admitted) => { + RequestContext::new_routed(request, params, entry.metadata.clone(), admitted) + } + None => RequestContext::new(request, params), + }; + let next = Next::new(&self.middlewares, entry.handler.as_ref()); + next.run(ctx).await + } + fn find_route(&self, method: &Method, path: &str) -> RouteMatch<'_> { if let Some(router) = self.routes.get(method) && let Ok(matched) = router.at(path) @@ -280,17 +424,24 @@ impl RouterInner { return RouteMatch::Found(matched.value, params); } - let allowed: HashSet = self + let mut allowed: Vec = self .routes - .iter() - .filter(|(_, router)| router.at(path).is_ok()) - .map(|(candidate_method, _)| candidate_method.clone()) + .values() + .filter_map(|router| router.at(path).ok()) + .map(|matched| matched.value.metadata.clone()) .collect(); + allowed.sort_by(|left, right| { + left.method() + .as_str() + .cmp(right.method().as_str()) + .then_with(|| left.pattern().cmp(right.pattern())) + }); + if allowed.is_empty() { RouteMatch::NotFound } else { - RouteMatch::MethodNotAllowed(allowed.into_iter().collect()) + RouteMatch::MethodNotAllowed(allowed) } } } @@ -324,6 +475,59 @@ impl RouterService { RouterBuilder::new() } + /// Dispatches the exact route selected before ingress admission without rematching. + /// + /// # Errors + /// Returns [`EdgeError::Internal`] if the token belongs to another router or the request + /// method/path changed after resolution. Normal handler and routing errors are preserved. + #[inline] + pub async fn dispatch_resolved( + &self, + resolved: ResolvedDispatch, + request: Request, + ingress: AdmittedIngress, + ) -> Result { + if !Arc::ptr_eq(&self.inner, &resolved.owner) { + return Err(EdgeError::internal(anyhow::anyhow!( + "resolved dispatch token belongs to another router" + ))); + } + if request.method() != resolved.method || request.uri().path() != resolved.path { + return Err(EdgeError::internal(anyhow::anyhow!( + "request method or path changed after ingress route resolution" + ))); + } + + match resolved.target { + ResolvedTarget::Found(entry, params) => { + if ingress.is_fallback() { + return Err(EdgeError::internal(anyhow::anyhow!( + "fallback body policy cannot dispatch a matched route" + ))); + } + self.inner + .dispatch_found(request, &entry, params, Some(ingress)) + .await + } + ResolvedTarget::MethodNotAllowed(allowed) => { + if let Some(response) = fallback_terminal_response(request, ingress).await? { + return Ok(response); + } + let methods = allowed + .iter() + .map(|metadata| metadata.method().clone()) + .collect::>(); + Err(EdgeError::method_not_allowed(&resolved.method, &methods)) + } + ResolvedTarget::NotFound => { + if let Some(response) = fallback_terminal_response(request, ingress).await? { + return Ok(response); + } + Err(EdgeError::not_found(resolved.path)) + } + } + } + fn new( routes: HashMap>, middlewares: Vec, @@ -354,6 +558,33 @@ impl RouterService { } } + /// Resolves a route without invoking middleware, handlers, or request-body code. + #[must_use] + #[inline] + pub fn resolve(&self, method: &Method, path: &str) -> ResolvedDispatch { + let (resolution, target) = match self.inner.find_route(method, path) { + RouteMatch::Found(entry, params) => ( + RouteResolution::Matched(entry.metadata.clone()), + ResolvedTarget::Found(entry.clone(), params), + ), + RouteMatch::MethodNotAllowed(allowed) => ( + RouteResolution::MethodNotAllowed { + allowed: Arc::from(allowed.clone()), + }, + ResolvedTarget::MethodNotAllowed(allowed), + ), + RouteMatch::NotFound => (RouteResolution::NotFound, ResolvedTarget::NotFound), + }; + + ResolvedDispatch { + method: method.clone(), + owner: Arc::clone(&self.inner), + path: path.to_owned(), + resolution, + target, + } + } + #[must_use] #[inline] pub fn routes(&self) -> Vec { @@ -361,6 +592,31 @@ impl RouterService { } } +async fn fallback_terminal_response( + request: Request, + ingress: AdmittedIngress, +) -> Result, EdgeError> { + let Some(fallback) = ingress.into_fallback() else { + return Ok(None); + }; + let (grant, max_body_bytes, read_deadline, monotonic_clock, on_exceeded, on_timeout) = + fallback.into_parts(); + let outcome = drain_body_discard( + request.into_body(), + max_body_bytes, + read_deadline, + &monotonic_clock, + ) + .await; + drop(grant); + + match outcome? { + FallbackDrainOutcome::Complete => Ok(None), + FallbackDrainOutcome::Exceeded => Ok(Some(on_exceeded.into_response())), + FallbackDrainOutcome::TimedOut => Ok(Some(on_timeout.into_response())), + } +} + #[cfg(test)] mod tests { /// Per-capability introspection injection: a route receives exactly the @@ -396,7 +652,8 @@ mod tests { fn manifest_and_routes_route_injects_both() { // Combined `#[action(manifest, routes)]` → both payloads injected. let seen = run_probe( - RouterService::builder().with_manifest_json("{\"app\":{\"name\":\"t\"}}"), + RouterService::builder() + .with_manifest_json(serde_json::json!({"app": {"name": "t"}}).to_string()), IntrospectionNeeds { manifest: true, routes: true, @@ -409,7 +666,8 @@ mod tests { fn manifest_route_injects_only_manifest() { // Manifest available AND requested → ManifestJson present, RouteTable absent. let seen = run_probe( - RouterService::builder().with_manifest_json("{\"app\":{\"name\":\"t\"}}"), + RouterService::builder() + .with_manifest_json(serde_json::json!({"app": {"name": "t"}}).to_string()), IntrospectionNeeds { manifest: true, routes: false, @@ -437,7 +695,7 @@ mod tests { let saw: Arc>> = Arc::new(Mutex::new(None)); let router = RouterService::builder() - .with_manifest_json("{\"app\":{\"name\":\"t\"}}") + .with_manifest_json(serde_json::json!({"app": {"name": "t"}}).to_string()) .middleware(Probe(Arc::clone(&saw))) .get( "/", @@ -463,7 +721,8 @@ mod tests { fn plain_route_injects_neither() { // Manifest IS baked but the route requested nothing → neither injected. let seen = run_probe( - RouterService::builder().with_manifest_json("{\"app\":{\"name\":\"t\"}}"), + RouterService::builder() + .with_manifest_json(serde_json::json!({"app": {"name": "t"}}).to_string()), IntrospectionNeeds::default(), ); assert_eq!(seen, (false, false)); @@ -506,17 +765,178 @@ mod tests { } use super::*; + use crate::app::App; use crate::body::Body; use crate::context::RequestContext; use crate::error::EdgeError; use crate::http::{Method, Request, Response, StatusCode, request_builder}; + use crate::ingress::{ + AdmissionDecision, IngressAdmissionOutcome, IngressFraming, IngressGrant, IngressHead, + IngressHeadAccounting, + }; use crate::params::PathParams; use crate::response::response_with_body; + use crate::response_egress::ResponseEgressCompletion; + use crate::time::{Deadline, MonotonicInstant}; use futures::executor::block_on; use futures::task::noop_waker_ref; use serde::Deserialize; use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; + use std::time::Duration; + + #[test] + fn resolve_exposes_stable_route_identity_and_sorted_method_candidates() { + async fn handler(_ctx: RequestContext) -> Result { + response_with_body(StatusCode::OK, Body::empty()) + } + + let router = RouterService::builder() + .post("/users/{id}", handler) + .route_with_class("/users/{id}", Method::GET, "auction", handler) + .build(); + + let matched = router.resolve(&Method::GET, "/users/42"); + let RouteResolution::Matched(metadata) = matched.resolution() else { + panic!("expected matched route"); + }; + assert_eq!(metadata.method(), Method::GET); + assert_eq!(metadata.pattern(), "/users/{id}"); + assert_eq!(metadata.id().method(), Method::GET); + assert_eq!(metadata.id().pattern(), "/users/{id}"); + assert_eq!(metadata.class(), Some("auction")); + + let rejected = router.resolve(&Method::DELETE, "/users/99"); + let RouteResolution::MethodNotAllowed { allowed } = rejected.resolution() else { + panic!("expected method-not-allowed route"); + }; + assert_eq!(allowed.len(), 2); + assert_eq!(allowed[0].method(), Method::GET); + assert_eq!(allowed[1].method(), Method::POST); + assert_eq!(allowed[0].class(), Some("auction")); + assert_eq!(allowed[1].class(), None); + + assert!(matches!( + router.resolve(&Method::GET, "/missing").resolution(), + RouteResolution::NotFound + )); + } + + fn admitted_for(resolved: &ResolvedDispatch, method: Method, path: &str) -> AdmittedIngress { + let app = App::new(RouterService::builder().build()); + let request = request_builder() + .method(method) + .uri(path) + .body(Body::empty()) + .expect("request"); + let head = IngressHead::from_request( + &request, + MonotonicInstant::now(), + resolved.resolution().clone(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + let IngressAdmissionOutcome::Admitted { + completion, + ingress: admitted, + } = app.admit_ingress(&head).expect("admission") + else { + panic!("expected admission"); + }; + drop(completion); + admitted + } + + #[test] + fn dispatch_resolved_preserves_metadata_and_grant() { + async fn handler(ctx: RequestContext) -> Result { + let metadata = ctx.route_metadata().expect("route metadata"); + let grant = ctx.take_ingress_grant().expect("ingress grant"); + assert!(ctx.take_ingress_grant().is_none()); + let lease = grant.downcast::().expect("lease type"); + Ok(format!( + "{}:{}:{lease}", + metadata.pattern(), + metadata.class().expect("route class") + )) + } + + let router = RouterService::builder() + .route_with_class("/users/{id}", Method::GET, "auction", handler) + .build(); + let resolved = router.resolve(&Method::GET, "/users/42"); + let start = MonotonicInstant::now(); + let request = request_builder() + .method(Method::GET) + .uri("/users/42") + .body(Body::empty()) + .expect("request"); + let head = IngressHead::from_request( + &request, + start, + resolved.resolution().clone(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + let mut app = App::new(router.clone()); + app.set_ingress_admission_policy(|_| AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::new(String::from("lease")), + read_deadline: Deadline::after(Duration::from_secs(1)), + }); + let IngressAdmissionOutcome::Admitted { + completion, + ingress: admitted, + } = app.admit_ingress(&head).expect("admission") + else { + panic!("expected admission"); + }; + drop(completion); + assert_eq!(admitted.request_start(), start); + let response = block_on(router.dispatch_resolved(resolved, request, admitted)) + .expect("resolved response"); + assert_eq!( + response.body().as_bytes().expect("buffered"), + b"/users/{id}:auction:lease" + ); + } + + #[test] + fn dispatch_resolved_rejects_foreign_router_token() { + let router = RouterService::builder() + .get("/users/{id}", ok_handler) + .build(); + let resolved = router.resolve(&Method::GET, "/users/42"); + let admitted = admitted_for(&resolved, Method::GET, "/users/42"); + let request = request_builder() + .method(Method::GET) + .uri("/users/42") + .body(Body::empty()) + .expect("request"); + let foreign = RouterService::builder().build(); + assert!(matches!( + block_on(foreign.dispatch_resolved(resolved, request, admitted)), + Err(EdgeError::Internal { .. }) + )); + } + + #[test] + fn dispatch_resolved_rejects_mutated_request_path() { + let router = RouterService::builder() + .get("/users/{id}", ok_handler) + .build(); + let resolved = router.resolve(&Method::GET, "/users/42"); + let admitted = admitted_for(&resolved, Method::GET, "/users/42"); + let request = request_builder() + .method(Method::GET) + .uri("/users/43") + .body(Body::empty()) + .expect("request"); + assert!(matches!( + block_on(router.dispatch_resolved(resolved, request, admitted)), + Err(EdgeError::Internal { .. }) + )); + } async fn ok_handler(_ctx: RequestContext) -> Result { response_with_body(StatusCode::OK, Body::empty()) @@ -719,6 +1139,7 @@ mod tests { let entry = RouteEntry { handler: ok_handler.into_handler(), introspection_needs: IntrospectionNeeds::default(), + metadata: RouteMetadata::new(Method::GET, "/test"), }; let cloned = entry.clone(); @@ -888,16 +1309,16 @@ mod tests { } #[crate::action] async fn probe(ctx: RequestContext) -> Result { - let request = ctx.request(); - let counter = request.extensions().get::>().unwrap(); + let body = ctx.body_bytes(1024).await?; + let counter = ctx.extensions().get::>().unwrap(); counter.fetch_add(1, Ordering::SeqCst); Ok(format!( "{}:{}:{}:{}:{}", ctx.path_params().get("id").unwrap(), - request.headers()["x-request-token"].to_str().unwrap(), - from_utf8(ctx.body().as_bytes().unwrap()).unwrap(), - request.extensions().get::().unwrap().0, - request.extensions().get::().unwrap().0 + ctx.headers()["x-request-token"].to_str().unwrap(), + from_utf8(&body).unwrap(), + ctx.extensions().get::().unwrap().0, + ctx.extensions().get::().unwrap().0 )) } let (send1, recv1) = oneshot::channel(); @@ -956,12 +1377,7 @@ mod tests { #[crate::action] async fn probe(ctx: RequestContext) -> Result { - assert!( - ctx.request() - .extensions() - .get::() - .is_none() - ); + assert!(ctx.extensions().get::().is_none()); Ok(ctx.path_params().get("id").unwrap().to_owned()) } diff --git a/crates/edgezero-core/src/secret_store.rs b/crates/edgezero-core/src/secret_store.rs index f5cd44540..e998f264b 100644 --- a/crates/edgezero-core/src/secret_store.rs +++ b/crates/edgezero-core/src/secret_store.rs @@ -26,7 +26,9 @@ use std::sync::Arc; use async_trait::async_trait; use bytes::Bytes; +use crate::config_store::BoundedStoreRead; use crate::error::EdgeError; +use crate::time::{Deadline, MonotonicClock}; // --------------------------------------------------------------------------- // Contract test macro @@ -111,6 +113,10 @@ pub const MAX_NAME_LEN: usize = 512; #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum SecretError { + /// The absolute read deadline expired before a complete value was available. + #[error("secret store read deadline exceeded")] + DeadlineExceeded, + /// A general internal error. #[error("secret store error: {0}")] Internal(#[from] anyhow::Error), @@ -126,12 +132,19 @@ pub enum SecretError { /// A validation error (e.g., invalid secret name). #[error("validation error: {0}")] Validation(String), + + /// The value or guest-visible backend read exceeded its supplied allowance. + #[error("secret value exceeds configured byte limit")] + ValueTooLarge, } impl From for EdgeError { #[inline] fn from(err: SecretError) -> Self { match err { + SecretError::DeadlineExceeded => { + EdgeError::service_unavailable("secret store read deadline exceeded") + } SecretError::NotFound { .. } => { EdgeError::internal(anyhow::anyhow!("required secret is not configured")) } @@ -139,6 +152,9 @@ impl From for EdgeError { SecretError::Validation(..) => { EdgeError::internal(anyhow::anyhow!("secret lookup failed")) } + SecretError::ValueTooLarge => { + EdgeError::internal(anyhow::anyhow!("secret value too large")) + } SecretError::Internal(..) => { EdgeError::internal(anyhow::anyhow!("secret store operation failed")) } @@ -186,6 +202,36 @@ impl SecretStore for InMemorySecretStore { let compound = format!("{store_name}/{key}"); Ok(self.secrets.get(&compound).cloned()) } + + #[inline] + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + if deadline.is_expired_at(clock.now()) { + return Err(SecretError::DeadlineExceeded); + } + let result = self.get_bytes(store_name, key).await; + if deadline.is_expired_at(clock.now()) { + return Err(SecretError::DeadlineExceeded); + } + let value = result?; + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.len()).map_err(|_length_error| SecretError::ValueTooLarge) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + return Err(SecretError::ValueTooLarge); + } + Ok(BoundedStoreRead { + backend_bytes, + value, + }) + } } // --------------------------------------------------------------------------- @@ -205,6 +251,25 @@ impl SecretStore for NoopSecretStore { async fn get_bytes(&self, _store_name: &str, _key: &str) -> Result, SecretError> { Ok(None) } + + #[inline] + async fn get_bytes_bounded( + &self, + _store_name: &str, + _key: &str, + clock: &MonotonicClock, + deadline: Deadline, + _max_backend_bytes: u64, + _max_value_bytes: u64, + ) -> Result, SecretError> { + if deadline.is_expired_at(clock.now()) { + return Err(SecretError::DeadlineExceeded); + } + Ok(BoundedStoreRead { + backend_bytes: 0, + value: None, + }) + } } // --------------------------------------------------------------------------- @@ -242,6 +307,34 @@ impl SecretHandle { self.provider.get_bytes(store_name, key).await } + /// Retrieve a secret under one absolute deadline and two byte limits. + /// + /// # Errors + /// Preserves validation and the provider's typed bounded-read errors. + #[inline] + pub async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + validate_name(store_name)?; + validate_name(key)?; + self.provider + .get_bytes_bounded( + store_name, + key, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + .await + } + /// Create a new handle wrapping a multi-store provider. #[inline] pub fn new(provider: Arc) -> Self { @@ -294,12 +387,119 @@ impl SecretHandle { pub trait SecretStore: Send + Sync { /// Retrieve a secret from a named store. Returns `Ok(None)` if not found. async fn get_bytes(&self, store_name: &str, key: &str) -> Result, SecretError>; + + /// Retrieves one value under an absolute deadline and independent backend/value caps. + /// + /// Implementations must define their deadline and allocation behavior explicitly. There is + /// no compatibility default because awaiting an unbounded provider call can hide a + /// never-ready future. + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError>; +} + +#[cfg(test)] +macro_rules! ready_secret_store_bounded_read { + () => { + fn get_bytes_bounded<'life0, 'life1, 'life2, 'life3, 'async_trait>( + &'life0 self, + store_name: &'life1 str, + key: &'life2 str, + clock: &'life3 $crate::time::MonotonicClock, + deadline: $crate::time::Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> ::std::pin::Pin< + Box< + dyn ::std::future::Future< + Output = Result< + $crate::config_store::BoundedStoreRead<::bytes::Bytes>, + $crate::secret_store::SecretError, + >, + > + 'async_trait, + >, + > + where + 'life0: 'async_trait, + 'life1: 'async_trait, + 'life2: 'async_trait, + 'life3: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + if deadline.is_expired_at(clock.now()) { + return Err($crate::secret_store::SecretError::DeadlineExceeded); + } + let Some(result) = + ::futures_util::FutureExt::now_or_never(self.get_bytes(store_name, key)) + else { + return Err($crate::secret_store::SecretError::Internal( + ::anyhow::anyhow!("ready secret-store test fixture returned pending"), + )); + }; + if deadline.is_expired_at(clock.now()) { + return Err($crate::secret_store::SecretError::DeadlineExceeded); + } + let value = result?; + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.len()) + .map_err(|_length_error| $crate::secret_store::SecretError::ValueTooLarge) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + return Err($crate::secret_store::SecretError::ValueTooLarge); + } + Ok($crate::config_store::BoundedStoreRead { + backend_bytes, + value, + }) + }) + } + }; } // --------------------------------------------------------------------------- // Shared validation // --------------------------------------------------------------------------- +/// Applies post-read deadline precedence and byte caps to one materialized secret result. +/// +/// This helper does not dispatch, await, cancel, or time out the provider operation. A +/// [`SecretStore::get_bytes_bounded`] implementation must still reject an already-expired +/// deadline and arrange any target-specific timer race before calling it. +/// +/// # Errors +/// Returns the provider error, [`SecretError::DeadlineExceeded`], or +/// [`SecretError::ValueTooLarge`] according to post-read precedence. +#[inline] +pub fn finish_bounded_secret_read( + result: Result, SecretError>, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, +) -> Result, SecretError> { + if deadline.is_expired_at(clock.now()) { + return Err(SecretError::DeadlineExceeded); + } + let value = result?; + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.len()).map_err(|_length_error| SecretError::ValueTooLarge) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + return Err(SecretError::ValueTooLarge); + } + Ok(BoundedStoreRead { + backend_bytes, + value, + }) +} + fn validate_name(name: &str) -> Result<(), SecretError> { if name.is_empty() { return Err(SecretError::Validation( @@ -336,8 +536,11 @@ mod tests { use super::*; use crate::http::StatusCode; + use crate::time::{MonotonicClock, MonotonicInstant}; use bytes::Bytes; use futures::executor::block_on; + use std::sync::Mutex; + use std::time::Duration; fn provider_handle_with(entries: &[(&str, &str)]) -> SecretHandle { let provider = InMemorySecretStore::new( @@ -375,6 +578,100 @@ mod tests { }); } + #[test] + fn bounded_secret_exact_cap_succeeds_and_over_cap_discards_value() { + use crate::time::Deadline; + + let handle = provider_handle_with(&[("signing-keys/current", "abc123")]); + let clock = MonotonicClock::default(); + let exact = block_on(handle.get_bytes_bounded( + "signing-keys", + "current", + &clock, + Deadline::after(Duration::from_secs(1)), + 6, + 6, + )) + .expect("exact bounded read"); + assert_eq!(exact.backend_bytes, 6); + assert_eq!(exact.value, Some(Bytes::from_static(b"abc123"))); + + let error = block_on(handle.get_bytes_bounded( + "signing-keys", + "current", + &clock, + Deadline::after(Duration::from_secs(1)), + 6, + 5, + )) + .expect_err("value cap"); + assert!(matches!(error, SecretError::ValueTooLarge)); + } + + #[test] + fn bounded_secret_read_uses_injected_clock() { + let handle = provider_handle_with(&[("signing-keys/current", "abc123")]); + let process_now = MonotonicInstant::now(); + let injected_now = process_now + .checked_sub(Duration::from_mins(1)) + .expect("injected instant"); + let deadline = Deadline::at_instant( + injected_now + .checked_add(Duration::from_secs(1)) + .expect("deadline instant"), + ); + let clock = MonotonicClock::new(move || injected_now); + + let read = + block_on(handle.get_bytes_bounded("signing-keys", "current", &clock, deadline, 6, 6)) + .expect("the application clock is still before its deadline"); + + assert_eq!(read.value, Some(Bytes::from_static(b"abc123"))); + } + + #[test] + fn bounded_secret_read_deadline_wins_ready_provider_error() { + struct AdvancingErrorStore { + now: Arc>, + terminal: MonotonicInstant, + } + + #[async_trait(?Send)] + impl SecretStore for AdvancingErrorStore { + async fn get_bytes( + &self, + _store_name: &str, + _key: &str, + ) -> Result, SecretError> { + *self.now.lock().expect("clock lock") = self.terminal; + Err(SecretError::Unavailable) + } + + ready_secret_store_bounded_read!(); + } + + let start = MonotonicInstant::now(); + let terminal = start + .checked_add(Duration::from_secs(1)) + .expect("terminal instant"); + let now = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&now); + let clock = MonotonicClock::new(move || *clock_now.lock().expect("clock lock")); + let handle = SecretHandle::new(Arc::new(AdvancingErrorStore { now, terminal })); + + let error = block_on(handle.get_bytes_bounded( + "signing-keys", + "current", + &clock, + Deadline::at_instant(terminal), + 6, + 6, + )) + .expect_err("equality expiry must beat a ready provider error"); + + assert!(matches!(error, SecretError::DeadlineExceeded)); + } + #[test] fn provider_handle_require_bytes_errors_for_missing() { let handle = provider_handle_with(&[]); diff --git a/crates/edgezero-core/src/store_registry.rs b/crates/edgezero-core/src/store_registry.rs index e0ffed595..28180fb61 100644 --- a/crates/edgezero-core/src/store_registry.rs +++ b/crates/edgezero-core/src/store_registry.rs @@ -25,9 +25,10 @@ use std::collections::BTreeMap; use bytes::Bytes; -use crate::config_store::ConfigStoreHandle; +use crate::config_store::{BoundedStoreRead, ConfigStoreHandle}; use crate::key_value_store::KvHandle; use crate::secret_store::{SecretError, SecretHandle}; +use crate::time::{Deadline, MonotonicClock}; /// A per-bind KV handle, returned by [`KvRegistry::named`] / [`KvRegistry::default`]. pub type BoundKvStore = KvHandle; @@ -71,6 +72,31 @@ impl BoundSecretStore { self.handle.get_bytes(&self.store_name, key).await } + /// Retrieve a secret against the bound store under one absolute deadline and byte budget. + /// + /// # Errors + /// Preserves validation and the provider's typed bounded-read errors. + #[inline] + pub async fn get_bytes_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + self.handle + .get_bytes_bounded( + &self.store_name, + key, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + .await + } + /// Underlying [`SecretHandle`] (escape hatch for callers that need the /// store-name argument explicitly). #[inline] diff --git a/crates/edgezero-core/src/test_env.rs b/crates/edgezero-core/src/test_env.rs index 70a188936..afd051591 100644 --- a/crates/edgezero-core/src/test_env.rs +++ b/crates/edgezero-core/src/test_env.rs @@ -16,7 +16,8 @@ //! The guards below are RAII: they capture the prior value on construction and //! restore it on drop, so a panicking test cannot leak state into the next one. //! That alone does not make concurrent mutation sound — callers must also -//! serialise env-mutating tests on a process-wide mutex ([`env_lock`], or the +//! serialise env-mutating tests on a process-wide mutex +//! ([`crate::test_env::env_lock()`], or the //! calling crate's own equivalent) and hold it for the guard's whole lifetime. //! The `unsafe` here is sound only under that discipline, which is why this //! module is test-only and never compiled into a production build. @@ -36,7 +37,8 @@ use std::sync::{Mutex, OnceLock}; /// and restores the prior value — or removes the variable if it was previously /// unset — on drop. /// -/// Construct it while holding [`env_lock`] (or the calling crate's equivalent); +/// Construct it while holding [`crate::test_env::env_lock()`] (or the calling +/// crate's equivalent); /// see the module docs for the full safety contract. pub struct EnvOverride { key: OsString, diff --git a/crates/edgezero-core/src/time.rs b/crates/edgezero-core/src/time.rs new file mode 100644 index 000000000..940017493 --- /dev/null +++ b/crates/edgezero-core/src/time.rs @@ -0,0 +1,338 @@ +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use crate::error::{BudgetSource, EdgeError}; +use crate::outbound::OutboundRequest; + +/// Max adapter overhead tolerated before a fan-out slot fails closed. +pub const BATCH_DISPATCH_SLACK_MAX: Duration = Duration::from_millis(25); +/// Hard clamp on any caller-supplied duration, so construction cannot panic. +pub const DEADLINE_FAR_FUTURE: Duration = Duration::from_hours(168); +/// Budget applied when a request sets neither a timeout nor a deadline. +pub const DEFAULT_NO_DEADLINE_BUDGET: Duration = Duration::from_secs(30); + +/// An absolute, copyable monotonic deadline. A deadline at or before now is expired. +#[derive(Debug, Clone, Copy)] +pub struct Deadline(MonotonicInstant); + +/// Effective timeout selected for one outbound dispatch. +#[derive(Clone, Copy, Debug)] +pub struct DispatchBudget { + pub cause: BudgetSource, + pub deadline: Deadline, + pub duration: Duration, +} + +/// Portable monotonic clock instant used by `EdgeZero` timing APIs. +pub type MonotonicInstant = web_time::Instant; + +/// Cloneable monotonic clock source shared by one application and its admitted requests. +#[derive(Clone)] +pub struct MonotonicClock { + now: Arc MonotonicInstant + Send + Sync>, +} + +impl MonotonicClock { + /// Returns a deadline anchored to this clock and clamped to the portable maximum. + #[must_use] + #[inline] + pub fn deadline_after(&self, duration: Duration) -> Deadline { + let now = self.now(); + let clamped = duration.min(DEADLINE_FAR_FUTURE); + Deadline(now.checked_add(clamped).unwrap_or(now)) + } + + #[must_use] + #[inline] + pub fn new(now: Now) -> Self + where + Now: Fn() -> MonotonicInstant + Send + Sync + 'static, + { + Self { now: Arc::new(now) } + } + + #[must_use] + #[inline] + pub fn now(&self) -> MonotonicInstant { + (self.now)() + } +} + +impl Default for MonotonicClock { + #[inline] + fn default() -> Self { + Self::new(MonotonicInstant::now) + } +} + +impl fmt::Debug for MonotonicClock { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MonotonicClock").finish_non_exhaustive() + } +} + +impl Deadline { + /// Returns a deadline anchored to the process-default clock; never panics. + /// + /// Use [`MonotonicClock::deadline_after`] when work is paired with an injected clock. + #[inline] + #[must_use] + pub fn after(duration: Duration) -> Self { + let now = MonotonicInstant::now(); + let clamped = duration.min(DEADLINE_FAR_FUTURE); + Deadline(now.checked_add(clamped).unwrap_or(now)) + } + + /// Constructs a deadline from an absolute instant. + #[inline] + #[must_use] + pub fn at_instant(instant: MonotonicInstant) -> Self { + Deadline(instant) + } + + /// Returns the absolute deadline instant. + #[inline] + #[must_use] + pub fn instant(&self) -> MonotonicInstant { + self.0 + } + + /// Returns `true` once the deadline instant is at or before the process-default clock. + #[inline] + #[must_use] + pub fn is_expired(&self) -> bool { + self.is_expired_at(MonotonicInstant::now()) + } + + /// Returns `true` when this deadline is at or before the supplied clock snapshot. + #[inline] + #[must_use] + pub fn is_expired_at(&self, now: MonotonicInstant) -> bool { + self.remaining_at(now).is_none() + } + + /// Returns process-default-clock time remaining, or `None` once reached or passed. + #[inline] + #[must_use] + pub fn remaining(&self) -> Option { + self.remaining_at(MonotonicInstant::now()) + } + + /// Returns time remaining at an explicit clock snapshot, or `None` at/past expiry. + #[inline] + #[must_use] + pub fn remaining_at(&self, now: MonotonicInstant) -> Option { + self.0 + .checked_duration_since(now) + .filter(|remaining| !remaining.is_zero()) + } +} + +/// Computes one effective outbound deadline from a shared monotonic snapshot. +/// +/// # Errors +/// Returns [`EdgeError::GatewayTimeout`] when the selected budget is already exhausted. +#[inline] +pub fn dispatch_budget( + request: &OutboundRequest, + now: MonotonicInstant, + observation_cutoff: Option, +) -> Result { + let inputs = request.budget_inputs(); + let deadline_from_duration = |duration: Duration| { + let bounded_duration = duration.min(DEADLINE_FAR_FUTURE); + Deadline::at_instant(now.checked_add(bounded_duration).unwrap_or(now)) + }; + + let from_timeout = inputs.timeout.map(deadline_from_duration); + let from_caller = inputs.deadline.map(|deadline| { + let far = now.checked_add(DEADLINE_FAR_FUTURE).unwrap_or(now); + Deadline::at_instant(deadline.instant().min(far)) + }); + let from_observation_cutoff = observation_cutoff.map(|deadline| { + let far = now.checked_add(DEADLINE_FAR_FUTURE).unwrap_or(now); + Deadline::at_instant(deadline.instant().min(far)) + }); + let from_default = (inputs.timeout.is_none() && inputs.deadline.is_none()) + .then(|| deadline_from_duration(DEFAULT_NO_DEADLINE_BUDGET)); + + let (cause, deadline) = [ + from_observation_cutoff.map(|deadline| (BudgetSource::BatchCutoff, deadline)), + from_timeout.map(|deadline| (BudgetSource::PerCallTimeout, deadline)), + from_caller.map(|deadline| (BudgetSource::RequestDeadline, deadline)), + from_default.map(|deadline| (BudgetSource::Default, deadline)), + ] + .into_iter() + .flatten() + .min_by_key(|(_, deadline)| deadline.instant()) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "dispatch_budget: no deadline candidate; invariant violated" + )) + })?; + + let duration = deadline.instant().saturating_duration_since(now); + if duration.is_zero() { + return Err(EdgeError::gateway_timeout_caused( + "effective budget is zero", + cause, + )); + } + Ok(DispatchBudget { + cause, + deadline, + duration, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[test] + fn monotonic_clock_uses_the_injected_source() { + let start = MonotonicInstant::now(); + let now = Arc::new(Mutex::new(start)); + let observed_now = Arc::clone(&now); + let clock = MonotonicClock::new(move || *observed_now.lock().expect("clock lock")); + + assert_eq!(clock.now(), start); + let advanced = start + .checked_add(Duration::from_secs(2)) + .expect("advanced instant"); + *now.lock().expect("clock lock") = advanced; + assert_eq!(clock.now(), advanced); + assert_eq!(clock.clone().now(), advanced); + } + + #[test] + fn monotonic_clock_deadline_after_uses_the_injected_source() { + let start = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || start); + let deadline = clock.deadline_after(Duration::from_secs(2)); + + assert_eq!( + deadline.instant(), + start.checked_add(Duration::from_secs(2)).expect("deadline") + ); + } + + #[test] + fn monotonic_clock_deadline_after_clamps_to_far_future() { + let start = MonotonicInstant::now(); + let clock = MonotonicClock::new(move || start); + let deadline = clock.deadline_after(Duration::MAX); + + assert_eq!( + deadline.instant(), + start.checked_add(DEADLINE_FAR_FUTURE).expect("deadline") + ); + } + + #[test] + fn monotonic_instant_is_public_clock_type() { + let start = MonotonicInstant::now(); + let deadline = Deadline::at_instant(start); + assert_eq!(deadline.instant(), start); + } + + #[test] + fn deadline_is_copy() { + fn assert_copy() {} + assert_copy::(); + } + + #[test] + fn constants_have_exact_values() { + assert_eq!(DEFAULT_NO_DEADLINE_BUDGET, Duration::from_secs(30)); + assert_eq!(DEADLINE_FAR_FUTURE, Duration::from_hours(168)); + assert_eq!(BATCH_DISPATCH_SLACK_MAX, Duration::from_millis(25)); + } + + #[test] + fn deadline_before_now_is_expired() { + let base = MonotonicInstant::now(); + let past = Deadline::at_instant(base); + let now = base + .checked_add(Duration::from_secs(1)) + .expect("no overflow"); + assert!(past.is_expired_at(now)); + assert_eq!(past.remaining_at(now), None); + } + + #[test] + fn deadline_exactly_now_is_expired() { + let base = MonotonicInstant::now(); + let at_now = Deadline::at_instant(base); + assert_eq!( + at_now.remaining_at(base), + None, + "zero remaining is expired, not Some(0)" + ); + assert!( + at_now.is_expired_at(base), + "a deadline exactly at now is expired" + ); + } + + #[test] + fn deadline_in_future_has_exact_remaining() { + let base = MonotonicInstant::now(); + let future = Deadline::at_instant( + base.checked_add(Duration::from_mins(1)) + .expect("no overflow"), + ); + assert!(!future.is_expired_at(base)); + assert_eq!(future.remaining_at(base), Some(Duration::from_mins(1))); + } + + #[test] + fn after_clamps_duration_max_to_far_future() { + let before = MonotonicInstant::now(); + let deadline = Deadline::after(Duration::MAX); + let after = MonotonicInstant::now(); + let lower = before + .checked_add(DEADLINE_FAR_FUTURE) + .expect("no overflow"); + let upper = after.checked_add(DEADLINE_FAR_FUTURE).expect("no overflow"); + assert!(deadline.instant() >= lower, "clamped below the 7-day bound"); + assert!( + deadline.instant() <= upper, + "Duration::MAX was not clamped to 7 days" + ); + } + + #[test] + fn public_remaining_and_is_expired_smoke() { + let before = MonotonicInstant::now(); + let far = Deadline::after(Duration::from_hours(1)); + let after = MonotonicInstant::now(); + assert!(!far.is_expired()); + let lower = before + .checked_add(Duration::from_hours(1)) + .expect("no overflow"); + let upper = after + .checked_add(Duration::from_hours(1)) + .expect("no overflow"); + assert!( + far.instant() >= lower && far.instant() <= upper, + "after() must land exactly at now plus duration" + ); + assert!(far.remaining().is_some()); + + let now_deadline = Deadline::after(Duration::ZERO); + assert!(now_deadline.is_expired()); + assert_eq!(now_deadline.remaining(), None); + } + + #[test] + fn instant_round_trips() { + let base = MonotonicInstant::now() + .checked_add(Duration::from_secs(10)) + .expect("no overflow"); + assert_eq!(Deadline::at_instant(base).instant(), base); + } +} diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index f1dda1aee..b22634cdc 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -1,11 +1,13 @@ -use crate::manifest_definitions::{LogLevel, Manifest, ResolvedLoggingConfig, StoreDeclaration}; +use crate::manifest_definitions::{ + LogLevel, Manifest, ResolvedLoggingConfig, StoreDeclaration, reject_misplaced_capabilities, +}; use proc_macro::TokenStream; use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::quote; use std::collections::BTreeSet; use std::env; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use syn::parse::{Parse, ParseStream}; use syn::{Ident, LitStr, Token, parse_macro_input}; use validator::Validate as _; @@ -13,6 +15,7 @@ use validator::Validate as _; #[derive(Debug)] struct AppArgs { app_ident: Option, + configure: Option, owns_logging: Option, path: LitStr, state: Option, @@ -22,6 +25,7 @@ impl Parse for AppArgs { fn parse(input: ParseStream) -> syn::Result { let path: LitStr = input.parse()?; let mut app_ident: Option = None; + let mut configure: Option = None; let mut owns_logging: Option = None; let mut state: Option = None; let mut seen_keyword = false; @@ -35,6 +39,15 @@ impl Parse for AppArgs { input.parse::()?; seen_keyword = true; match key.to_string().as_str() { + "configure" => { + if configure.is_some() { + return Err(syn::Error::new( + key.span(), + "duplicate `configure` argument", + )); + } + configure = Some(input.parse::()?); + } "owns_logging" => { if owns_logging.is_some() { return Err(syn::Error::new( @@ -55,7 +68,7 @@ impl Parse for AppArgs { return Err(syn::Error::new( key.span(), format!( - "unknown `app!` argument `{other}`; expected `state` or `owns_logging`" + "unknown `app!` argument `{other}`; expected `configure`, `state`, or `owns_logging`" ), )); } @@ -82,6 +95,7 @@ impl Parse for AppArgs { } Ok(Self { app_ident, + configure, owns_logging, path, state, @@ -124,6 +138,29 @@ fn build_stores_tokens(manifest: &Manifest) -> TokenStream2 { } } +fn build_configure_tokens(callback: Option<&syn::Expr>) -> TokenStream2 { + callback.map_or_else( + || { + quote! { + fn configure( + _app: &mut edgezero_core::app::App, + ) -> Result<(), edgezero_core::error::EdgeError> { + Ok(()) + } + } + }, + |configure| { + quote! { + fn configure( + app: &mut edgezero_core::app::App, + ) -> Result<(), edgezero_core::error::EdgeError> { + (#configure)(app) + } + } + }, + ) +} + fn logging_config_tokens(config: &ResolvedLoggingConfig) -> TokenStream2 { let level = match config.level { LogLevel::Trace => quote! { edgezero_core::manifest::LogLevel::Trace }, @@ -199,14 +236,75 @@ fn build_route_tokens(manifest: &Manifest) -> Result, String> }; let handler_path = parse_handler_path(handler)?; let path_lit = LitStr::new(&trigger.path, Span::call_site()); + let class_lit = trigger + .class + .as_deref() + .map(|class| LitStr::new(class, Span::call_site())); for method in trigger.methods() { - tokens.push(route_for_method(method, &path_lit, &handler_path)); + tokens.push(route_for_method( + method, + &path_lit, + class_lit.as_ref(), + &handler_path, + )); } } Ok(tokens) } +fn build_hooks_tokens( + app_ident: &Ident, + configure_tokens: &TokenStream2, + logging_tokens: &TokenStream2, + manifest_json_lit: &LitStr, + owns_logging: bool, + app_name_lit: &LitStr, + stores_tokens: &TokenStream2, +) -> TokenStream2 { + quote! { + pub struct #app_ident; + + impl edgezero_core::app::Hooks for #app_ident { + fn routes() -> edgezero_core::router::RouterService { + build_router() + } + + #configure_tokens + + #logging_tokens + + fn manifest() -> ::edgezero_core::manifest::BakedManifest { + static CACHE: ::std::sync::OnceLock< + ::edgezero_core::manifest::BakedManifest, + > = ::std::sync::OnceLock::new(); + *CACHE.get_or_init(|| { + match ::manifest_json() { + None => ::edgezero_core::manifest::BakedManifest::Absent, + Some(json) => { + ::edgezero_core::manifest::Manifest::from_baked_json(json) + } + } + }) + } + + fn manifest_json() -> Option<&'static str> { + Some(#manifest_json_lit) + } + + fn owns_logging() -> bool { + #owns_logging + } + + fn name() -> &'static str { + #app_name_lit + } + + #stores_tokens + } + } +} + pub fn expand_app(input: TokenStream) -> TokenStream { let args = parse_macro_input!(input as AppArgs); @@ -219,18 +317,10 @@ pub fn expand_app(input: TokenStream) -> TokenStream { } }; - let mut manifest: Manifest = match toml::from_str(&manifest_source) { - Ok(parsed) => parsed, - Err(err) => { - let msg = format!("failed to parse {}: {err}", manifest_path.display()); - return quote!(compile_error!(#msg);).into(); - } + let manifest = match parse_manifest(&manifest_source, &manifest_path) { + Ok(manifest) => manifest, + Err(msg) => return quote!(compile_error!(#msg);).into(), }; - if let Err(err) = manifest.validate() { - let msg = format!("failed to validate {}: {err}", manifest_path.display()); - return quote!(compile_error!(#msg);).into(); - } - manifest.finalize(); let manifest_json = match serde_json::to_string(&manifest) { Ok(json) => json, @@ -264,48 +354,25 @@ pub fn expand_app(input: TokenStream) -> TokenStream { let manifest_path_lit = LitStr::new(&manifest_path.to_string_lossy(), Span::call_site()); let owns_logging_lit = args.owns_logging.unwrap_or(false); - // Emitted only when `state = ` is given; `Option: ToTokens` - // renders `None` as nothing, so an app without `state` is unchanged. + let configure_tokens = build_configure_tokens(args.configure.as_ref()); let state_call = args.state.as_ref().map(|state_expr| { quote! { builder = builder.with_state(#state_expr); } }); + let hooks_tokens = build_hooks_tokens( + &app_ident, + &configure_tokens, + &logging_tokens, + &manifest_json_lit, + owns_logging_lit, + &app_name_lit, + &stores_tokens, + ); - // The emitted `Hooks` impl below explicitly defines `configure`, - // `owns_logging`, and `build_app` even though their bodies mirror the trait - // defaults. This is required because `missing_trait_methods` (restriction = - // deny) forbids relying on trait defaults in the impl. If those `Hooks` - // defaults change, update these emitted bodies to match. let output = quote! { // Force a rebuild when the manifest file changes (include_bytes tracks it as a build input). const _: &[u8] = include_bytes!(#manifest_path_lit); - pub struct #app_ident; - - impl edgezero_core::app::Hooks for #app_ident { - fn routes() -> edgezero_core::router::RouterService { - build_router() - } - - fn configure(_app: &mut edgezero_core::app::App) {} - - fn owns_logging() -> bool { - #owns_logging_lit - } - - #logging_tokens - - fn name() -> &'static str { - #app_name_lit - } - - #stores_tokens - - fn build_app() -> edgezero_core::app::App { - let mut app = edgezero_core::app::App::with_name(Self::routes(), Self::name()); - Self::configure(&mut app); - app - } - } + #hooks_tokens pub fn build_router() -> edgezero_core::router::RouterService { let mut builder = edgezero_core::router::RouterService::builder(); @@ -320,6 +387,21 @@ pub fn expand_app(input: TokenStream) -> TokenStream { output.into() } +fn parse_manifest(source: &str, path: &Path) -> Result { + let value: toml::Value = toml::from_str(source) + .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; + reject_misplaced_capabilities(&value) + .map_err(|error| format!("failed to validate {}: {error}", path.display()))?; + let mut manifest: Manifest = value + .try_into() + .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; + manifest + .validate() + .map_err(|error| format!("failed to validate {}: {error}", path.display()))?; + manifest.finalize(); + Ok(manifest) +} + /// Parses a handler reference like `crate::handlers::root` from `edgezero.toml` /// into the `syn::ExprPath` that the generated router code references. /// @@ -370,7 +452,32 @@ fn resolve_manifest_path(relative: String) -> PathBuf { PathBuf::from(manifest_dir).join(relative) } -fn route_for_method(method: &str, path: &LitStr, handler: &syn::ExprPath) -> TokenStream2 { +fn route_for_method( + method: &str, + path: &LitStr, + class: Option<&LitStr>, + handler: &syn::ExprPath, +) -> TokenStream2 { + if let Some(route_class) = class { + let method_tokens = match method { + "GET" => quote! { edgezero_core::http::Method::GET }, + "POST" => quote! { edgezero_core::http::Method::POST }, + "PUT" => quote! { edgezero_core::http::Method::PUT }, + "DELETE" => quote! { edgezero_core::http::Method::DELETE }, + custom_method => { + let method_bytes = + syn::LitByteStr::new(custom_method.as_bytes(), Span::call_site()); + quote! { + edgezero_core::http::Method::from_bytes(#method_bytes) + .expect("invalid HTTP method in manifest") + } + } + }; + return quote! { + builder = builder.route_with_class(#path, #method_tokens, #route_class, #handler); + }; + } + match method { "GET" => quote! { builder = builder.get(#path, #handler); }, "POST" => quote! { builder = builder.post(#path, #handler); }, @@ -442,6 +549,25 @@ mod tests { assert_eq!(args.owns_logging, None); } + #[test] + fn app_args_parses_configure_expr() { + let args: AppArgs = + parse_str(r#""edgezero.toml", configure = crate::configure_app"#).expect("parse"); + let rendered = args.configure.map(|expr| quote::quote!(#expr).to_string()); + assert_eq!(rendered, Some("crate :: configure_app".to_owned())); + } + + #[test] + fn app_args_parses_configure_with_other_keywords() { + let args: AppArgs = parse_str( + r#""edgezero.toml", MyApp, state = crate::app_state(), configure = crate::configure_app, owns_logging = true"#, + ) + .expect("parse"); + assert!(args.configure.is_some()); + assert!(args.state.is_some()); + assert_eq!(args.owns_logging, Some(true)); + } + #[test] fn app_args_parses_state_with_app_ident_and_owns_logging() { let args: AppArgs = @@ -462,6 +588,18 @@ mod tests { assert!(err.to_string().contains("duplicate `state`"), "got: {err}"); } + #[test] + fn app_args_rejects_duplicate_configure() { + let err = parse_str::( + r#""edgezero.toml", configure = crate::first, configure = crate::second"#, + ) + .expect_err("duplicate configure"); + assert!( + err.to_string().contains("duplicate `configure`"), + "got: {err}" + ); + } + #[test] fn app_args_rejects_duplicate_key() { let err = @@ -492,6 +630,7 @@ mod tests { err.to_string().contains("unknown `app!` argument `bogus`"), "got: {err}" ); + assert!(err.to_string().contains("`configure`"), "got: {err}"); } #[test] @@ -560,6 +699,31 @@ handler = "crate::handlers::root" assert_eq!(tokens.len(), 3); } + #[test] + fn build_route_tokens_propagates_manifest_route_class() { + let manifest: Manifest = toml::from_str( + r#" +[app] +name = "demo" +entry = "crates/demo-core" + +[[triggers.http]] +class = "auction" +path = "/bid" +methods = ["GET", "POST"] +handler = "crate::handlers::bid" +"#, + ) + .expect("manifest TOML should parse"); + let tokens = build_route_tokens(&manifest).expect("valid manifest builds routes"); + assert_eq!(tokens.len(), 2); + for token in tokens { + let rendered = token.to_string(); + assert!(rendered.contains("route_with_class"), "{rendered}"); + assert!(rendered.contains("auction"), "{rendered}"); + } + } + #[test] fn build_route_tokens_skips_trigger_without_handler() { let manifest: Manifest = toml::from_str( diff --git a/crates/edgezero-macros/tests/app_macro.rs b/crates/edgezero-macros/tests/app_macro.rs index f32cf2d5c..18ad60a37 100644 --- a/crates/edgezero-macros/tests/app_macro.rs +++ b/crates/edgezero-macros/tests/app_macro.rs @@ -33,3 +33,134 @@ mod tests { assert!(missing.echo_stdout.is_none()); } } + +#[cfg(test)] +mod configured_app { + use edgezero_core::app::App; + use edgezero_core::body::Body; + use edgezero_core::config_store::ConfigExtractionLimits; + use edgezero_core::http::{HeaderMap, Method, Response, StatusCode, Uri, Version}; + use edgezero_core::ingress::{AdmissionDecision, IngressBeginOutcome, IngressHeadParts}; + use edgezero_core::response_egress::ResponseEgressCompletion; + use edgezero_core::time::MonotonicInstant; + + fn configure_app(app: &mut App) -> Result<(), edgezero_core::EdgeError> { + app.set_config_extraction_limits(ConfigExtractionLimits::default())?; + app.set_ingress_admission_policy(|_| { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::TOO_MANY_REQUESTS; + AdmissionDecision::Refuse { + completion: ResponseEgressCompletion::empty(), + response, + } + }); + Ok(()) + } + + edgezero_core::app!( + "tests/fixtures/owns_logging.toml", + ConfiguredApp, + configure = configure_app + ); + + #[test] + fn app_macro_configure_callback_installs_ingress_policy() { + let app = App::build::(edgezero_core::PlatformMetadata::default()) + .expect("configured app"); + let head = IngressHeadParts::new( + Method::GET, + Uri::from_static("/"), + Version::HTTP_11, + HeaderMap::new(), + ); + let outcome = app + .begin_ingress(head, MonotonicInstant::now()) + .expect("begin ingress"); + let IngressBeginOutcome::Refused(response) = outcome else { + panic!("configured policy must refuse ingress"); + }; + let Ok((prepared, _, mut attempt, clock)) = response.begin() else { + panic!("begin response egress"); + }; + assert!(attempt.begin_writing()); + assert!(attempt.complete(clock.now())); + assert_eq!( + prepared.into_response().status(), + StatusCode::TOO_MANY_REQUESTS + ); + } +} + +#[cfg(test)] +mod failing_configured_app { + use edgezero_core::app::App; + use edgezero_core::error::EdgeError; + use edgezero_core::http::StatusCode; + + fn configure_app(_app: &mut App) -> Result<(), EdgeError> { + Err(EdgeError::service_unavailable("configuration unavailable")) + } + + edgezero_core::app!( + "tests/fixtures/owns_logging.toml", + FailingConfiguredApp, + configure = configure_app + ); + + #[test] + fn app_macro_propagates_configuration_failure() { + let Err(error) = + App::build::(edgezero_core::PlatformMetadata::default()) + else { + panic!("configuration failure must stop application assembly"); + }; + assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE); + } +} + +#[cfg(test)] +mod platform_configured_app { + use std::num::NonZeroU32; + + use edgezero_core::app::App; + use edgezero_core::{ + EdgeError, HostIngressMemoryAccounting, InboundRequestPopulationBound, MemoryCeiling, + MemoryCeilingScope, PlatformFact, PlatformMetadata, PlatformResourceSource, + }; + + const SOURCE: PlatformResourceSource = PlatformResourceSource::PlatformLimit { + provider: "macro-test", + }; + const TEST_PLATFORM: PlatformMetadata = PlatformMetadata::new( + PlatformFact::known( + MemoryCeiling::new(64_000_000, MemoryCeilingScope::PerExecution, None), + SOURCE, + ), + PlatformFact::known(InboundRequestPopulationBound::new(NonZeroU32::MIN), SOURCE), + PlatformFact::known(HostIngressMemoryAccounting::OutsideCeiling, SOURCE), + ); + + fn configure_app(app: &mut App) -> Result<(), EdgeError> { + if app.platform() == TEST_PLATFORM { + Ok(()) + } else { + Err(EdgeError::service_unavailable( + "platform metadata unavailable during configuration", + )) + } + } + + edgezero_core::app!( + "tests/fixtures/owns_logging.toml", + PlatformConfiguredApp, + configure = configure_app + ); + + #[test] + fn app_macro_installs_platform_metadata_before_configuration() { + let app = App::build::(TEST_PLATFORM) + .expect("platform-aware configured app"); + + assert_eq!(app.platform(), TEST_PLATFORM); + } +} diff --git a/crates/edgezero-macros/tests/nested_secrets_e2e.rs b/crates/edgezero-macros/tests/nested_secrets_e2e.rs index 5f8a7d1f7..e9876948c 100644 --- a/crates/edgezero-macros/tests/nested_secrets_e2e.rs +++ b/crates/edgezero-macros/tests/nested_secrets_e2e.rs @@ -14,7 +14,9 @@ use async_trait::async_trait; use edgezero_core::blob_envelope::BlobEnvelope; use edgezero_core::body::Body; -use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; +use edgezero_core::config_store::{ + BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigStoreHandle, +}; use edgezero_core::context::RequestContext; use edgezero_core::extractor::{AppConfig as AppConfigExtractor, FromRequest as _}; use edgezero_core::http::{Method, request_builder}; @@ -23,6 +25,7 @@ use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; use edgezero_core::store_registry::{ BoundSecretStore, ConfigRegistry, ConfigStoreBinding, SecretRegistry, StoreRegistry, }; +use edgezero_core::time::{Deadline, MonotonicClock}; use futures::executor::block_on; use std::collections::BTreeMap; use std::sync::Arc; @@ -80,8 +83,39 @@ struct BlobStore(String); #[async_trait(?Send)] impl ConfigStore for BlobStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) + } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + let value = self.get(key).await?; + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + let backend_bytes = value.as_ref().map_or(Ok(0_u64), |stored_value| { + u64::try_from(stored_value.len()) + .map_err(|_length_error| ConfigStoreError::ValueTooLarge) + })?; + if backend_bytes > max_backend_bytes || backend_bytes > max_value_bytes { + return Err(ConfigStoreError::ValueTooLarge); + } + Ok(BoundedStoreRead { + backend_bytes, + value, + }) } } diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 14bdca0a9..f866b9293 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -36,8 +36,9 @@ export default defineConfig({ { text: 'Routing', link: '/guide/routing' }, { text: 'Handlers & Extractors', link: '/guide/handlers' }, { text: 'Middleware', link: '/guide/middleware' }, + { text: 'Capabilities', link: '/guide/capabilities' }, { text: 'Streaming', link: '/guide/streaming' }, - { text: 'Proxying', link: '/guide/proxying' }, + { text: 'Outbound HTTP', link: '/guide/proxying' }, { text: 'KV Storage', link: '/guide/kv' }, ], }, diff --git a/docs/audits/2026-09-27-brotli-decoder-memory-accounting.md b/docs/audits/2026-09-27-brotli-decoder-memory-accounting.md new file mode 100644 index 000000000..49d994fb2 --- /dev/null +++ b/docs/audits/2026-09-27-brotli-decoder-memory-accounting.md @@ -0,0 +1,62 @@ +# Brotli Decoder Memory Accounting Audit + +This audit owns `BROTLI_DECODER_FIXED_CHARGE_BYTES = 16,777,216`. It derives an +allocation-payload subtotal for the source-owned non-window storage in EdgeZero's pinned Brotli +response decoder. It does not claim an exact total-heap bound. The ring buffer's `2^WBITS` bytes +are excluded from the fixed term because `brotli_decoder_memory_charge` charges them +independently; the fixed term below includes its additional 66-byte write-ahead/dictionary slack. + +## Audited Graph + +| Package | Version | Registry checksum | +| --------------------- | -------- | ------------------------------------------------------------------ | +| `alloc-no-stdlib` | `2.0.4` | `cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3` | +| `alloc-stdlib` | `0.2.4` | `0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195` | +| `async-compression` | `0.4.43` | `3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8` | +| `brotli` | `8.0.4` | `5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3` | +| `brotli-decompressor` | `5.0.1` | `5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924` | +| `compression-codecs` | `0.4.38` | `ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf` | +| `compression-core` | `0.4.32` | `cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789` | + +`compression-codecs` boxes one `brotli::BrotliState` and constructs it with the pinned +`alloc-stdlib::StandardAlloc` for byte, `u32`, and `HuffmanCode` storage. +`brotli-decompressor`'s `DecodeVarLenUint8` produces at most 255 before the caller adds one, so +every block-type or Huffman-tree count is at most 256. `BROTLI_HUFFMAN_MAX_TABLE_SIZE` is 1080, +and the pinned `#[repr(C)] HuffmanCode` occupies four bytes. The colocated core regression test +checks the compiled state/code sizes and EdgeZero bridge-buffer size. The lockfile checksum gate +protects private table, parser, and ring-layout assumptions that cannot be imported as public +constants. + +## Audited Allocation-Payload Subtotal + +| Allocation family | Maximum bytes | +| ------------------------------------------ | -------------------------------: | +| Boxed `BrotliState` payload reserve | 65,536 | +| Initial context-map Huffman table | `1080 * 4 = 4,320` | +| Block-type and block-length trees | `2 * 3 * 1080 * 4 = 25,920` | +| Literal context modes | `256` | +| Literal and distance context maps | `256 * 64 + 256 * 4 = 17,408` | +| Three Huffman-tree index arrays | `3 * 256 * 4 = 3,072` | +| Three Huffman-tree code arrays | `3 * 256 * 1080 * 4 = 3,317,760` | +| EdgeZero decoder output buffer | `8,192` | +| Ring write-ahead and dictionary-word slack | `42 + 24 = 66` | +| **Source-audited payload subtotal** | **3,442,530** | + +The public 16,777,216-byte fixed charge therefore leaves 13,334,686 bytes within the accounting +reservation above the source-audited payload subtotal. That remainder is deliberately reserved +for async wrapper/generator/replay objects, collection capacity, allocator metadata, and +fragmentation, but this audit does not claim an exact upper bound for those terms. The charge is a +conservative policy reservation, not a complete RSS certificate. + +Encoded source chunks, decoded response retention, task stacks, adapter/SDK storage, and +provider-owned allocations are outside this decoder charge. Those terms are either separately +bounded or remain outside `outbound-complete-resource-accounting`, as documented in the +capability matrix. + +## Change Gate + +The dependency contract script pins every listed normal dependency, version, registry source, and +checksum in both workspace lockfiles. Any change to a pinned source, decoder construction path, +allocator, grammar limit, table-size constant, or bridge buffer requires repeating this source +audit before retaining or changing the fixed charge. Allocation measurements may be added as +regression evidence, but they do not replace this source-level accounting proof. diff --git a/docs/audits/2026-09-29-flate-decoder-memory-accounting.md b/docs/audits/2026-09-29-flate-decoder-memory-accounting.md new file mode 100644 index 000000000..2efcd88bb --- /dev/null +++ b/docs/audits/2026-09-29-flate-decoder-memory-accounting.md @@ -0,0 +1,35 @@ +# Flate Decoder Memory Accounting + +This audit pins the flate decoder graph used by `edgezero-core` for HTTP +`deflate` and gzip response decoding. The audited graph is locked to: + +- `async-compression 0.4.43` +- `compression-codecs 0.4.38` +- `compression-core 0.4.32` +- `flate2 1.1.9` with the Rust backend +- `miniz_oxide 0.8.9` +- `adler2 2.0.1`, `crc32fast 1.5.0`, and `simd-adler32 0.3.9` + +The lockfile versions, registry source, checksums, metadata graph, and direct +normal dependencies are enforced by `scripts/check_flate_dependency_contract.mjs` +for both the root workspace and the excluded `examples/app-demo` workspace. + +## Charge + +The pinned `miniz_oxide` state reserves a 32 KiB sliding window and a 64 KiB +inflate state. The codec wrapper reserve is 4 KiB and the shared EdgeZero +stream output buffer is 8 KiB. The measured source subtotal is therefore +73,728 bytes before allocator metadata, fragmentation, task stacks, and host +buffers. `FLATE_DECODER_FIXED_CHARGE_BYTES` is pinned to 1 MiB, giving more +than 14x headroom over that subtotal while keeping the public policy uniform +for Brotli, gzip, and deflate. + +The charge is a conservative guest-visible policy reservation, not a claim +about total process RSS. The documented outbound memory model continues to +exclude allocator metadata, fragmentation, adapter/SDK buffers, source-owned +chunks, output/body buffers, task stacks, and provider host allocations. +Those exclusions are why complete resource accounting remains a separate +capability. + +Any dependency upgrade must repeat this audit, update the exact pins and +checksum contract, and re-evaluate the fixed charge before it is merged. diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index 80b3f3e01..e9a9f82ae 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -41,6 +41,8 @@ variables (see [the migration guide](../manifest-store-migration.md)). The portable store metadata baked into `App` by the `app!` macro drives which logical stores are exposed; no `edgezero.toml` needs to be loaded by the runtime. +If `Hooks::configure` fails, `run_app` returns the source-preserving +`application configuration failed` error before binding the listener. ## Development Server @@ -78,21 +80,154 @@ cargo build -p my-app-adapter-axum --release The binary is placed in `target/release/my-app-adapter-axum`. -## Proxy Client +## Bounded HTTP/1 Ingress -The Axum adapter provides a native HTTP client for proxying: +The standard server entrypoints own a bounded pool of HTTP/1 connections. These limits apply +before application admission, including to sockets that never finish a request head. No custom +listener or router is needed. HTTP/2 is not enabled, and protocol upgrades cannot transfer the +socket out of this pool. + +`run_app` resolves these runtime variables once, before application configuration or binding: + +| Variable (`EDGEZERO__ADAPTER__INGRESS__` prefix) | Default | Supported range | +| ------------------------------------------------ | ------- | --------------- | +| `MAX_CONNECTIONS` | 256 | 1–4096 | +| `MAX_RAW_HEAD_BYTES` | 65536 | 8192–1048576 | +| `MAX_HEADER_COUNT` | 100 | 1–1024 | +| `HEAD_READ_TIMEOUT_MS` | 10000 | 1–120000 | + +Only missing settings use defaults. Explicit values must be unsigned decimal integers within +the supported ranges; empty, signed, malformed, overflowing, and zero values fail startup. +Diagnostics identify the setting, never its supplied value. The effective validated +`AxumIngressConfig` is logged at `Info`. Applications can resolve the same configuration with +`AxumIngressConfig::from_env(&EnvConfig::from_env())` and inspect its getters. Programmatic +servers install the validated value in `AxumDevServerConfig::ingress`: ```rust -use edgezero_adapter_axum::proxy::AxumProxyClient; -use edgezero_core::proxy::ProxyService; +use std::time::Duration; +use edgezero_adapter_axum::{ingress_config::AxumIngressConfig, dev_server::AxumDevServerConfig}; -let client = AxumProxyClient::try_new()?; -let response = ProxyService::new(client).forward(request).await?; +let config = AxumDevServerConfig { + ingress: AxumIngressConfig::new(64, 16384, 64, Duration::from_secs(5))?, + ..AxumDevServerConfig::default() +}; ``` -This uses `reqwest` under the hood for outbound HTTP requests. `try_new` is -fallible because it builds a `reqwest::Client`; it returns a `reqwest::Error` if -the TLS backend cannot be initialised on the host. +Manifest defaults can be supplied through adapter-filtered `[[environment.variables]]` entries. +The Axum CLI forwards the selected manifest's defaults to its Cargo child without mutating the +parent environment. An existing parent variable, including an empty one, takes precedence; +duplicate applicable defaults retain declaration order, with the last taking precedence. +Running the binary directly requires setting the runtime variables directly. + +### Connection Ownership And Deadlines + +Let `C` be `max_connections()`. At most `C` connection futures own parser state, sockets, and +application work, including keep-alive connections. There is no per-connection spawned task or +permit-waiter queue. The accept loop may own **one additional transient accepted socket**; if +capacity is exhausted it closes that socket immediately, without a parser, response, or +background task. Completed connections are reaped before accepting more work. Disconnect, +parser failure, timeout, cancellation, and shutdown drop the single connection owner and release +capacity; unwinding handler failures are isolated to their connection. Shutdown closes the +owned connections rather than waiting indefinitely for a graceful drain. + +The first absolute idle-plus-header deadline starts when the accepted socket enters the pool, +before its future is first polled. Each subsequent deadline starts when Hyper first polls the +next request head after the previous exchange permits it. Idle keep-alive time and incomplete-head +time share this budget; a byte arriving does **not** reset it. Pipelined bytes may already be +buffered, so this is not a deadline starting at the network arrival of each pipelined request. +An independent timer wakes without incoming data, and ready I/O and completed heads also check +expiry before admission. Equality is expired. Slow/trickled or idle connections close when the +deadline expires; an HTTP timeout response is not promised. + +After a complete head is accepted, the header clock is disarmed for application work. Existing +application body-read and response-write deadlines remain in force. Infrastructure timers use +the native Tokio monotonic clock, independently of an application's injectable clock, so a +frozen application clock cannot disable pre-admission expiry. These timers are cooperatively +scheduled: blocking application code can delay all work on the connection executor. + +### Wire Limits And Parser Storage + +Let `H = max_raw_head_bytes()` and `N = max_header_count()`. The pinned Hyper 1.12.0 parser +rejects a complete raw head above `H` bytes or above `N` field lines **before** constructing an +admitted request. The byte budget includes the entire request line, field names, colons, +whitespace, line endings, and the final empty line. Duplicate field lines count separately. +Request-line and header bytes share one combined budget, not independent allowances. Exactly +`H` bytes and `N` fields pass the size/count checks; other protocol, type, or application validation +can still reject an in-budget head. Hyper additionally limits request targets to 65534 bytes +(414). Overflow closes the connection and Hyper attempts a bounded 431 response; other malformed +syntax may receive a bounded 400 response. Expiry or disconnect can prevent response delivery. +Application admission and rendering hooks do not run for raw parser rejections. + +`parser_buffer_bytes()` is Hyper's logical threshold `H`, **not** a hard allocation ceiling. +The receive scratch audit pins Hyper 1.12.0, bytes 1.12.1, http 1.4.2, and Rust 1.95.0: + +- `parser_read_allocation_bytes()` reports the conservative requested-capacity bound `R = 4H` + for each receive backing allocation. Adaptive reads expose spare capacity and shared-buffer + splits can cause a larger replacement allocation than the logical threshold. +- `parser_trailer_allocation_bytes()` reports `T = next_power_of_two(H)` for the separate raw + chunked-trailer buffer. Hyper applies its byte budget separately to trailers, whose completed + raw field section must be **strictly smaller** than `H`; this is not a combined head-plus-trailer + allowance. The trailer field count remains at most `N`. +- Conservative simultaneous receive scratch, including an outstanding channel frame, replacement + allocation, raw trailer growth, and copied field bytes, is `3R + 2T + H`, **plus** parser/header + metadata, URI/method storage, and fixed connection state. These are requested capacities, not + allocator-resident bytes or a complete per-request memory certificate. +- `parser_header_map_allocation_bytes()` reports `M`, a conservative per-map requested-capacity + charge covering both ordinary and adversarial collision-driven growth, duplicate-value storage, + and capacity retained across clear/reuse. For `P = max(100, N) + 1`, raw index capacity is bounded + by the largest power of two at most `10P`, bucket capacity by three quarters of that number, + and duplicate capacity by `next_power_of_two(max(4, P - 1))`. The getter uses public header-type + sizes plus conservative machine-word padding for private map nodes. Charge up to `1.5M` for + one map's reallocating peak, or `3.5M` for overlapping request, response/cache, and trailer maps. + An application-retained request map or admission's temporary header clone is an additional + owner, not included in those three maps. +- Raw parse workspaces additionally retain the default 100-field inline arrays and, above that, + bounded heap arrays. On 64-bit native builds a conservative payload charge for these two + workspaces is `64 * max(100, N)` bytes, plus their fixed container state. Parsed URI/method data + and field-name/value backing bytes are separate charges bounded by the raw input sizes. + +Response header maps become reusable request-parser storage in Hyper. Before handoff, EdgeZero +therefore bounds application response fields to `max_response_header_count() = max(100, N)` +and `max_response_header_bytes() = H`. Field-byte accounting is the sum of name bytes, value +bytes, and four bytes per field (`: ` and CRLF), including duplicates. EdgeZero copies the map +and field data into fresh storage, discarding application-supplied spare capacity and large +shared backing buffers; duplicate values and sensitive flags are preserved. Custom Hyper +reason phrases are removed in favor of canonical status phrases. Hyper's own status line, +framing, connection, and date fields need additional fixed overhead. An oversized response head +closes the connection and reports a transport error to any active egress attempt; it does not +allocate a replacement error response. + +The pool bounds retention of receive scratch, parser-generated error responses, and parser +metadata, not arbitrary application allocations. A budget must additionally charge parsed +request/response metadata and copies, application-held body frames, response buffers, handler +state, stores, outbound exchanges, and allocator overhead. Kernel socket buffers, the OS listen +backlog, and resources outside EdgeZero are excluded. The connection bound is not a published +platform memory ceiling or a whole-process RSS guarantee. + +This combined HTTP/1 resource policy does not implement the stricter independent raw-target / +field-section accounting or raw framing-rejection policy. Admission still receives +`IngressHeadAccounting::HostManaged` and `IngressFraming::HostManaged`; both raw-ingress +capabilities remain `Unsupported`. See [Capabilities](/guide/capabilities). + +## Outbound HTTP + +The Axum adapter injects `AxumOutboundClient`, backed by `reqwest`. Application handlers use the +portable client from `RequestContext::http_client()`; direct wiring and tests can construct the +adapter client with `AxumOutboundClient::try_new()`. + +Axum provides native total deadlines, completion-order batching, pending-future cancellation, +batch slot isolation, cache bypass, wire-authority override, elastic phase budgeting, header +fidelity, and streamed upload cancellation. Downstream responses run on a connection-local Hyper +HTTP/1 executor, so portable non-`Send` streams remain lazy and are polled under Hyper demand. +The connection supervisor enforces the absolute response-write deadline, but frame acceptance is +not proof of socket or client receipt; response-egress capabilities therefore remain BestEffort. +See [Capabilities](/guide/capabilities). + +For streamed responses with `Content-Length`, Axum retains only the final chunk until source EOF +validates, because Hyper stops polling once the declared length is satisfied. A bad tail is not +sent, and a pending tail remains subject to the write deadline. A declared zero-length stream is +validated before handoff. Suppressed HEAD/status bodies are dropped without polling. Clean source +EOF remains `HostHandoff`, not proof of socket flush or client receipt. ## Logging @@ -100,8 +235,29 @@ The Axum adapter's `run_app` helper installs `simple_logger` at the level read f `EDGEZERO__LOGGING__LEVEL`, falling back to `info` when the variable is unset or unparseable. It does not read `edgezero.toml`, and `echo_stdout` has no effect on the runtime. To install a different logger, set `owns_logging = true` on your `app!` -declaration so `run_app` skips its own logger, then install yours in `main`. Wiring -`App::build_app()` and `AxumDevServer` by hand remains the fallback if you also need +declaration so `run_app` skips its own logger, then install yours in `main` **before** `run_app`. +Logger setup now precedes application configuration, and setup failures are returned rather than +ignored. For embedding startup validation, `run_app_with_preflight::(check)` runs `check` +after logging is ready and before configuration or listener binding. The bundled `edgezero demo` +uses this for its capability gate instead of preinstalling a second CLI logger. + +Managed boot warnings use `edgezero::boot` at `Warn`, even when runtime logging is `Off`. +Application-owned loggers can reuse the shared resolver and constants: + +```rust +use edgezero_core::{BOOT_LOG_LEVEL, BOOT_LOG_TARGET, resolve_logging_level}; +use edgezero_core::env_config::EnvConfig; + +let env = EnvConfig::from_env(); +simple_logger::SimpleLogger::new() + .with_level(resolve_logging_level(&env)) + .with_module_level(BOOT_LOG_TARGET, BOOT_LOG_LEVEL) + .init()?; +// Then call run_app::() with owns_logging = true. +``` + +Wiring +`edgezero_core::app::App::build::(edgezero_adapter_axum::AXUM_PLATFORM)` and `AxumDevServer` by hand remains the fallback if you also need to control the bind address or store setup. ::: tip Logging status @@ -116,12 +272,13 @@ The Axum adapter enables standard Rust testing: #[cfg(test)] mod tests { use super::*; - use edgezero_core::app::Hooks; + use edgezero_core::app::App as EdgeZeroApp; use edgezero_core::http::{Request, Method}; #[tokio::test] async fn test_handler() { - let app = App::build_app(); + let app = EdgeZeroApp::build::(edgezero_adapter_axum::AXUM_PLATFORM) + .expect("configured app"); let router = app.router(); let request = Request::builder() @@ -175,6 +332,10 @@ EDGEZERO__SECRETS__API_KEY=mysecret edgezero serve --adapter axum For local development, each declared `[stores.config]` id resolves to a local-file config store backed by `.edgezero/local-config-.json`. +The standard runner loads immutable snapshots before binding its listener. +Missing files create empty declared stores. Malformed files retain typed failing +bindings, while allocation-limit violations fail startup. Changes made by `config +push` take effect after restart, not on the next request. The portable manifest carries no inline defaults — the pre-rewrite `[stores.config.defaults]` table is gone (see [the migration guide](../manifest-store-migration.md)). @@ -219,12 +380,59 @@ flow — the bundled `edgezero config push` errors), which writes the same `.edgezero/local-config-.json` files the runtime reads — no shell-out, no server to authenticate against. +### Snapshot Allocation Limits + +Set `EDGEZERO__ADAPTER__CONFIG_STORE__` through the normal environment +configuration/startup path. Absent settings use finite defaults; explicit invalid, +zero, overflowing or inconsistent settings fail before listener binding. + +| Setting | Default | Supported Range / Scope | +| -------------------- | ------- | -------------------------------------------------------------------------- | +| `MAX_FILE_BYTES` | 16 MiB | 1 byte to 256 MiB per file, including JSON syntax and escaping | +| `MAX_ENTRIES` | 1024 | 1 to 65536 entries per file, including duplicate-key attempts | +| `MAX_KEY_BYTES` | 1024 | 1 byte to 64 KiB decoded UTF-8 per key, no larger than the file cap | +| `MAX_VALUE_BYTES` | 8 MiB | 1 byte to 256 MiB decoded UTF-8 per value, no larger than the file cap | +| `MAX_RESIDENT_BYTES` | 32 MiB | 1 byte to 256 MiB across every config snapshot in the startup registry | +| `MAX_STARTUP_BYTES` | 112 MiB | 1 byte to 1 GiB across resident snapshots plus one file's reserved staging | + +These are requested-allocation charges, not an RSS ceiling. Stores load sequentially. +For wire cap `W`, staging reserves `5 * max(W, 32)` bytes: input growth, escaped-string +decoder growth (including old/new allocation overlap), and temporary decoded key/value +strings. A separate 4096-byte read buffer lives on the stack. The startup limit must +cover staging; every index/payload reservation also checks residency plus staging. +This decoder allowance is audited against pinned `serde_json 1.0.150` and Rust 1.95.0; +changing those allocation implementations requires repeating the audit. + +The sorted snapshot index reserves `MAX_ENTRIES * size_of::<(String, ConfigValue)>()` +bytes per store. Each entry additionally charges key UTF-8 bytes, value UTF-8 bytes, +two `Arc` reference counts and up to `size_of::() - 1` alignment bytes. Duplicate +keys are rejected. Keys/values are size-checked before their owned copies; actual reads +detect file growth and reject the first excess byte without appending it. Non-string +values are rejected without constructing a JSON tree. Failed loads drop staging and +partial snapshots. Diagnostics never contain file contents. + +`get` and `get_bounded` return `ConfigValue`, an independently owned, shared UTF-8 +payload. Clones allocate no payload and never retain the whole file. Bounded reads +check both extraction caps and the injected deadline before returning an `Arc` clone; +there is no request-time file read. Borrow with `as_str()` or `as_ref()`; `to_string()` +is an explicit allocating boundary. Already-owned `from_map` inputs are caller allocations +outside these startup-reader charges. + +Budget fixed manifest/registry/handle metadata separately, as well as allocator overhead, +environment-backed secret reads, concurrently parsed config trees, typed outputs and +application retention of returned handles. Canonical hashing streams into SHA-256 rather +than staging a second full JSON string. The extractor drops its raw handle before resolving +secrets. Kernel buffers, filesystem page cache, allocator fragmentation and total RSS are +not guaranteed. Local file reads are synchronous; no finite startup I/O interruption is +claimed. The broad `config-read-allocation-bounds` capability remains `Unsupported`; +this bounded local config-snapshot path does not certify every secret/provider allocation. + ## Container Deployment Build and deploy as a standard container: ```dockerfile -FROM rust:1.75 as builder +FROM rust:1.95.0 as builder WORKDIR /app COPY . . RUN cargo build -p my-app-adapter-axum --release @@ -262,14 +470,14 @@ A typical development workflow: ## Differences from Edge Adapters -| Aspect | Axum | Fastly/Cloudflare | -| ----------- | -------------- | ----------------- | -| Compilation | Native | Wasm | -| Cold start | ~0ms | ~0ms (Wasm) | -| Memory | Unlimited | 128MB typical | -| Filesystem | Full access | Sandboxed | -| Network | Direct | Backend/fetch | -| Concurrency | Multi-threaded | Single-threaded | +| Aspect | Axum | Fastly/Cloudflare | +| ----------- | ------------------------------- | ----------------- | +| Compilation | Native | Wasm | +| Cold start | ~0ms | ~0ms (Wasm) | +| Memory | Operator configured | Provider quota | +| Filesystem | Full access | Sandboxed | +| Network | Direct | Backend/fetch | +| Concurrency | Bounded, connection-local async | Runtime dependent | ::: tip Development Parity While Axum provides a convenient development environment, always test on actual edge platforms before deploying. Provider-specific behaviour such as store backends and request context differs on the real targets. diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index dad084e8b..a92696fd9 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -29,11 +29,19 @@ The Wrangler manifest configures your Worker: name = "my-app" main = "build/worker/shim.mjs" compatibility_date = "2023-05-01" +compatibility_flags = ["enable_request_signal", "enable_weak_ref"] [build] command = "worker-build --release" ``` +Keep both compatibility flags when migrating an existing application. `enable_request_signal` +exposes incoming-request cancellation. Bounded config KV reads require `FinalizationRegistry` +for wasm-bindgen cleanup callbacks and fail before dispatch if it is absent; `enable_weak_ref` +enables that support even with an older compatibility date. GC timing is nondeterministic and +does not provide a certified allocation or teardown bound. See +[Cloudflare's compatibility contract](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#enable-finalizationregistry-and-weakref). + ### Entrypoint The Cloudflare Wasm entrypoint in `src/lib.rs` wires the adapter: @@ -55,6 +63,9 @@ derived from the baked store ids and queried individually). Per-id `KV` / `Config` / `Secret` registries are built and injected into request extensions automatically. No `edgezero.toml` is loaded by the runtime — see [the migration guide](../manifest-store-migration.md). +If `Hooks::configure` fails, `run_app` logs only the stable EdgeZero error category and returns the +fixed `application configuration failed` worker error before request conversion. Application or +provider diagnostics are not exposed on the wire. For fully manual wiring, `CloudflareService::new(&app)` builds a dispatcher one store at a time: `.with_config(binding)` (a KV binding name), @@ -64,14 +75,15 @@ matching `.require_kv()` / `.require_secrets()` flags, and finally to open bindings: ```rust -use edgezero_adapter_cloudflare::request::CloudflareService; -use edgezero_core::app::Hooks as _; +use edgezero_adapter_cloudflare::{CLOUDFLARE_PLATFORM, request::CloudflareService}; +use edgezero_core::app::App as EdgeZeroApp; use my_app_core::App; use worker::*; #[event(fetch)] pub async fn main(req: Request, env: Env, ctx: Context) -> Result { - let app = App::build_app(); + let app = EdgeZeroApp::build::(CLOUDFLARE_PLATFORM) + .map_err(|_| Error::RustError("application configuration failed".to_owned()))?; CloudflareService::new(&app) .with_config("app_config") .with_kv("sessions") @@ -120,17 +132,28 @@ wrangler deploy --cwd crates/my-app-adapter-cloudflare ## Fetch API -Cloudflare Workers use the global `fetch` API for outbound requests: - -```rust -use edgezero_adapter_cloudflare::proxy::CloudflareProxyClient; -use edgezero_core::proxy::ProxyService; - -let client = CloudflareProxyClient; -let response = ProxyService::new(client).forward(request).await?; -``` - -Unlike Fastly, there's no backend configuration needed - Workers can fetch any URL directly. +`CloudflareOutboundClient` uses the Workers global `fetch` API and is injected into core request +extensions. Workers needs no backend registration, but application manifests still declare +outbound hosts so the same portable contract validates on every target. + +The adapter requests raw encoded upstream bytes with manual fetch encoding, disables automatic +redirect following, and owns an abort signal for the absolute request deadline. When an encoded +body is passed through to the downstream response, the response converter separately selects +manual response-body encoding so Workers does not encode it again. These are two distinct +controls. The abort path is implemented, but outbound and streamed-upload deadlines remain +BestEffort until a deployed host-observed cancellation fixture proves a finite bound. Cloudflare +drives batch slots in completion order, maps cache bypass to `NoStore`, and applies a validated +wire-authority override. The platform has not yet proved finite abort teardown or that its final +wire request preserves authority independently from the connection target, so cancellation and +authority override remain BestEffort. Cloudflare and Axum have Native lazy streamed response passthrough; +raw header octets and original non-`set-cookie` field boundaries remain unavailable, so header +fidelity is BestEffort. See [Capabilities](/guide/capabilities). + +Downstream response delivery uses one JavaScript stream writer owned by a coordinator registered +with `Context::wait_until`. It awaits writer backpressure and races the request abort signal and +absolute write deadline. Writer acceptance and close are host-handoff observations; deployed +disconnect and network-completion timing remain unproved, so response-egress capabilities are +BestEffort. ## Logging @@ -138,6 +161,12 @@ EdgeZero does not install a Cloudflare logger by default. Use your preferred log `console_log` or your own `log` implementation), and view output in Wrangler or the Cloudflare dashboard. +Workers console capture does not install a Rust `log` backend. Initialize your backend and +both its filter and facade maximum **before** `run_app`, and set `owns_logging = true`. +`edgezero_core::resolve_logging_level(&env_config)` resolves a supplied platform `EnvConfig`; +`edgezero::boot` is available for separately filtered startup diagnostics. The no-op adapter +initializer cannot guarantee their visibility. + ::: tip Logging status Cloudflare logging is opt-in; install a logger (such as `console_log`) in your entrypoint if you need structured output. @@ -316,18 +345,29 @@ For explicit retention, keep a cache owned by one concrete application and select its `Hooks` type with `dispatch_app::` on every fetch: ```rust -use edgezero_core::app::{App, Hooks}; +use edgezero_adapter_cloudflare::CLOUDFLARE_PLATFORM; +use edgezero_core::{app::App, EdgeError}; +use my_app_core::App as MyApp; use std::sync::OnceLock; static APP: OnceLock = OnceLock::new(); +fn retained_app() -> Result<&'static App, EdgeError> { + if let Some(app) = APP.get() { + return Ok(app); + } + let app = App::build::(CLOUDFLARE_PLATFORM)?; + Ok(APP.get_or_init(|| app)) +} + #[worker::event(fetch)] async fn fetch(req: worker::Request, env: worker::Env, ctx: worker::Context) -> worker::Result { - edgezero_adapter_cloudflare::dispatch_app::( - APP.get_or_init(MyApp::build_app), req, env, ctx, - ).await + let app = retained_app().map_err(|_| { + worker::Error::RustError("application configuration failed".to_owned()) + })?; + edgezero_adapter_cloudflare::dispatch_app::(app, req, env, ctx).await } ``` @@ -336,12 +376,21 @@ the first application value replaces any body-generated default. This correction also applies to existing `run_app` users. `dispatch_app::` reads `MyApp::stores()` and does not initialize logging -or construct an app. Pass an app built from the same `Hooks` type. The caller owns -initialization before construction. It resolves configuration and bindings for -each invocation; never retain `Env`, `Context`, request bodies, or registries in -this cache. The initializer is synchronous and must not recursively access the -cache or block waiting for async initialization. Apps needing fallible or async -initialization own that state machine and publish only a complete snapshot. +or construct an app. Its signature is +`dispatch_app(&App, Request, Env, Context) -> Result`. +Pass an app built from the same `Hooks` type with `CLOUDFLARE_PLATFORM`; the caller +owns logging setup before construction. `App::build` returns `Result` +and invokes fallible `Hooks::configure` after installing platform metadata. +Only a complete successful app enters the cache; an initialization error leaves it +empty for a later attempt. Concurrent construction attempts may build separate +successful candidates, but only one is retained. + +Each invocation resolves fresh configuration, runtime variables, and store +registries, then runs fresh admission and response egress. Never retain `Env`, +`Context`, request bodies, admission resources, or delivery coordinators in the +app cache. Construction is synchronous and must not recursively access the cache +or block waiting for async initialization. Apps needing async initialization or +refresh own that state machine and publish only a complete successful snapshot. Fetch invocations may overlap. Verify isolation while two requests are actually in flight in the same instance. A serialized test or two separate instances does diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 8913e843e..5ac62d22f 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -6,7 +6,10 @@ Deploy EdgeZero applications to Fastly's Compute@Edge platform using WebAssembly - [Fastly CLI](https://developer.fastly.com/learning/compute/#install-the-fastly-cli) - Rust `wasm32-wasip1` target: `rustup target add wasm32-wasip1` -- [Viceroy](https://github.com/fastly/Viceroy) for local execution and testing +- [Viceroy](https://github.com/fastly/Viceroy) 0.21.0 for local execution and testing + +The adapter pins the Fastly SDK to 0.13.1. Older Viceroy releases may reject its +host imports before application code executes; use the repository's pinned runtime. ## Project Setup @@ -57,109 +60,72 @@ The Fastly entrypoint wires the adapter: ```rust use my_app_core::App; -#[fastly::main] -fn main(req: fastly::Request) -> Result { - edgezero_adapter_fastly::run_app::(req) +pub fn main() -> Result<(), fastly::Error> { + edgezero_adapter_fastly::run_app::() } ``` +Do not add Fastly's response-returning entrypoint attribute. EdgeZero initializes the ABI, +receives the client request, and owns `stream_to_client` through final body-handle close. + `run_app` builds per-id `KV` / `Config` / `Secret` registries from the portable store metadata baked into `App` by the `app!` macro. Logging settings are baked -in at the same time. A managed deployment resolves `EDGEZERO__*` store selectors -and links each selected Fastly resource under its logical ID; no -`edgezero.toml` or deployment environment is read by the runtime. See +in at the same time. Managed deployments resolve `EDGEZERO__*` store selectors +and link each physical resource under its logical ID; neither `edgezero.toml` +nor the deployment environment is read by the runtime. See [the migration guide](../manifest-store-migration.md). +If `Hooks::configure` fails, `run_app` returns the source-preserving +`application configuration failed` error before receiving the client request. -For fully manual wiring, `FastlyService::new(&app)` builds a dispatcher one -store at a time: `.with_config(name)`, `.with_config_handle(handle)`, -`.with_kv(name)`, `.with_secrets()`, the matching `.require_kv()` / -`.require_secrets()` flags, and finally `.dispatch(req)`. Prefer `run_app` when -the entrypoint should use the store and logging metadata baked into the app. - -### Migrating a custom entrypoint - -Custom entrypoints previously loaded selectors from the -`edgezero_runtime_env` Config Store on every request and passed an `EnvConfig` -to `dispatch_with_registries`: - -```rust -let stores = MyHooks::stores(); -let env = edgezero_adapter_fastly::runtime_env_config(stores); -edgezero_adapter_fastly::request::dispatch_with_registries( - &app, - req, - stores, - &env, - extend, -) -``` - -Remove the `runtime_env_config` call, any use of `RUNTIME_ENV_STORE_NAME`, and -the `env` argument. Initialize logging from the app's baked Fastly settings: - -```rust -use edgezero_adapter_fastly::{FastlyLogging, init_logger}; -use edgezero_core::app::Hooks as _; - -let stores = MyHooks::stores(); -let logging = FastlyLogging::from(MyHooks::logging_for("fastly")); -if logging.use_fastly_logger && !MyHooks::owns_logging() { - let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); - init_logger(endpoint, logging.level, logging.echo_stdout)?; -} -edgezero_adapter_fastly::request::dispatch_with_registries( - &app, - req, - stores, - extend, -) -``` - -A hand-written `Hooks` impl inherits the default `stores()`, which is empty, so -`dispatch_with_registries` opens no stores. Such an impl must override -`stores()` or pass explicit `StoresMetadata`. - -`EDGEZERO__STORES______NAME` is now a deployment input. EdgeZero -resolves it before provider mutation and binds that physical resource to the -version under the stable logical `` alias. The runtime opens the alias -directly. Fastly Config Stores always use `` as the entry key, so remove -Fastly `__KEY` selectors as well. +For fully manual wiring, build `FastlyService`, attach stores as needed, and call its send-owning +`send()` method. Prefer `run_app` for manifest-driven store resolution. -Fastly logging endpoint, level, and stdout behavior now come from -`[adapters.fastly.logging]` when the immutable application is built. Runtime -`EDGEZERO__LOGGING__*` values cannot vary those settings between production and -staging for the same release. +Fastly store `__NAME` selectors are deployment inputs. The runtime opens logical +aliases directly, and Config Stores use the logical ID as the entry key. Remove +runtime selector-store reads and Fastly `__KEY` overrides when migrating a custom +entrypoint; use the canonical send-owning helper below. Logging endpoint, level +and stdout behavior come from `Hooks::logging_for("fastly")` and cannot vary +between staging and production for the same immutable application release. ### Capturing raw-request signals (JA4, H2 fingerprint) -`run_app` converts the `fastly::Request` into a neutral core request before -dispatch. The client IP is carried across automatically — read it via +`run_app` receives and converts the `fastly::Request` into a neutral core request before +dispatch. The client IP is carried across automatically; read it via `FastlyRequestContext` (see [Context Access](#context-access) below). Other Fastly-only signals that are readable only on the raw request (`get_tls_ja4()`, `get_client_h2_fingerprint()`) aren't reachable from handlers -by default. Use `run_app_with_request_extensions`, which -runs an app closure against a scratch `Extensions` **before** conversion and -merges the values into the core request — so a `State`/extractor or middleware -can read them: +by default. Use `run_app_with_hooks`, which runs a request closure against a +scratch `Extensions` **before** conversion and merges the values into the core +request. Its response closure runs only for routed responses before egress +policy and framing; EdgeZero retains delivery ownership: ```rust #[derive(Clone)] struct Ja4(String); -#[fastly::main] -fn main(req: fastly::Request) -> Result { - edgezero_adapter_fastly::run_app_with_request_extensions::(req, |raw, ext| { - if let Some(ja4) = raw.get_tls_ja4() { - ext.insert(Ja4(ja4.to_owned())); - } - }) +pub fn main() -> Result<(), fastly::Error> { + edgezero_adapter_fastly::run_app_with_hooks::( + |raw, ext| { + if let Some(ja4) = raw.get_tls_ja4() { + ext.insert(Ja4(ja4.to_owned())); + } + }, + |_response| (), + ) + .map(|_post_send_state| ()) } ``` -`run_app` is exactly `run_app_with_request_extensions::(req, |_, _| {})`. -The closure runs once per request; insert whatever typed values your handlers -need, then read them in a handler via a custom extractor or -`ctx.request().extensions().get::()`. +The request closure runs once before admission for each request that reaches +preparation; insert whatever typed values your handlers need, then read them via a custom extractor or +`ctx.request().extensions().get::()`. The optional state returned by +`run_app_with_hooks` is available only after EdgeZero reaches its strongest +terminal delivery boundary. Detached admission responses skip the response +closure and return `None`. + +For fully manual wiring, `FastlyService::send_request_with_hooks` provides the +same closed lifecycle for an already-received request. It does not expose a +response-returning dispatch operation. ### Owning your own logging @@ -173,7 +139,9 @@ edgezero_core::app!("edgezero.toml", owns_logging = true); ``` or on a hand-written `Hooks` impl (`fn owns_logging() -> bool { true }`). Every -adapter's `run_app` honors it, so the app is responsible for logger setup. +adapter's `run_app` honors it. The app must install its backend **before** entering the runner, +apply `Hooks::logging_for("fastly").level`, and set both backend and facade filters. Raising only +`log::set_max_level` does not override `log_fastly`'s internal filter. ## Building @@ -215,24 +183,25 @@ edgezero deploy --adapter fastly \ ``` The release fixes the package, `edgezero.toml`, and its referenced Fastly -manifest before runtime configuration is selected. A bare -`edgezero deploy --adapter fastly` remains only as store-free production -compatibility for an existing manifest command. Staging and any deployment that +manifest before deployment selects physical resource links. A bare +`edgezero deploy --adapter fastly` supports store-free production deployments +using a manifest command. Staging and any deployment that declares a Config, KV, or Secret Store require the verified release-backed managed command above. ## Backends -EdgeZero's Fastly proxy client uses **dynamic backends** derived from the target URI (host + scheme). -You do not need to predeclare backends in `fastly.toml` for EdgeZero proxying. +Outbound HTTP uses deterministic **dynamic backends** derived from the canonical target, +TLS identity, and provider timer budget. You do not predeclare those destinations in +`fastly.toml`, but dynamic backends must be enabled on the deployed Fastly service. The local CLI +cannot prove that service entitlement, so `outbound-http` is BestEffort. A disabled service +returns a typed 502 with an enablement diagnostic. -```rust -use edgezero_adapter_fastly::proxy::FastlyProxyClient; -use edgezero_core::proxy::ProxyService; - -let client = FastlyProxyClient; -let response = ProxyService::new(client).forward(request).await?; -``` +Fastly also has documented deadline, upload, elastic-budget, batch-isolation, and lazy downstream +streaming limitations. EdgeZero owns the low-level `stream_to_client` lifetime and writes portable +response chunks without whole-body collection. Synchronous source polls and hostcalls cannot be +preempted, so downstream streaming and response-egress guarantees remain BestEffort. See +[Capabilities](/guide/capabilities) before marking a capability required. ## Logging @@ -245,14 +214,19 @@ level = "info" echo_stdout = true ``` -To initialize logging manually, call `init_logger` with explicit settings: +Managed Fastly logging keeps the `edgezero::boot` target at `Warn` while ordinary records use +the configured level. Both the SDK and facade allow boot warnings even at ordinary `Off`; +raising the facade maximum alone is insufficient. `edgezero::boot` is a logical target, not a +Fastly endpoint name. Initialize logging before application configuration. + +To initialize logging manually, call `init_logger` with a provisioned endpoint: ```rust use edgezero_adapter_fastly::init_logger; use log::LevelFilter; fn main() { - init_logger("stdout", LevelFilter::Info, true).expect("init logger"); + init_logger("edgezero-logs", LevelFilter::Info, true).expect("init logger"); } ``` @@ -342,8 +316,12 @@ async fn handler(config: Config) -> Result { } ``` -See [the store migration guide](../manifest-store-migration.md) for store selection and [the GitHub Actions guide](../deploy-github-actions.md) for the -immutable-release workflow. +If a configured store link is missing, the adapter logs a one-time warning +and retains a typed failing declared binding, rather than reporting intentional absence. +Raw values are shared `ConfigValue` UTF-8 payloads; use `as_str()` to borrow them. +Migrating from `name`/`adapters.*`? +See [the migration guide](../manifest-store-migration.md). +See [the GitHub Actions guide](../deploy-github-actions.md) for the immutable-release workflow. ## Context Access @@ -366,20 +344,23 @@ async fn handler(ctx: RequestContext) -> Result { ## Streaming -Standard `run_app` and `serve_app` dispatch drain a `Body::Stream` response into -a `fastly::Body` before returning, so the full payload is materialised rather -than delivered chunk by chunk. Custom `lifecycle::serve_custom` callbacks can -instead commit a native streaming response and pump chunks progressively; see -[Custom dispatch and streaming](#custom-dispatch-and-streaming). +Fastly provides `stream_to_client`, but that API is incompatible with the standard +response-returning SDK entrypoint. Generated applications use EdgeZero's undecorated, send-owning +entrypoint instead. EdgeZero commits the response head through the raw Fastly ABI, writes each +portable body chunk to the streaming body handle with short-write accounting, and closes or +abandons that handle exactly once. A blocked synchronous source poll or hostcall cannot be +preempted, and a failed consuming `finish` call leaves no handle to abandon; those limits keep the +response-egress capabilities at BestEffort. -See the [Streaming guide](/guide/streaming) for examples and patterns. +See the [Streaming guide](/guide/streaming) and +[capability matrix](/guide/capabilities#outbound-matrix) for the exact boundary. ## Testing Run contract tests for the Fastly adapter: ```bash -cargo install viceroy --locked +cargo install viceroy --version 0.21.0 --locked export CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" # Run tests @@ -400,7 +381,7 @@ Configure the Fastly adapter in `edgezero.toml`. See [Configuration](/guide/conf ## Reusing a sandbox and retaining an app -Opt in with an ordinary `main` in place of the single-request `#[fastly::main]`: +Opt in with an ordinary `main` in place of the single-request `run_app` call: ```rust use edgezero_adapter_fastly::{Serve, serve_app}; @@ -415,36 +396,40 @@ fn main() -> Result<(), fastly::Error> { } ``` -`serve_app_with_request_extensions` additionally accepts an `FnMut` callback -for fresh extensions on every request. The first request initializes logging -and builds the app. Each request reads runtime configuration and constructs new -store registries. App construction and `configure` execute once per sandbox; -explicit work elsewhere still executes whenever the application calls it. -The extension callback and its captures are retained for the whole serving loop; -only the supplied extensions are fresh. Create request-specific mutable data -inside the callback or store it in those extensions. - -Logging takes the first configuration snapshot, unless the app owns logging. -An unavailable optional runtime configuration store disables logging for that -sandbox, even if subsequent reads recover store selectors. It does not silently -retry or reconfigure logging. The current overlay enables logging when an -endpoint exists and uses `echo_stdout: true`. Applications requiring another -initialization policy should use `lifecycle::serve_custom` and `setup_once`. -The resolved configuration does not distinguish intentionally disabled logging -from an unavailable first snapshot. Warnings through the log facade may be lost -before a logger is installed; absence of a warning does not establish a healthy -configuration read. - -SDK 0.12.1 exposes `with_max_requests`, `with_timeout`, `with_max_lifetime`, and +`serve_app_with_hooks` additionally accepts a preparation hook +`FnMut(&mut fastly::Request, &mut Extensions)` and a finalization hook +`FnMut(&mut edgezero_core::Response) -> State`. Preparation runs before admission; +finalization runs only for routed responses, before egress policy and framing. +Detached admission responses skip finalization. The retained helper discards the +finalizer's state after delivery; use the request-level send helper in a custom +callback when post-send work needs that state. + +The first request initializes logging and builds the app through +`App::build::(FASTLY_PLATFORM) -> Result`. Each request constructs +fresh store registries using deployed logical aliases. Successful app construction +and `configure` execute once per sandbox; explicit work elsewhere still executes +whenever the application calls it. Both hooks and their captures are retained for +the whole serving loop; the supplied native request, extensions, and response are +request-local. Create request-specific mutable data inside the preparation hook +or store it in those extensions. + +Logging uses the immutable baked settings, unless the app owns logging. Setup +completes once per sandbox and does not reconfigure filters on later callbacks. +Applications requiring another initialization policy should use +`lifecycle::serve_custom` and `setup_once`. Store-open diagnostics use the bounded +boot channel after logger setup; an absent warning is not proof of a healthy binding. + +SDK 0.13.1 exposes `with_max_requests`, `with_timeout`, `with_max_lifetime`, and `with_max_memory`. A value of zero disables the request-count or memory limit; it does not request zero callbacks or zero memory use. The lifetime limit is measured from `Serve` construction, including time before the first callback. `with_timeout` bounds only the idle wait for the next request; it does not bound handler or backend time. If your service is billed by memory × wall-clock time, idle waiting is billed too. Limits do not guarantee reuse; any request may start a -fresh sandbox. The standalone Viceroy 0.17.0 pinned for the fixtures admits at most -six requests per guest even with a larger SDK request limit. `edgezero serve` runs -`fastly compute serve`, whose bundled Viceroy can differ. Neither local ceiling +fresh sandbox. The fixtures pin standalone Viceroy 0.21.0; any observed per-guest +request ceiling belongs to that runtime and run, not to the SDK request limit. +`edgezero serve` runs `fastly compute serve`, whose bundled Viceroy can differ. +Record the executable and observed reuse rather than assuming a local ceiling establishes deployed behavior. Lifetime and memory checks occur between callbacks and cannot interrupt blocked application work. Before using a local memory limit, verify that the @@ -453,67 +438,81 @@ ends the SDK loop. CPU clock observations are not reliable cross-run benchmarks. `ServeSummary::requests()` counts attempted callbacks, including a failed one; a crash can prevent a final summary. Handler `EdgeError`s render as responses -and serving continues. Any other returned error (logger setup, a required KV -store that fails to open, request conversion, response-stream collection, or an -`EdgeError` that itself fails to render) makes -the SDK send a 500 with the error text and end the loop. Constructor panics -remain sandbox failures. +and serving continues when adapter delivery succeeds. A returned setup, required-store, +admission-abort, or terminal-delivery error ends the loop and remains in its summary. +The lifecycle wrapper records the callback result without asking the SDK to send a +synthetic 500, including when a response was already committed. Ingress and egress +policies may produce their own error responses; an escaping error is not proof that +any response was sent. Constructor panics remain sandbox failures. ### Custom dispatch and streaming -The standard helper collects core response streams into a native response body. -For progressive client streaming, mutable native requests, response-extension -finalization, or post-send work, use `lifecycle::serve_custom` with a configured -`Serve` builder and a callback receiving `&mut lifecycle::Sandbox`. EdgeZero -owns the retained slot and successful-only initialization; your callback chooses -the application-owned `T`. Initialize lazily so health checks can bypass expensive -construction. Use `request::dispatch_with_registries` when standard translation -suffices. For custom response handling, use -`request::into_core_request_with_registries(req, MyApp::stores(), extend)` -then drive `app.router().oneshot(request)` with `futures::executor::block_on`. -This inserts the same registries for the `Kv`, `Config`, `Secrets` and `AppConfig` -extractors as standard dispatch while leaving the core response available for -streaming and finalization. -The bare `into_core_request` helper inserts no store registries. - -For manual streaming, append every header value, commit once with -`stream_to_client`, pump and flush chunks, then finish the stream. Handle errors -after commitment locally: returning an error to the SDK can trigger another -response attempt. Complete pending backend and post-send operations before the -callback returns. Do not cache native store handles with the app. +The standard helpers preserve core response streams without whole-body collection. +For custom initialization or post-send work, use `lifecycle::serve_custom` with a +configured `Serve` builder and a callback +`FnMut(fastly::Request, &mut lifecycle::Sandbox) -> Result<(), E>`. EdgeZero owns +the retained slot and successful-only initialization; your callback chooses the +application-owned `T`. Initialize lazily so health checks can bypass expensive +construction. + +For an EdgeZero response, call `request::send_request_with_registries_and_hooks` +with the app, store metadata, received request, preparation hook, +and finalization hook. It runs canonical admission, injects fresh `Kv`, `Config`, +`Secrets`, and `AppConfig` registries, then owns framing, streaming, deadlines, and +terminal delivery. Preparation borrows a mutable native request and extension bag; +finalization borrows a mutable routed core response. Neither hook returns a +response for the callback to send separately. The result is `Result, +fastly::Error>`: `Some` carries routed finalizer state only after terminal delivery, +while a delivered detached admission response returns `None`. + +The manual response lifetime remains adapter-owned: append every header value, +commit once through `stream_to_client`, account accepted body bytes, and finish or +abandon the body handle exactly once. A native response constructed directly by +your callback, such as a health reply, must instead be explicitly sent there; +manual native streams must be pumped, flushed, and finished before returning. +The lifecycle wrapper never supplies an extra response. Returning `Err` terminates +serving without a synthetic send, even after commitment. Return `Ok(())` only when +your policy permits continued serving after cleanup. Complete pending backend and +post-send operations before returning. Do not cache native store handles with the app. Use `Request::get_client_request_id()` for request correlation. `FASTLY_TRACE_ID` describes the sandbox and must not be treated as a unique request ID. If a native ID is unavailable, generate a request-local fallback and label its source. Do not retain correlation fields in app state or global logger configuration. -### Custom lifecycle compatibility contract +### Custom lifecycle contract -The custom path uses public APIs: `lifecycle::{Sandbox, serve_custom, run_custom}`, -`request::into_core_request_with_registries`, and `app.router().oneshot`. -EdgeZero owns the successful-only retained slot and delegates request reception, -limits and `HandlerResult` completion to the SDK. Applications own setup, state, -refresh policy, response finalization and sending. Direct SDK serving remains -available when these helpers do not fit. +The custom path uses public APIs: `lifecycle::{Sandbox, serve_custom, run_custom}` +and `request::send_request_with_registries_and_hooks`. EdgeZero owns the +successful-only retained slot and delegates request reception and limits to the +SDK. The callback owns delivery: it either explicitly sends a native response or +calls the adapter's closed send helper. The serving wrapper passes only the +terminal `Result<(), E>` to the SDK without a synthetic response. Applications +own setup, state, refresh policy, hooks, and post-send work. There is no +response-returning conversion or whole-body compatibility path. Follow this order inside a callback: -1. Return lightweight health responses before expensive initialization. -2. Use `sandbox.setup_once` to guard successful logger setup independently from +1. Explicitly send lightweight health responses before expensive initialization. +2. Resolve baked logging with `FastlyLogging::from(MyApp::logging_for("fastly"))`. + Use `sandbox.setup_once` to guard successful logger setup independently from app construction. Failed setup leaves the guard unset; partial side effects - are not rolled back, so retries must be safe. -3. Call `sandbox.initialize` only for application-owned state. Success fills the - slot; failure leaves it empty and returns the error unchanged. Read the value + are not rolled back, so retries must be safe. The closed send helper opens fresh + logical store bindings after logger setup. +3. Call `sandbox.initialize` with `App::build::(FASTLY_PLATFORM)` for a core + app, mapping its typed error at the adapter boundary. Success fills the slot; + failure leaves it empty and returns the error unchanged. Read the value with `state()`. A handled failure may send a 503 and return `Ok(())` to permit - a later callback to retry. Propagating it with `?` sends a 500 and ends the loop. -4. Convert with `into_core_request_with_registries` and drive router dispatch - with `futures::executor::block_on`. Keep bodies, - extensions, registries and native handles request-local. The bare - `into_core_request` path provides no `Kv`, `Config`, `Secrets` or `AppConfig` - stores. Registered app state still overwrites extensions of the same type. -5. Inspect response extensions and finalize before sending. Append repeated - headers, commit once, flush progressive chunks and finish the writer. After - commitment, handle errors locally and return `()` or `Ok(())`. + a later callback to retry. Propagating it with `?` ends the loop without a synthetic send. +4. Pass the received request to `send_request_with_registries_and_hooks`. Its + `FnOnce` preparation hook may mutate the native request and portable extensions + before admission; its `FnOnce` finalizer may inspect response extensions and + mutate a routed response before egress. Keep bodies, extensions, registries, + and native handles request-local. Registered app state still overwrites + extensions of the same type. +5. Wait for the send helper's terminal result before using finalizer state or + starting post-send work. `None` means a detached response skipped finalization. + Never send the core response again; a returned delivery error may follow commitment. 6. Complete pending backend and post-send work before returning. Constructors and callbacks that panic remain sandbox failures. @@ -525,20 +524,22 @@ if let Err(error) = sandbox.setup_once(|| install_application_logger()) { fastly::Response::from_status(503).send_to_client(); return Ok(()); } -sandbox.initialize(|| build_application())?; -// Read sandbox.state(), perform registry-aware dispatch, then explicitly send. +sandbox.initialize(|| App::build::(FASTLY_PLATFORM).map_err(fastly::Error::from))?; +// Read sandbox.state() and call the closed send helper; do not send its response again. // The ? above deliberately terminates this sandbox if application construction fails. ``` A complete callback looks like this. `serve_custom` returns a `ServeSummary` -that you inspect or convert with `into_result()`, and the infallible `Hooks::build_app` -needs an explicit error type for `initialize` to infer: +that you inspect or convert with `into_result()`. `App::build` is fallible and +installs `FASTLY_PLATFORM` before application configuration: ```rust use edgezero_adapter_fastly::lifecycle::{self, Sandbox}; -use edgezero_adapter_fastly::{Serve, request, response}; +use edgezero_adapter_fastly::{ + FASTLY_PLATFORM, FastlyLogging, Serve, init_logger, request, +}; use edgezero_core::app::{App, Hooks}; -use futures::executor::block_on; +use my_app_core::App as MyApp; fn main() -> Result<(), fastly::Error> { lifecycle::serve_custom(Serve::new().with_max_requests(10), handle).into_result() @@ -549,20 +550,35 @@ fn handle(req: fastly::Request, sandbox: &mut Sandbox) -> Result<(), fastly fastly::Response::from_status(200).send_to_client(); return Ok(()); } - sandbox.initialize(|| Ok::<_, fastly::Error>(MyApp::build_app()))?; - let core = request::into_core_request_with_registries(req, MyApp::stores(), |_, _| {})?; + let stores = MyApp::stores(); + sandbox.setup_once(|| { + let logging = FastlyLogging::from(MyApp::logging_for("fastly")); + if logging.use_fastly_logger && !MyApp::owns_logging() { + init_logger( + logging.endpoint.as_deref().expect("configured endpoint"), + logging.level, + logging.echo_stdout, + )?; + } + Ok::<(), fastly::Error>(()) + })?; + sandbox.initialize(|| App::build::(FASTLY_PLATFORM).map_err(fastly::Error::from))?; let app = sandbox.state().expect("initialized above"); - let core_response = block_on(app.router().oneshot(core))?; - // Inspect response extensions and finalize here. To stream progressively, - // append headers to a native response and use `stream_to_client` instead. - response::from_core_response(core_response)?.send_to_client(); + let _post_send_state = request::send_request_with_registries_and_hooks( + app, + stores, + req, + |_raw, _extensions| {}, + |response| response.status(), + )?; + // Delivery is terminal here; complete any post-send work before returning. Ok(()) } ``` The fixture's [`custom_dispatch`](https://github.com/stackpop/edgezero/blob/main/tests/fixtures/reusable-app/crates/fixture-fastly/src/lib.rs) is a fuller reference covering logger setup, handled initialization failure, -response finalization, progressive streaming and post-send work. +response finalization, adapter-owned progressive streaming and post-send work. `Sandbox::requests()` counts attempted callbacks, including early health responses and failed ones. `initialization_attempts()` counts only actual builder calls. @@ -570,14 +586,13 @@ These counters describe this sandbox and are part of the public lifecycle API. They do not establish client completion or provider reuse. Use `lifecycle::run_custom(Request::from_client(), callback)` for the single-request -branch. It creates fresh state without entering `Serve`. Both wrappers complete -`HandlerResult` exactly once. Use an ordinary `fn main`; `#[fastly::main]` may -attempt another error response if an already-completed error propagates. -Registry-aware `dispatch_with_registries` still collects response streams and -cannot replace this explicit streaming/finalization path. - -Keep adapter, core and SDK versions compatible. Run the compatibility fixtures -against the checkout being adopted: +branch, initializing the Fastly ABI before receiving that request. It creates +fresh state without entering `Serve` and returns the callback's `Result<(), E>` +directly, without asking the SDK to render an error. Use an ordinary `fn main`; +the response-returning SDK entrypoint attribute does not own this delivery lifecycle. + +Keep adapter, core and SDK versions aligned with the pinned checkout. Run the +lifecycle fixtures against the checkout being adopted: ```sh ./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smoke diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index 4bb4ac368..e2c014ac4 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -9,37 +9,46 @@ Adapters translate provider-specific HTTP primitives into the portable `App` in - Preserve request semantics - Stream responses without buffering where the provider supports it - Expose provider context -- Offer a proxy bridge so handlers can forward traffic without knowing which platform they are on +- Inject the portable outbound HTTP client without exposing provider SDK types to handlers ## Request Conversion -Each adapter exposes an `into_core_request` helper that accepts the provider's request type and returns `edgezero_core::http::Request`. The conversion must: +Canonical adapter dispatch normalizes the provider request head, performs admission, +and only then consumes the body under the admitted limits and deadline. Public raw +conversion helpers are not a common adapter contract or a substitute for this lifecycle. +The admitted conversion must: - **Preserve the HTTP method** exactly (`GET`, `POST`, etc.) - **Parse the full URI** (path and query string) into an `http::Uri`. Reject invalid URIs with `EdgeError::bad_request` - **Copy all headers** into the core request. Provider-specific headers may be filtered only when they clash with platform defaults -- **Consume the request body** into an `edgezero_core::body::Body`. Adapters may buffer inbound bodies today; streaming input should be preserved where available +- **Consume the request body** into an `edgezero_core::body::Body` only after admission, enforcing the selected size limit and absolute read deadline - **Insert a provider context struct** (e.g., `FastlyRequestContext`) into the request extensions. The context should expose metadata such as client IP addresses or environment handles so handlers can reach platform APIs -## Response Conversion +## Response Egress -Adapters also expose `from_core_response` (or equivalent) to transform an `edgezero_core::http::Response` into the provider response type. Implementations must: +Adapters consume a `ResponseEgressEnvelope` and own delivery through the strongest platform +transport boundary available. Implementations must: - **Map HTTP status codes** verbatim - **Copy headers**, respecting casing rules enforced by the provider -- **Preserve streaming bodies** - `Body::Stream` should be written chunk-by-chunk to the provider output without buffering the entire payload. Cloudflare does this through standard dispatch via `Response::from_stream`. Fastly's standard `run_app`/`serve_app` paths drain the stream into a `fastly::Body`; custom [`lifecycle::serve_custom`](/guide/adapters/fastly#custom-dispatch-and-streaming) callbacks can stream progressively. Spin collects into a `Vec` capped at 16 MiB, and Axum buffers as well -- **Handle encoding helpers** (`decode_gzip_stream`, `decode_brotli_stream`) where a provider requires transparent decompression +- **Preserve lazy body production** and platform backpressure without whole-response collection +- **Use the application clock and one absolute write deadline** through terminal observation +- **Account accepted payload bytes** at the adapter's documented host boundary +- **Abort or close owned transport resources** and deliver exactly one terminal report +- **Use the bounded precommit fallback** without replacing a response after commit ## Dispatch Helper -Adapters surface a dispatch entry point, either a free function or a service builder's `dispatch` method, that bridges from the provider event loop into the shared router (`app.router().oneshot(...)`). It should: +Adapters surface a send-owning runner that bridges from the provider event loop into the shared +router. It should: -1. Convert the incoming provider request with `into_core_request` -2. Await the router future -3. Convert the resulting `Response` back into the provider type -4. Map any `EdgeError` into the provider's error type so failures surface as provider errors (often 5xx) instead of panicking +1. Normalize the incoming provider request head and run the app's admission policy +2. Consume the admitted body and await canonical app dispatch, or deliver the detached admission response +3. Carry the returned egress envelope into the adapter coordinator +4. Own body delivery, deadline/abort handling, and terminal observation +5. Map precommit failures to the bounded fallback and expose only category-safe diagnostics -This helper is what demo entrypoints and adapters call when wiring their platform-specific main functions. +Generated and demo entrypoints call only the canonical runner for their adapter. ## Store Registry Resolution @@ -52,30 +61,55 @@ the id-keyed `Kv` / `Secrets` / `Config` extractors or the matching `ctx.kv_store(id)` / `ctx.config_store(id)` / `ctx.secret_store(id)` accessors. The pre-rewrite `Hooks::config_store()` hook is gone. -## Proxy Integration +## Outbound HTTP Integration -Adapters implement `edgezero_core::proxy::ProxyClient` so handlers can forward outbound requests. The client must: +Adapters implement `edgezero_core::outbound::OutboundHttpClient` and inject an `HttpClient` into +each core request. Implementations must: -- Accept a `ProxyRequest` created with `ProxyRequest::from_request` -- Build and send an outbound provider request, reusing headers and streaming the body without buffering -- Convert the provider response into a `ProxyResponse`, again preserving streaming behaviour and normalising encodings -- Attach a diagnostic header (e.g., `x-edgezero-proxy`) identifying which adapter forwarded the call (Fastly, Cloudflare, and Spin do this today; Axum does not) -- Surface provider errors as `EdgeError::internal` so applications can decide how to respond +- Implement `send` and the completion-driven `start_batch_until` driver; ordered collection is + provided once by core through `send_all_until` +- Apply the shared request validation, hop-by-hop normalization, deadline, and response-body rules +- Enforce independent request, encoded response, decoded response, final buffer, header, Brotli, and chunk-shape controls +- Map typed cache-bypass and validated wire-authority policy without changing URI-owned routing, + TLS SNI, or certificate identity +- Preserve typed deadline, transport, protocol, codec, and resource failures without message matching +- Construct every `OutboundResponse` with the same `MonotonicClock` retained by the outbound + client; the constructor has no process-default-clock fallback +- Attach `x-edgezero-proxy: ` to completed outbound responses +- Publish an exact static capability level for every outbound capability + +Provider limitations are part of the contract rather than hidden implementation details. See +[Capabilities](/guide/capabilities) for the support matrix, timing semantics, and accounting +exclusions. Consumers upgrading platform resource or response construction APIs should use the +[Unreleased migration table](https://github.com/stackpop/edgezero/blob/main/CHANGELOG.md#platform-resource-metadata). ## Logging Initialisation -Each adapter exports an `init_logger` helper for platform-specific logging backends. Fastly wires -`log_fastly`, Cloudflare currently no-ops, Spin no-ops because Spin manages its own logging -internally, and Axum uses `simple_logger` in its `run_app` helper. -New adapters should provide a comparable helper so apps consistently opt into logging. +Fastly wires `log_fastly`, and Axum uses `simple_logger`. Their runners initialize logging +before application configuration. Cloudflare and Spin `init_logger` helpers are no-ops: host +console or stdout/stderr capture does not install a Rust `log` backend. + +`owns_logging = true` skips adapter logger setup completely. Install your backend **before** +entering the adapter runner, apply the configured runtime filter to that backend, and set the +facade maximum high enough for all enabled targets. `edgezero_core::resolve_logging_level(&env)` +resolves the supplied platform environment's runtime level (invalid or absent means `Info`); +it neither installs a logger nor reads manifest defaults implicitly. + +Managed Fastly and Axum backends filter `edgezero_core::BOOT_LOG_TARGET` (`edgezero::boot`) at +`BOOT_LOG_LEVEL` (`Warn`) independently of ordinary records. Consequently boot warnings and +errors still appear with ordinary logging set to `Error` or `Off`. Boot `Info`/`Debug` messages +do not appear. The target is not a physical Fastly endpoint. Application-owned loggers choose +their own boot policy; raising `log::set_max_level` alone cannot restore backend-filtered records. ## Contract Tests -To keep the contract enforceable, each adapter includes integration tests that validate request/response conversions and the dispatch helper: +To keep the contract enforceable, each adapter includes tests that validate request conversion, +response framing, and the owned delivery coordinator: -- `into_core_request` for method, URI, header, body, and context propagation -- `from_core_response` for status propagation and streamed body writes -- `dispatch` for routed handlers, body passthrough, and streaming responses +- canonical admission and request conversion for method, URI, header, body, and context propagation +- response-head commit and lazy streamed body writes +- absolute deadlines, accepted-byte accounting, abort/close, and exactly-once terminal reports +- routing, admission refusal, fallback drain, handler, middleware, and fallback response paths ### Fastly Tests @@ -120,10 +154,10 @@ The `wasmtime` version CI uses is pinned in `.tool-versions`. When bringing up another adapter: -1. **Implement request/response conversion functions** that follow the rules above -2. **Provide a context type** exposing the adapter's metadata and insert it in `into_core_request` -3. **Implement a dispatch entry point** (free function or service builder) plus logging helper -4. **Wire up a `ProxyClient`** that streams bodies and normalises encodings +1. **Implement request conversion and an owned response coordinator** that follow the rules above +2. **Provide a context type** exposing the adapter's metadata and insert it during admitted request conversion +3. **Implement a send-owning runner** plus logging helper +4. **Wire up an `OutboundHttpClient`** with limits, deadlines, batching, and typed errors 5. **Copy the contract test suite**, swapping in the new adapter types. Ensure the tests are gated to the target architecture if the adapter SDK does not compile for native hosts 6. **Register the adapter** with `edgezero-adapter::register_adapter` (typically in a `cli` module using the `ctor` crate) so the CLI can discover it dynamically. To take part in `provision` and `config push`, override the relevant `Adapter` trait hooks: `single_store_kinds` and `merged_id_kinds` declare the platform's store shape, `validate_adapter_manifest` checks the adapter's own manifest, `provision` creates platform resources, and `push_config_entries` plus `read_config_entry` move config blobs. `single_store_kinds` and `merged_id_kinds` default to empty, `validate_adapter_manifest` defaults to accepting, `provision` defaults to a no-op, and the config push and read hooks default to reporting the operation as unsupported @@ -152,7 +186,7 @@ logical id under both is a collision that `config validate` rejects. | Axum | `secrets` | none | No | No | Fastly is the only adapter implementing `gc_config_entries` and the staging lifecycle -actions (`DeployStaged`, `EmitVersion`, `Healthcheck`, `Rollback`); the others return +actions (`DeployStaging`, `EmitVersion`, `Healthcheck`, `Rollback`); the others return an unsupported error for those. ## Opt-in application retention @@ -164,12 +198,21 @@ can change and no refresh policy has been defined. Application ownership and request scheduling are separate. An `App` can be retained by its caller; each adapter still controls how requests arrive: -| Adapter | Existing default | Explicit retention | -| ---------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| Fastly | Build for each single-request invocation | `serve_app` with an SDK `Serve`, or [`lifecycle::serve_custom`](/guide/adapters/fastly#custom-dispatch-and-streaming) | -| Cloudflare | Build on each fetch | Concrete application-owned cache and `dispatch_app` | -| Spin | Build on each invocation | Concrete cache and `dispatch_app` on a compatible host | -| Axum | Build once at server startup | Already retains the router | +| Adapter | Existing default | Explicit retention | +| ---------- | ---------------------------------------- | ------------------------------------------------------------------------------------------ | +| Fastly | Build for each single-request invocation | `serve_app` / `serve_app_with_hooks` with SDK `Serve`, backed by `lifecycle::Sandbox` | +| Cloudflare | Build on each fetch | Concrete application-owned cache and `dispatch_app` | +| Spin | Build on each invocation | Concrete cache and `dispatch_app` on a compatible host | +| Axum | Build once at server startup | Already retains the app and router | + +The canonical assembly path is `App::build::(PLATFORM) -> Result`. +It installs platform metadata before calling fallible `Hooks::configure` and rejects +successful configuration that changes that metadata. Retain only a successfully +configured app; never cache an initialization error or publish a partial app. +Cloudflare uses `dispatch_app::(&app, req, env, ctx)`; Spin uses +`dispatch_app::(&app, req)` and returns a raw `SpinResponse`. Neither builds an +app nor initializes logging. The caller must pair the app with its construction +`Hooks` type and selected platform. Retain owned settings, parsed objects, and bounded caches only when their staleness policy is acceptable. Keep native request handles, bodies, store @@ -178,6 +221,11 @@ and cloning do not prove host-resource validity. Shared `Arc` interiors remain shared; the framework does not reset them between requests. Registered app state continues to overwrite request extensions of the same type. +Every dispatch creates fresh admission state and response-egress ownership in +addition to fresh registries. Fastly's preparation/finalization hooks borrow +request-local values; the adapter owns sending and lazy streaming. Retention does +not expose a response-returning Fastly dispatch path or add response buffering. + Applications own refresh, invalidation, key rotation, initialization-failure policy, and workload benchmarks. Publish complete snapshots; overlapping requests keep the snapshot they acquired. Never put an unkeyed static in a generic cache diff --git a/docs/guide/adapters/spin.md b/docs/guide/adapters/spin.md index 4fd7b0d11..b4a42300d 100644 --- a/docs/guide/adapters/spin.md +++ b/docs/guide/adapters/spin.md @@ -26,11 +26,11 @@ crates/my-app-adapter-spin/ The Spin entrypoint wires the adapter via `#[http_service]`: ```rust -use spin_sdk::{http::IntoResponse, http::Request, http_service}; +use spin_sdk::{http::Request, http_service}; use my_app_core::App; #[http_service] -async fn handle(req: Request) -> anyhow::Result { +async fn handle(req: Request) -> anyhow::Result { edgezero_adapter_spin::run_app::(req).await } ``` @@ -38,6 +38,8 @@ async fn handle(req: Request) -> anyhow::Result { `run_app` reads the portable store metadata baked into `App` by the `app!` macro plus `EDGEZERO__*` environment variables; it does not require an `edgezero.toml` to be present at runtime. +If `Hooks::configure` fails, `run_app` returns the source-preserving +`application configuration failed` error before converting or dispatching the request. ## Building @@ -71,6 +73,25 @@ edgezero deploy --adapter spin spin deploy --from crates/my-app-adapter-spin ``` +## Outbound HTTP + +`SpinOutboundClient` uses the WASI HTTP 0.3 interfaces directly so request-body production, +response completion, limits, and the guest-visible deadline race share one owner. Generated +`spin.toml` files default `allowed_outbound_hosts` to `https://*:*`; cleartext requires an explicit +manifest declaration. + +Spin exposes raw header bytes and drives isolated batch slots in completion order. Cache bypass +adds no synthetic origin header because the adapter owns no intermediary cache. A wire-authority +override is rejected during preflight: WASI HTTP uses the same authority for connection routing, +so Spin cannot preserve URI-owned routing and TLS identity while changing only the HTTP authority. +Deadline, batch cancellation, and streamed-upload cancellation remain BestEffort until host +teardown has a documented observed bound, and provider phase-timer defaults may prevent a fully +elastic budget. Downstream response +delivery owns the raw WASI response, body, and result writers in one coordinator and preserves +lazy body production under host backpressure. Successful close is a host handoff, not proof of +client receipt, so response-egress capabilities also remain BestEffort. See +[Capabilities](/guide/capabilities). + ## KV Storage Spin KV is **label-backed and multi-store** — each logical id in @@ -196,9 +217,10 @@ Schema-coupling note: the SQLite writer uses the exact `spin_key_value` schema and `INSERT … ON CONFLICT DO UPDATE` statement vendored from spinframework/spin's `crates/key-value-spin/src/store.rs`. A contract test in `edgezero-adapter-spin/src/cli/push_sqlite.rs` asserts -byte-equality against the upstream string, and the workspace's -`spin-sdk = "~6.0"` pin blocks any Spin minor bump that would change -the schema until the operator opts in. +byte-equality against the upstream Spin 4.1.0 strings. The workspace's +exact `spin-sdk = "=7.0.0"` pin makes every SDK upgrade explicit, and +the CLI warns when the installed Spin runtime major is outside the +schema-verified 2.x through 4.x range. ```bash # Local dev: writes through to .spin/sqlite_key_value.db. @@ -253,31 +275,18 @@ surfaces before `provision` / `config push` run. ## Logging -`edgezero_adapter_spin::init_logger()` is a no-op today because Spin manages its own -logging internally; `run_app` calls it unless your app sets `owns_logging = true`. An +`edgezero_adapter_spin::init_logger()` is a no-op; `run_app` calls it before application +configuration unless your app sets `owns_logging = true`. An `[adapters.spin.logging]` table in `edgezero.toml` is not consumed by the Spin runtime. View output through `spin up` or your Spin host's log files. ::: tip Logging status -Spin logging is handled by the runtime; install your own `log` implementation in the -entrypoint if you need structured output. +Host stdout/stderr capture does not install a Rust `log` backend. Install yours before the +adapter entrypoint and set `owns_logging = true` if you need facade output. Resolve ordinary +levels with `edgezero_core::resolve_logging_level(&EnvConfig)` and apply both backend and facade +filters; the adapter does not promise boot-target visibility without that backend. ::: -## Proxy Client - -The Spin adapter forwards outbound requests through `spin_sdk::http::send`: - -```rust -use edgezero_adapter_spin::proxy::SpinProxyClient; -use edgezero_core::proxy::ProxyService; - -let response = ProxyService::new(SpinProxyClient).forward(request).await?; -``` - -Proxied responses carry `x-edgezero-proxy: spin`. Every upstream host must be listed -in the component's `allowed_outbound_hosts` in `spin.toml` or the send fails at -runtime. - ## Context Access Access Spin-specific request metadata via the request context extensions: @@ -328,29 +337,52 @@ application logging before the cache's synchronous app constructor. Do not use a generic unkeyed static or hold a lock across an await. ```rust -use edgezero_core::app::{App, Hooks}; -use spin_sdk::{http::{IntoResponse, Request}, http_service}; +use edgezero_adapter_spin::{SPIN_PLATFORM, SpinResponse}; +use edgezero_core::{app::App, EdgeError}; +use my_app_core::App as MyApp; +use spin_sdk::{http::Request, http_service}; use std::sync::OnceLock; static APP: OnceLock = OnceLock::new(); +fn retained_app() -> Result<&'static App, EdgeError> { + if let Some(app) = APP.get() { + return Ok(app); + } + let app = App::build::(SPIN_PLATFORM)?; + Ok(APP.get_or_init(|| app)) +} + #[http_service] -async fn handle(req: Request) -> anyhow::Result { - let app = APP.get_or_init(MyApp::build_app); +async fn handle(req: Request) -> anyhow::Result { + let app = retained_app()?; edgezero_adapter_spin::dispatch_app::(app, req).await } ``` -The pinned SDK 6 macro exports a P3 HTTP interface. The `wasm32-wasip2` Rust target +`App::build::(SPIN_PLATFORM)` returns `Result` and invokes +fallible `Hooks::configure` after installing platform metadata. For Akamai Functions, +select `AKAMAI_FUNCTIONS_PLATFORM`; other hosts can supply metadata matching their +configured limits. Only a successful app is cached, so a failed build can be retried. +Concurrent construction attempts may build multiple candidates; only one is retained. +The app must be built from the same `Hooks` type used by dispatch. + +`dispatch_app(&App, SpinRequest) -> anyhow::Result` returns +the raw WASI response, not a collected SDK response. Each invocation resolves fresh +registries and runs admission and response egress; the adapter's coordinator owns +the body writer and transmission-result observation through terminal delivery. + +The pinned SDK 7 macro exports a P3 HTTP interface. The `wasm32-wasip2` Rust target name does not establish the component's HTTP lifecycle. Verify the emitted -interface and host together. spin-sdk 6 requires Spin 3.7 or later. See Spin's +interface and host together. See Spin's [instance-reuse documentation](https://spinframework.dev/v4/http-trigger#controlling-instance-reuse) for the reuse and concurrency controls your host version supports. Test both sequential reuse and overlapping invocations in the same instance. Different guest instances are not evidence of concurrent isolation. Keep -request/response bodies and pending operations invocation-local. Existing -response buffering and stream collection limits still apply. Restore `run_app` +request/response bodies, admission resources, delivery coordinators, and pending +operations invocation-local. Standard response delivery remains lazy under host +backpressure; retention adds no whole-response buffer or collection cap. Restore `run_app` and remove the cache to roll back; default generated entry points are unchanged. ## Next Steps diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index b01f54dd9..3c0e625d1 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -29,7 +29,8 @@ edgezero/ - **Extractors** - `Json`, `Path`, `Query`, `Form`, `Headers`, `Host`, `ForwardedHost`, and `Validated*` variants - **Middleware** - Composable middleware chain with async support - **Manifest** - `edgezero.toml` parsing and validation -- **Compression** - Shared gzip/brotli stream decoders +- **Compression** - Shared gzip/deflate/brotli stream decoders and typed passthrough classification +- **Outbound HTTP** - Typed requests, responses, limits, deadlines, batching, and capability contracts Handlers in your core crate only depend on `edgezero-core`, keeping them portable. @@ -58,26 +59,27 @@ Adapters translate between provider-specific types and the portable core model: - Converts Fastly `Request` to `edgezero_core::http::Request` - Maps core responses back to Fastly `Response` - Provides `FastlyRequestContext` for accessing Fastly-specific APIs -- Implements `FastlyProxyClient` for upstream requests +- Injects `FastlyOutboundClient` for upstream requests ### edgezero-adapter-cloudflare - Converts Workers `Request` to core request - Maps responses to Workers `Response` - Provides `CloudflareRequestContext` for Workers APIs -- Implements `CloudflareProxyClient` for fetch operations - -### edgezero-adapter-spin - -- Converts `spin_sdk::http::Request` to core request and back -- Provides `SpinRequestContext` for Spin-specific APIs -- Implements `SpinProxyClient` for outbound requests +- Injects `CloudflareOutboundClient` for fetch operations ### edgezero-adapter-axum - Wraps `RouterService` in Axum/Tokio services - Powers the local development server - Supports native container deployments +- Injects `AxumOutboundClient` backed by `reqwest` + +### edgezero-adapter-spin + +- Converts Spin/WASI HTTP requests and responses +- Resolves component-scoped KV, config, and secret bindings +- Injects `SpinOutboundClient` backed by WASI HTTP 0.3 ## CLI Crate @@ -93,16 +95,16 @@ Adapters translate between provider-specific types and the portable core model: ``` ┌─────────────────────────────────────────────────────────────┐ │ Provider Runtime │ -│ (Fastly Compute / Cloudflare Workers / Axum Server) │ +│ (Fastly Compute / Cloudflare Workers / Spin / Axum) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Adapter │ │ - into_core_request(): Provider Request → Core Request │ -│ - from_core_response(): Core Response → Provider Response │ -│ - run_app(): Canonical lifecycle │ -│ - dispatch(): Low-level manual lifecycle │ +│ - response coordinator: Core envelope → owned delivery │ +│ - run_app(): Canonical request and response lifecycle │ +│ - adapter service: Optional low-level send-owning wiring │ └─────────────────────────────────────────────────────────────┘ │ ▼ diff --git a/docs/guide/blob-app-config-migration.md b/docs/guide/blob-app-config-migration.md index 2d9956a94..e2764c4bd 100644 --- a/docs/guide/blob-app-config-migration.md +++ b/docs/guide/blob-app-config-migration.md @@ -99,7 +99,11 @@ with the same data produce blobs with identical `sha256` and different The push writes to `.edgezero/local-config-.json` next to your `edgezero.toml`. The file is a JSON map of `{ "": "" }`; -the dev server reads the envelope through `AxumConfigStore::get`. There +the dev server reads the envelope through a shared `ConfigValue` snapshot loaded +before listener bind. Push before startup or restart the server afterward. The +raw `ConfigStore` API now returns `ConfigValue`; custom store implementations must +migrate both read methods atomically. Borrow its UTF-8 bytes with `as_str()` and +reserve `to_string()` for deliberate ownership copies. There is no `--local` flag because Axum's push IS always local. ### Cloudflare diff --git a/docs/guide/capabilities.md b/docs/guide/capabilities.md new file mode 100644 index 000000000..fd923dae3 --- /dev/null +++ b/docs/guide/capabilities.md @@ -0,0 +1,477 @@ +# Capabilities + +Capabilities let an application state which portable runtime behaviors it depends on. +`edgezero build`, `serve`, and `deploy` compare the manifest declaration with the selected +adapter before starting the adapter command. + +## Declaration + +```toml +[capabilities] +required = ["outbound-deadlines"] +optional = ["outbound-http"] + +[capabilities.outbound] +hosts = ["https://api.example.com", "https://*.example.net:*"] +``` + +A capability cannot appear more than once or in both lists. `required` accepts `Native` +and `BoundedCooperative`; it rejects `BestEffort` and `Unsupported`. `optional` never +blocks an operation, but degraded and unavailable support is logged. A missing or malformed +capability contract fails closed when it contains required entries. + +Support levels mean: + +- `Native`: fully supported without a documented deviation. +- `BoundedCooperative`: enforced with a documented deterministic bound. No current outbound + matrix cell uses this level. +- `BestEffort`: implemented, but with a documented behavioral deviation or an unverified + deployment prerequisite. +- `Unsupported`: unavailable on that adapter. + +## Platform Memory Ceilings + +`Adapter::platform_metadata()` reports target-owned resource facts separately from behavioral +capabilities. Runtime adapters install the same `PlatformMetadata` before `Hooks::configure` +runs, so startup validation and CLI tooling use one canonical value. `Primary ceiling` excludes +the separately published Fastly stack allowance. A source is shown only for a known fact; an +unknown fact instead records why it is unknown. + +| Target | Primary ceiling | Separate stack | Scope | Memory source / reason | Live inbound requests | Population source / reason | Host ingress/framing charge | Accounting source / reason | +| ----------------------- | ------------------------- | -------------------- | ------------- | ---------------------------------- | --------------------- | -------------------------------- | --------------------------- | ----------------------------- | +| Axum | Unknown | Unknown | Unknown | Unknown: Operator configured | Unknown | Unknown: Operator configured | Unknown | Unknown: Operator configured | +| Cloudflare Workers | 128000000 bytes (128 MB) | None | Per instance | Platform limit: Cloudflare Workers | Unknown | Unknown: Provider unpublished | Unknown | Unknown: Provider unpublished | +| Fastly Compute | 128000000 bytes (128 MB) | 1000000 bytes (1 MB) | Per execution | Platform limit: Fastly Compute | 1 | Platform limit: Fastly Compute | Unknown | Unknown: Provider unpublished | +| Spin (generic) | Unknown | Unknown | Unknown | Unknown: Runtime configured | Unknown | Unknown: Runtime configured | Unknown | Unknown: Runtime configured | +| Akamai Functions (Spin) | 134217728 bytes (128 MiB) | None | Per execution | Hosted default: Akamai Functions | 1 | Hosted default: Akamai Functions | Unknown | Unknown: Provider unpublished | + +Cloudflare's limit is shared by concurrent requests in one isolate, but the provider does not +publish the maximum live inbound request population charged to that memory domain. Its limit of +six simultaneous outbound connections applies to connections waiting for response headers within +one invocation; it is not an inbound population bound. Fastly publishes per-execution heap and +separate stack allowances with a population of one. Generic Spin leaves all three facts unknown +because deployment configuration controls them. Applications deployed to Akamai Functions opt +into `AKAMAI_FUNCTIONS_PLATFORM`, which publishes the hosted 128 MiB per-execution default and a +population of one rather than changing the meaning of every Spin deployment. + +All current adapters receive host-parsed requests. Where the provider does not publish whether +host parser, HPACK, or framing allocations count toward the memory ceiling, host-ingress +accounting remains `Unknown: Provider unpublished`; guest-visible allocation data cannot fill in +that fact. Unknown memory, population, or host-accounting facts make +`PlatformMetadata::validate_memory_envelope` return `Indeterminate`, so only `Fits` is a complete +validation result. A known minimum can still return `Exceeds` before all other facts are known. + +Sources: [Cloudflare Workers limits](https://developers.cloudflare.com/workers/platform/limits/), +[Fastly Compute resource limits](https://docs.fastly.com/products/compute-resource-limits), and +[Akamai Functions quotas](https://techdocs.akamai.com/akamai-functions/docs/quotas-and-limits). + +## Ingress Matrix + +| Capability | Axum | Cloudflare | Fastly | Spin | +| -------------------------------- | ----------- | ----------- | ----------- | ----------- | +| `ingress-admission` | Native | Native | Native | Native | +| `ingress-admission-abort` | Native | BestEffort | BestEffort | BestEffort | +| `inbound-read-deadlines` | Native | BestEffort | BestEffort | BestEffort | +| `raw-ingress-head-limits` | Unsupported | Unsupported | Unsupported | Unsupported | +| `raw-ingress-framing-validation` | Unsupported | Unsupported | Unsupported | Unsupported | + +All standard adapters resolve the route and run the application admission policy before +middleware, handler dispatch, or body polling. Axum can preempt an asynchronous body read. +Cloudflare and Spin race pending reads against platform timers and enforce absolute checks +around ready results; both require deployed probes before host-side teardown latency can be +bounded. Fastly cannot preempt a synchronous host body read. Those limitations keep the three +deadline cells at `BestEffort`. + +`AdmissionDecision::Abort` is a non-response outcome: it polls no body, invokes no middleware or +handler, starts no response-egress lifecycle, and emits no HTTP response. Axum owns the HTTP/1 +connection and closes or resets it at that boundary, so its abort capability is `Native`. +Cloudflare, Fastly, and Spin convert the outcome to a fixed provider error before response +delivery; their hosts decide the transport effect, so those cells remain `BestEffort` pending +deployed proof of a reset or close. + +The detached-egress decision factory can select the same non-response outcome for normalized +request-head validation or admission-policy failures when an application cannot reserve a bounded +response resource. Axum routes that decision through its proven connection-owned abort path. +Cloudflare, Fastly, and Spin route it through their fixed pre-response provider error boundaries; +this does not promote their `BestEffort` capability cells. + +All current adapters expose `IngressHeadAccounting::HostManaged` and +`IngressFraming::HostManaged`. They perform defensive checks on the normalized request +information they receive, but those checks do not prove parser-boundary accounting or +ambiguous HTTP/1 framing rejection. Neither raw-ingress capability is currently available on +any adapter. + +Axum additionally enforces a finite adapter-owned HTTP/1 connection pool, combined raw-head +byte and field-count limits, and absolute idle-plus-header timeouts before admission. Its +[bounded HTTP/1 ingress configuration](/guide/adapters/axum#bounded-http1-ingress) publishes the +effective limits, accept-loop overshoot, parser storage assumptions, and response-header cache +safety policy. These controls do not supply independent raw-target/field-section accounting or +the strict framing policy, so they do not promote either raw-ingress capability above. + +Admission is body-blind. An application may opt into `ReadBodyBeforeFallback` for pre-resolved +404/405 requests when it needs body-limit precedence: clean EOF at or below the selected cap +retains the canonical 404/405, the first byte over returns the application-selected buffered +`on_exceeded` response first, and expiration of the one absolute read deadline returns the +application-selected buffered `on_timeout` response first. The application-supplied ingress +grant remains live throughout the drain and is released before response conversion, when the +core drain future is dropped, or when the finite read deadline terminates the drain. This path +discards the body and invokes no route middleware, handler, or request context. `Refuse` remains +the zero-read overload path, and ordinary admission retains the immediate, no-poll 404/405 +behavior. Axum dispatches the admission and fallback-drain future directly on its connection-local +executor, so cancellation drops that future and its grant. Cloudflare, Fastly, and Spin retain the +provider limitations described above. + +## Config Matrix + +| Capability | Axum | Cloudflare | Fastly | Spin | +| ------------------------------- | ----------- | ----------- | ----------- | ----------- | +| `config-read-allocation-bounds` | Unsupported | Unsupported | Unsupported | Unsupported | +| `config-read-deadlines` | BestEffort | BestEffort | BestEffort | BestEffort | + +Every adapter enforces per-value and cumulative guest-visible config byte caps before EdgeZero +retains or processes a returned value. That does not bound provider-side materialization, +parser allocation, SDK copies, or other work performed before the value reaches guest code, so +`config-read-allocation-bounds` remains `Unsupported` everywhere. + +Axum's local config-store path now has [bounded startup snapshots](/guide/adapters/axum#snapshot-allocation-limits): +actual file reads, flat-string parsing, per-key/value and entry caps, aggregate snapshot +residency and startup staging are enforced before listener binding. Request reads clone +shared `ConfigValue` handles without copying payloads. This narrower guarantee does not +certify environment-backed secrets, fixed registry metadata, allocator overhead or all +concurrent extraction allocations, so the broad capability is not promoted. + +Cloudflare bounded config reads request KV byte streams and use reusable 16 KiB Rust/JS +BYOB buffers. With `L = min(max_backend_bytes, max_value_bytes)`, the requested payload +storage is bounded conservatively by `2L + 32 KiB`, including accumulator relocation or +the final shared-string copy. Invalid UTF-8 is rejected, not lossily expanded. There is no +text/array-buffer fallback. Overflow, timeout and caller cancellation cancel an acquired +reader; cancellation while waiting for KV acquisition cannot cancel the provider promise. +An acquisition owner cancels any stream that arrives after abandonment. Cancel-promise +rejections are consumed without publishing provider diagnostics. Ready reads check the +application clock before and after each pull and yield to the task queue every 16 chunks. +Bounded KV reads require `FinalizationRegistry` and fail before provider dispatch without it. +Generated/demo Workers explicitly enable `enable_weak_ref`; GC timing remains nondeterministic, +not part of the payload charge. See [Workers compatibility flags](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#enable-finalizationregistry-and-weakref). +Opaque KV buffering, JS wrapper/GC retention and host teardown remain unqualified. + +Fastly bounded config reads use the pinned host ABI with one initialized caller-sized +buffer, never retrying a `BUFLEN` result with the provider's requested length. Physical +reads have an adapter ceiling of 32,000 bytes. Root reads allow at most +`R = min(max_backend_bytes, max(max_value_bytes, 8000), 32000)`; pointer JSON is limited to +8,000 bytes and chunk reads to 7,000 bytes and both remaining allowances. Validated +declared totals must fit before any chunk fetch or reassembly allocation. The bounded +resolver verifies chunk/transport hashes and layout without constructing an envelope +`Value`; the extractor performs inner-envelope verification after its structural scan. +A conservative requested guest-payload charge is `2V + R + 16P + 7000` bytes, with +`V = max_value_bytes`, `P = 8000`, plus fixed objects and allocator overhead. This includes +pointer/schema/index scratch, one chunk, reassembly and the shared-string copy; provider +storage and synchronous-call interruption are excluded. Raw `get` and CLI integrity +operations are not allocation-certified. + +Spin's list-valued host get still materializes a complete value. Stock Spin 4.1.0 has no +configurable read-size cap in its KV interface; Akamai's published 1 MB default value quota +does not establish an exact read-allocation or overridden-quota contract. Neither is used +as a guessed allocation guarantee. A bounded host/backend interface or verified hosted +enforcement is still required. + +The response-egress specification's evidence matrix +(`docs/superpowers/specs/2026-09-08-response-egress-design.md`, section 8) +distinguishes local ownership proofs from deployed lifecycle qualification. Opt-in fixtures and +`scripts/qualify_response_egress.mjs` collect correlated terminal/source/resource evidence; +they cannot certify peer delivery, same-instance recovery or provider allocation. No deployed +artifact has been supplied for the current hosted adapters, so their guarantees are not promoted. + +Config reads use one absolute extraction deadline in the application's monotonic clock domain. +Core passes that same clock through every root, Fastly chunk, and secret read. Adapters sample it +before provider dispatch and after readiness, before propagating either success or provider +failure, so equality expiry wins simultaneous readiness. Cloudflare and Spin also race asynchronous +guest futures against native timers. A timer wake is checked against the injected clock and re-armed +when it arrives early. This bounds EdgeZero's return but does not prove host-side cancellation. Axum +and Fastly provider operations are synchronous and guest-nonpreemptible. No provider operation is +proved cancellable within a finite wall-clock interval, so every adapter reports `BestEffort` for +`config-read-deadlines`. Spin opens declared config labels inside that timed read, after ingress +admission; Cloudflare and Fastly retain failed declared bindings as typed failing handles rather +than treating them as absent. The +[configuration design](https://github.com/stackpop/edgezero/blob/main/docs/superpowers/specs/2026-06-16-blob-app-config.md#632-bounded-capability-scoped-extraction-reads) +defines the normative accounting, cancellation, error, and promotion requirements. + +Application assembly is fallible. `Hooks::configure(&mut App) -> Result<(), EdgeError>` runs after +the runtime adapter installs `PlatformMetadata` and before EdgeZero request conversion, body +polling, or dispatch. The hard-cut, core-owned `App::build::(platform)` constructor requires +every caller to choose target metadata explicitly and propagates the configuration result. After +successful configuration, core rejects changed platform facts, provenance, or unknown reasons +rather than silently restoring them. Axum also completes assembly before binding its listener. + +## Response Egress Matrix + +| Capability | Axum | Cloudflare | Fastly | Spin | +| ------------------------------ | ---------- | ---------- | ---------- | ---------- | +| `response-egress-abort` | BestEffort | BestEffort | BestEffort | BestEffort | +| `response-egress-backpressure` | BestEffort | BestEffort | BestEffort | BestEffort | +| `response-egress-completion` | BestEffort | BestEffort | BestEffort | BestEffort | +| `response-write-deadlines` | BestEffort | BestEffort | BestEffort | BestEffort | + +These cells describe transport-observable client-response delivery, not response conversion. +Every adapter now owns a response coordinator through its strongest available host boundary, +uses the application's monotonic clock, applies one absolute write deadline, and emits one +terminal report. The boundaries remain `BestEffort` because none proves end-client receipt: + +- Axum commits when Hyper accepts the response head, accounts payload frames as Hyper polls them, + closes the owned HTTP/1 connection on deadline, and reports clean source EOF as `HostHandoff`. +- Cloudflare awaits JavaScript writer promises, races request abort/deadline signals, and keeps the + coordinator alive with `waitUntil`; deployed disconnect and completion timing remain unproved. +- Fastly accounts synchronous body-handle writes through `stream_to_client` close, but cannot + preempt a blocked source poll or hostcall and loses the body handle if `finish` itself fails. +- Spin owns the WASI response/body/result writers through handoff and races pending operations + against its timer; host teardown and network completion remain provider-observable only. + +Provider-owned buffering, SDK allocations, and bytes beyond each stated acceptance boundary are +not included. These limitations block whole-response-lifetime certification. + +## Outbound Matrix + +| Capability | Axum | Cloudflare | Fastly | Spin | +| --------------------------------------- | --------------------------------- | --------------------------------- | --------------------------------- | --------------------------------- | +| `outbound-http` | Native | Native | BestEffort⁹ | Native | +| `outbound-authority-override` | Native | BestEffort⁸ | Native | Unsupported[^spin-authority] | +| `outbound-batch-cancellation` | Native | BestEffort⁸ | BestEffort⁴ | BestEffort⁸ | +| `outbound-batch-completion-order` | Native | Native | BestEffort⁴ | Native | +| `outbound-batch-slot-isolation` | Native | Native | BestEffort⁴ | Native | +| `outbound-cache-bypass` | Native | Native | Native | Native | +| `outbound-complete-resource-accounting` | Unsupported[^resource-accounting] | Unsupported[^resource-accounting] | Unsupported[^resource-accounting] | Unsupported[^resource-accounting] | +| `outbound-header-fidelity` | Native | BestEffort⁸ | Native | Native | +| `outbound-deadlines` | Native | BestEffort⁸ | BestEffort¹ | BestEffort⁸ | +| `outbound-flexible-phase-budget` | Native | Native | BestEffort⁵ | BestEffort⁵ | +| `streamed-upload-deadlines` | Native | BestEffort⁸ | BestEffort² | BestEffort⁸ | +| `lazy-streamed-response-passthrough` | Native³ | Native | BestEffort⁶ | BestEffort⁷ | + +[^resource-accounting]: + Complete accounting includes provider parsing and field-section + materialization, informational responses, final headers, trailers, native receive chunks, + guest buffers and spare capacity, decoder state, allocator metadata, and runtime copies. + Current provider APIs do not expose or bound every term, so all adapters report + `Unsupported`. The narrower EdgeZero-owned limits below still apply. + +[^spin-authority]: + Spin's WASI HTTP authority also selects the connection target. An explicit HTTP authority + override is rejected before body polling or provider dispatch because the adapter cannot + preserve the required routing/TLS separation. + +¹ Fastly cannot preempt cold dynamic-backend registration or guest-to-origin writes. Before a +cache-miss registration in `start_batch_until`, EdgeZero checks the absolute deadline and 25 ms dispatch +slack so an already-delayed later slot does not begin another synchronous registration. A first +cold registration that passes that check can still block. Receive timers and absolute checks +cover the documented warm, response-read portions, but they are not one end-to-end wall-clock +guarantee. Exact ceiled-millisecond budgets are part of backend identity, so heterogeneous hosts +or budgets may require multiple registrations. + +² Fastly checks the absolute deadline between streamed upload chunks, but it cannot preempt a +stalled source pull or host write. + +³ Axum preserves the portable non-`Send` stream on a connection-local executor and yields one +payload frame at a time under Hyper demand. Unit and raw-socket tests prove first-frame delivery +before source EOF. Socket/client completion remains separately BestEffort under the response-egress +capabilities above. + +⁴ Fastly dispatches slots sequentially, selects ready pending handles within provider-bounded +groups, and drains each selected response body synchronously. Cold backend registration can delay +a later dispatch; a selected body drain can delay a sibling's terminal observation, and a ready +handle outside the active selection group can remain temporarily unobserved. + +⁵ Fastly divides a total budget among provider phase timers. Spin's host may reject phase +timer settings and retain opaque defaults. Neither can promise one fully elastic pool. + +⁶ Fastly uses an undecorated, send-owning entrypoint and the low-level `stream_to_client` response +lifetime. Scripted pump tests and the raw ABI Viceroy test prove the pieces, but the production +`run_app` composition has not yet been exercised end to end under Viceroy. The lazy-passthrough +cell therefore remains `BestEffort`; synchronous preemption and finish limitations separately +keep the response-egress cells BestEffort. + +⁷ Spin writes one portable chunk at a time through the WASI body writer. Unit tests and target +compilation prove the coordinator, but a live Spin component test has not yet established the +generated production composition. Host teardown and network completion also lack deployed proof. + +⁸ Spin cancellation is cooperative until host teardown is observable within a documented +bound. Cloudflare wires one owned `AbortController` through fetch and streamed body consumption, +but no deployed host-observed cancellation artifact currently proves a finite cancellation bound; +its outbound and streamed-upload deadline cells therefore remain `BestEffort`. Cloudflare also +exposes normalized header strings rather than original raw octets and field boundaries, although +it preserves the visible header semantics used by the client. + +⁹ Fastly outbound HTTP uses dynamic backends, which must be enabled on the deployed service. +The CLI cannot currently prove that entitlement. If disabled, dispatch returns a typed 502 +with the documented enablement diagnostic. + +## Outbound Content-Type Provenance + +`OutboundResponse::content_type_provenance()` reports original `Content-Type` evidence +captured before EdgeZero drops non-UTF-8 values or hop-by-hop/connection-nominated fields. +It is not MIME validation, an acceptance decision, or a debug-safety guarantee. + +| Provenance | Meaning | +| ---------------------------- | ------------------------------------------------------------------------------------------------------- | +| `SingleTextUnchanged` | Exactly one original UTF-8 value, retained unchanged, with no later mutable header access. | +| `Ineligible(Missing)` | No original value. | +| `Ineligible(MultipleValues)` | Multiple original values, even identical values or a textual value plus one later dropped as non-UTF-8. | +| `Ineligible(NonText)` | Exactly one original value, not valid UTF-8. | +| `Ineligible(Removed)` | An original textual singleton was stripped, such as by `Connection: content-type`. | +| `Ineligible(Modified)` | Mutable header access invalidated an originally eligible response. | +| `Unknown` | The adapter cannot recover original bytes or field boundaries; this says nothing about MIME validity. | + +Axum, Fastly, and Spin retain the original normalization metadata on buffered, streamed, and +bodyless response paths. Cloudflare **always** reports `Unknown`, including for absent or +apparently single values: Fetch string iteration has already lost original octets and multiplicity. +Core does not trim, split commas, or MIME-validate. A known single +`application/json, text/plain` value can be `SingleTextUnchanged`; the application must still +reject it during MIME validation. A quoted comma does not imply multiple original values. + +```rust +use edgezero_core::{ContentTypeProvenance, OutboundResponse}; + +fn content_type_candidate(response: &OutboundResponse) -> Option<&str> { + if response.content_type_provenance() != ContentTypeProvenance::SingleTextUnchanged { + return None; + } + let value = response.headers().get("content-type")?; + let candidate = std::str::from_utf8(value.as_bytes()).ok()?; + // Application must MIME-validate and apply acceptance/redaction policy before use. + Some(candidate) +} +``` + +`headers_mut()` conservatively marks headers dirty even without an edit or when only an +unrelated field changes. The private original evidence stays immutable; the accessor returns +`Ineligible(Modified)` for a previously eligible response. Originally ineligible or unknown +evidence never upgrades, even if the application inserts a valid singleton or restores old bytes. + +For adapter authors, `normalize_response_headers(method, status, &mut headers)` now returns +`ResponseHeadNormalization { body_disposition, content_type_provenance }`. Use +`.body_disposition` for body handling. Pass the original `.content_type_provenance` (always +`Unknown` on Cloudflare) explicitly to the six-argument +`OutboundResponse::new(method, status, headers, body, clock, content_type_provenance)`; +never infer it from the normalized header map. `into_parts()` returns the five-tuple +`(method, status, headers, body, provenance)` without another normalization pass. + +`into_response()` performs final defensive normalization and carries effective +`ContentTypeProvenance` in the resulting response's extensions without upgrading original +ineligible/unknown evidence. That extension describes the **historical outbound boundary only**; +it cannot certify later ordinary `http::Response` edits, which remain application-owned. +Provenance adds only tiny enum metadata, no retained/copied raw headers or body, and no new +capability or change to the matrix above. + +## Host Declarations + +`[capabilities.outbound].hosts` configures platform host plumbing; it is not an application +authorization policy. Enforce user-controlled destination policy in application code. + +- Omitting `hosts` defaults to `https://*:*`. Cleartext is not granted implicitly. +- `"*"` explicitly expands to both `http://*:*` and `https://*:*`. +- A bare host or wildcard subdomain defaults to HTTPS and port 443. +- An explicit scheme may be `http` or `https`; ports are `1..=65535` or `*`. +- Paths, queries, fragments, user information, whitespace, non-ASCII names, and malformed + wildcards are rejected. + +Spin receives the canonicalized entries through `allowed_outbound_hosts`. Cloudflare does not +need a build-time destination list. Fastly creates deterministic dynamic backends from the +canonical target. Axum applies the same manifest validation even though the native client does +not require provider host registration. + +## Limits And Accounting + +Each `OutboundRequest` owns independent limits: + +| Control | Scope | Default | +| ---------------------------- | ---------------------------------------------------------------------------------------------- | ------- | +| `max_request_body_bytes` | Buffered or streamed request bytes | 8 MiB | +| `max_encoded_response_bytes` | Upstream transport bytes before decoding | Unset | +| `max_decoded_response_bytes` | Identity or EdgeZero-decoded gzip/deflate/Brotli output | Unset | +| `max_response_bytes` | Final buffered response, including raw passthrough | 1 MiB | +| `max_response_header_bytes` | Adapter-visible upstream header name/value bytes before normalization, plus `x-edgezero-proxy` | Unset | +| `max_response_header_count` | Adapter-visible upstream header fields before normalization, plus `x-edgezero-proxy` | Unset | +| `max_brotli_window_bits` | Brotli stream header checked before decoder allocation | 24 | +| `max_decoder_bytes` | Pinned policy charge for Brotli/gzip/deflate decoder state | 32 MiB | +| `max_chunk_bytes` | Maximum emitted item size after decoding or passthrough | Unset | + +The encoded counter applies to every response path. The decoded counter applies to identity +and gzip/deflate/Brotli data decoded by EdgeZero, but not to unknown, stacked, parameterized, or other +raw passthrough encodings. The final buffered cap is independent of both. This separation lets +an application permit a larger raw body while keeping decoded expansion small. + +`max_chunk_bytes` is an item-shape guarantee, not a source-allocation limit: splitting a +`Bytes` value may retain its original allocation, and a provider may have already materialized +the source chunk. Header controls cover every adapter-visible upstream field before EdgeZero +normalization, including fields later stripped, and then charge EdgeZero's synthetic proxy marker. +They do not cover opaque parser allocations, informational blocks that the SDK hides, or +provider-owned trailers. These exclusions are why +`outbound-complete-resource-accounting` is `Unsupported` on every current adapter. + +For a buffered batch, bound both the number of requests and every per-slot cap. Core-retained payload +is approximately the sum of buffered request bodies, configured final response caps, and one +current chunk per actively draining slot. Adapter staging and host/runtime copies are outside +that formula. + +## Encoding Boundaries + +An application-provided request body is sent with its declared `Content-Encoding`; EdgeZero +does not silently recompress it. Response decoding is automatic only for a single bare `gzip`, +`deflate`, or `br` coding. Multi-member gzip is drained through the final member and transport EOF. +Unknown codings are typed as `Passthrough(Unsupported(token))`, compound codings as +`Passthrough(Stacked)`, and malformed values as `Passthrough(Malformed)`; all remain encoded +and retain `Content-Encoding`. + +Cloudflare has two separate manual-encoding controls. The outbound fetch requests raw encoded +response bytes so EdgeZero can enforce transport and decode limits. When those bytes are later +passed through to the downstream response, the converter also selects manual response-body +encoding so Workers does not encode them again. The latter does not promise an exact streamed +wire `Content-Length`, because Workers owns final framing. + +## Batch Timing + +`HttpClient::start_batch_until` returns an adapter-owned `OutboundBatch`. Calling `next()` yields +`OutboundBatchNext::Item` for each terminal slot at most once in observed completion order, paired +with its original input index, followed by `Finished(Completed)` or `Finished(Cutoff)`. Callers can +therefore retain completed results and cancel unresolved work at an application deadline. +`HttpClient::send_all_until` is the fallible ordered collector over that same driver; on success it +returns one `Option` per input plus the typed termination reason. `Completed` +guarantees all slots are present. Only `Cutoff` permits `None` in a successful result. Explicit +adapter failure, premature EOF, duplicate indices, and out-of-range indices return +`OutboundBatchFailure`: its precise batch-level error remains separate from cutoff, and its +index-aligned slots preserve sibling outcomes observed before the failure. +Normal completion requires clean driver EOF after the last item, not merely the expected item +count. Trailing driver failures and malformed events remain visible, including on empty batches. +`elapsed` starts at the single method-entry monotonic snapshot and ends when that slot becomes +terminal, including preflight validation, adapter setup, provider queueing, upload, headers, +buffered body drain, and any delayed guest observation. It is not pure transport RTT. +Preflight failures are timed from the same batch start, and a same-tick result may be zero. +A successful outcome is contractually terminal and on time. `elapsed` is metadata, not a second +lateness check; callers must not sample their clock after the whole batch returns to reinterpret an +earlier `Ok`, and the API intentionally exposes no absolute terminal or batch-start instant. +On Axum, Cloudflare, and Spin, terminal instants are sampled synchronously as child futures are +observed ready, so their samples follow poll-observation order in the shared monotonic clock +domain. Once an observation reaches the cutoff, a later observation cannot carry an earlier +on-time sample unless an injected clock violates its monotonic contract. Fastly retains its +documented BestEffort completion-order limitation. + +Standard adapter wiring clones the application's monotonic clock into its outbound client, so +ingress timing, dispatch budgets, slot elapsed values, error precedence, and deferred body +streams remain in one clock domain. The low-level `OutboundResponse` constructor requires that +clock explicitly, so third-party adapters cannot silently fall back to the process clock. A +backwards injected clock cannot enlarge the method-entry budget; backwards elapsed sampling fails +closed as an internal slot outcome with zero elapsed. + +Axum, Cloudflare, and Spin drive complete eligible exchanges concurrently. Fastly dispatches +eligible requests before selecting pending handles, but guest dispatch is sequential and body +drain remains observation-bound; a slot's elapsed value can therefore include sibling delay. +The `outbound-batch-slot-isolation` and `outbound-batch-completion-order` rows expose those +distinctions. + +Batch APIs accept buffered request bodies and buffered response mode only. Use `send` for a +streamed upload or response, and consume a streamed response body inside the same concurrent task +that issued it. + +## Missing Client + +Adapters inject `HttpClient` into request extensions. A handler obtains it with +`RequestContext::http_client()`. The accessor returns `None` when custom/manual wiring omitted +the client; handlers should return an explicit 501 or another application-selected fallback. +The generated project demonstrates the 501 behavior. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 4aef7303f..8a5b019fc 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -62,6 +62,29 @@ Each item must be: - Either a unit struct or zero-argument constructor - Implementing `edgezero_core::middleware::Middleware` +## Capabilities Section + +Use `[capabilities]` to declare portable runtime behavior that the application requires or +explicitly treats as optional: + +```toml +[capabilities] +required = ["outbound-deadlines"] +optional = ["outbound-http"] + +[capabilities.outbound] +hosts = ["https://api.example.com", "https://*.example.net:*"] +``` + +Required capabilities accept `Native` or `BoundedCooperative` support and fail before adapter +execution for `BestEffort` or `Unsupported`. Optional capabilities log degradation and proceed. +Unknown names, duplicates, and required/optional overlap are validation errors. + +Outbound hosts configure adapter plumbing rather than application authorization. Omitting +`hosts` defaults to `https://*:*`; `hosts = ["*"]` is an explicit grant for both HTTP and HTTPS. +See [Capabilities](/guide/capabilities) for the exact grammar, support matrix, and platform +limitations. + ## HTTP Triggers The `[[triggers.http]]` array defines routes: @@ -75,6 +98,7 @@ handler = "my_app_core::handlers::root" [[triggers.http]] id = "echo" +class = "interactive" path = "/echo/{name}" methods = ["GET", "POST"] handler = "my_app_core::handlers::echo" @@ -91,6 +115,11 @@ body-mode = "buffered" | `adapters` | No | Intended adapter filter (metadata; `app!` currently ignores) | | `description` | No | Human-readable description for docs or tooling | | `body-mode` | No | `buffered` or `stream` | +| `class` | No | Opaque route class exposed to ingress admission policy | + +`class` is application-defined metadata. It is available on matched and +method-not-allowed route metadata, but it is not interpreted by EdgeZero and does not change the +route's stable method-plus-pattern identity. ::: tip Adapter filters The `adapters` field is currently metadata for tooling; `app!` wires all triggers regardless of adapter. @@ -224,6 +253,13 @@ handlers can call `ctx.config_store("app_config")` (or Treat config-store keys like API surface: validate or allowlist any user-controlled lookup before calling `ctx.config_store_default()?.get(...)`. +Raw `ConfigStore::get` and `get_bounded` return shared `ConfigValue` UTF-8 payloads, +not `String`. Cloning shares one value's allocation; borrow with `as_str()` or +`as_ref()`, or explicitly allocate with `to_string()` at an ownership boundary. +`Debug` output is redacted. Typed `AppConfig` fields are unchanged. Axum loads +[bounded immutable snapshots](/guide/adapters/axum#snapshot-allocation-limits) +before listener bind; push updated local config before startup or restart afterward. + ## Application config `edgezero.toml` describes the _shape_ of the app — routes, adapters, @@ -510,7 +546,15 @@ Logging can be configured per adapter under `[adapters..logging]` or via a | `level` | All | Log level: `trace`, `debug`, `info`, `warn`, `error`, `off` | | `echo_stdout` | Fastly, Axum | Mirror logs to stdout | -Note: Cloudflare logging is not wired to a built-in logger yet. +Manifest logging settings and runtime `EDGEZERO__LOGGING__*` overrides are separate; consult +the adapter guide for which source its runner consumes. Cloudflare and Spin do not install a +Rust `log` backend. `owns_logging = true` transfers installation **and filter configuration** +to the application, before entering the runner. The pure +`edgezero_core::resolve_logging_level(&EnvConfig)` helper resolves runtime levels without +implicitly applying manifest defaults or changing global state. + +Managed Fastly/Axum loggers retain `edgezero::boot` errors and warnings at `Warn` even when +ordinary runtime logging is `Off`. See [Logging Initialisation](/guide/adapters/overview#logging-initialisation). ## Full Example @@ -523,6 +567,12 @@ middleware = [ "my_app_core::middleware::Cors" ] +[capabilities] +optional = ["outbound-http"] + +[capabilities.outbound] +hosts = ["https://api.example.com"] + [[triggers.http]] id = "root" path = "/" @@ -640,7 +690,8 @@ The macro: - Generates route registration - Wires middleware from the manifest - Bakes portable store metadata (`Hooks::stores()`) from `[stores.kv]`, `[stores.config]`, and `[stores.secrets]` when present -- Creates the `App` struct that implements `Hooks` (use `App::build_app()`) +- Creates the application hook struct consumed by the fallible, core-owned + `edgezero_core::app::App::build::(platform)` constructor Arguments: diff --git a/docs/guide/handlers.md b/docs/guide/handlers.md index b2fc3dc75..bb7ca82f5 100644 --- a/docs/guide/handlers.md +++ b/docs/guide/handlers.md @@ -202,17 +202,17 @@ async fn inspect(ctx: RequestContext) -> Result, EdgeError> { `RequestContext` provides these methods: -| Method | Returns | -| ---------------- | ------------------------------------------ | -| `request()` | `&Request` - full HTTP request | -| `path_params()` | `&PathParams` - raw path parameters | -| `path::()` | Deserialize path params to `T` | -| `query::()` | Deserialize query string to `T` | -| `json::()` | Deserialize JSON body to `T` | -| `form::()` | Deserialize form body to `T` | -| `body()` | `&Body` - raw request body | -| `into_request()` | `Request` - consume context, take request | -| `proxy_handle()` | `Option` - adapter proxy hook | +| Method | Returns | +| ---------------- | --------------------------------------------------- | +| `request()` | `&Request` - full HTTP request | +| `path_params()` | `&PathParams` - raw path parameters | +| `path::()` | Deserialize path params to `T` | +| `query::()` | Deserialize query string to `T` | +| `json::()` | Deserialize JSON body to `T` | +| `form::()` | Deserialize form body to `T` | +| `body()` | `&Body` - raw request body | +| `into_request()` | `Request` - consume context, take request | +| `http_client()` | `Option` - adapter outbound HTTP client | ### Store Extractors @@ -326,14 +326,179 @@ app-state struct and register that. > **Make `app_state()` cheap.** The macro emits the `state` expression inside the > generated `build_router()`, which each adapter's `run_app` calls through -> `A::build_app()` — **once at startup** for long-lived runtimes (Axum), but -> **once per request** on the default Fastly, Cloudflare and Spin entry points. -> Fastly starts a fresh Wasm instance by default; Cloudflare and Spin also -> rebuild the app per request. +> `App::build::(platform)` — **once at startup** for Axum, but **once per +> invocation** for the default Fastly, Cloudflare, and Spin entry points. +> Opt-in retained paths build only until an app is successfully retained; host +> eviction can require fresh initialization on any request. A reused Fastly +> sandbox does not imply that the app is retained unless the entry point chooses it. > So the `state` expression runs on that cadence: build heavy state **once** and > hand out clones (e.g. a `OnceLock>` as above, or a `static`), and > let `T = Arc` so each call is just a refcount bump. Do **not** `Arc::new(HeavyThing::build())` directly in the `state` expression. +### Configuring the application lifecycle + +Macro-driven apps can customize the generated `App` through the existing +`Hooks::configure` seam without replacing manifest-driven routing: + +```rust +use edgezero_core::{ + AdmissionDecision, DetachedResponseEgressDecision, EdgeError, IngressGrant, + ResponseEgressCompletion, DEFAULT_RESPONSE_WRITE_BUDGET, +}; +use std::time::Duration; + +fn configure_app(app: &mut edgezero_core::app::App) -> Result<(), EdgeError> { + app.set_ingress_admission_policy(|head| AdmissionDecision::Admit { + completion: ResponseEgressCompletion::empty(), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(30)), + }); + app.set_detached_response_egress_decision_factory(|head| { + DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::empty(), + deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET), + } + }); + Ok(()) +} + +edgezero_core::app!("edgezero.toml", configure = crate::configure_app); +``` + +`configure = ` must evaluate to a callable that accepts `&mut App` and returns +`Result<(), EdgeError>`. It is +invoked after the manifest router is built and before the app begins serving, so +it can install ingress admission, request-head limits, config extraction limits, +the monotonic clock, an error-response renderer, and response-egress policy or observation hooks. Keep the +callback cheap for the same adapter-lifecycle reasons as `state = ` above. A configuration +error prevents EdgeZero request conversion, body polling, and dispatch; Axum also fails before +binding its listener. + +`App::build::(PLATFORM) -> Result` is the canonical assembly +path. It installs the selected platform before calling `Hooks::configure` and +rejects a successful configuration that replaces that metadata. Publish or retain +only the completed successful app, never an initialization error. Retaining the app +does not retain request admission grants, registries, bodies, or response-egress +resources; those are fresh for every dispatch. See +[application retention](/guide/adapters/overview#opt-in-application-retention). + +Use the synchronous error renderer when framework and handler errors must use an application-owned, +fixed-size wire body: + +```rust +use edgezero_core::{Body, EdgeError, Response}; + +fn configure_app(app: &mut edgezero_core::app::App) -> Result<(), EdgeError> { + app.set_error_response_renderer(|error| Response::new(Body::from(error.kind()))); + Ok(()) +} +``` + +The renderer handles admitted routing/handler errors, post-admission conversion errors, and +detached normalized-ingress errors. EdgeZero reapplies mandatory protocol metadata after the +callback, including the error's status, so the status returned by the callback is ignored; +canonical 405 responses therefore retain their sorted, deduplicated `Allow` field. +Effective `config_out_of_date` responses retain `Retry-After: 60`. +Explicit admission refusals and fallback `on_exceeded`/`on_timeout` responses are already complete +application responses and bypass this renderer. Avoid serializing `error.to_string()` when the +body must remain bounded or diagnostics must remain private. + +Every response-producing admission decision owns one explicit `ResponseEgressCompletion`. Use +`ResponseEgressCompletion::empty()` when there is no response-scoped resource. To hold a permit +acquired during admission through terminal egress, move it directly into +`ResponseEgressCompletion::new(move |report| ... )`. The completion is non-clone. +It never belongs in response extensions. It follows admitted, refused, fallback, handler-error, +and middleware-error responses into the adapter's exactly-once egress attempt. +`AdmissionDecision::Abort` is different: it returns no response and therefore owns no completion. + +A handler can place a response-specific write bound in the response extensions without teaching +the global policy callback about application response categories: + +```rust +use edgezero_core::ResponseEgressDeadline; +use std::time::Duration; + +response + .extensions_mut() + .insert(ResponseEgressDeadline::after(Duration::from_secs(2))); +``` + +`after(duration)` starts at the injected-clock instant when response egress begins. EdgeZero +clamps the duration to seven days and fails closed to that start instant on arithmetic overflow. +Use `ResponseEgressDeadline::at(deadline)` when the application already owns an absolute cutoff, +such as a queue-through-egress deadline. EdgeZero removes either extension in `begin()`, exposes +the resolved absolute value to the policy callback, and enforces the earlier application or policy +deadline. The retired `new()` and unresolved `deadline()` APIs are not available. + +Use `left.join(right)` when two independently owned completion actions must follow the same +response. The joined completion invokes the left callback and then the right callback with the +same borrowed terminal report, at most once. Abandoning it before terminal egress releases the +resources captured by both children. On unwind-capable targets, each child has an independent +panic boundary, so a left-callback panic does not suppress the right callback. On panic-abort +targets, termination may occur before the right callback runs. Joining callbacks does not +strengthen the adapter's documented provider-completion boundary. + +When the handler acquires the permit later, use EdgeZero's late-bound resource owner. Put the +non-clone installer in the typed `IngressGrant` and transfer its paired completion with the +admission decision. The handler consumes the grant and installs the permit; terminal completion +takes and drops it: + +```rust +use edgezero_core::{ + IngressGrant, ResponseEgressCompletion, ResponseEgressResource, + ResponseEgressResourceInstallError, +}; + +struct Permit; + +struct AdmissionLease { + response_resource: ResponseEgressResource, +} + +impl AdmissionLease { + fn install(&self, permit: Permit) -> Result<(), ResponseEgressResourceInstallError> { + self.response_resource.install(permit) + } +} + +fn response_lifecycle() -> (IngressGrant, ResponseEgressCompletion) { + let (response_resource, completion) = ResponseEgressCompletion::late_bound(); + ( + IngressGrant::new(AdmissionLease { response_resource }), + completion, + ) +} +``` + +`install` consumes the permit. A second install returns `AlreadyInstalled`; an install after +terminal completion or pre-egress abandonment returns `Closed`. Both failures release the rejected +permit before returning. `ResponseEgressResourceInstallError` implements `std::error::Error`, so a +handler returning `Result<_, EdgeError>` can use +`lease.install(permit).map_err(EdgeError::internal)?`. Do not keep the permit only in a +handler-local guard: that releases it when the handler returns, before response transmission. If +the response envelope is abandoned before egress begins, dropping the completion closes the +surviving installer and releases an installed permit without fabricating a terminal report. + +Normalized request-head rejections happen before admission; admission-policy errors can also fail +before an admission decision transfers its completion. Configure +`App::set_detached_response_egress_decision_factory` to return +`DetachedResponseEgressDecision::Send { completion, deadline }` when those responses need an +application-owned completion resource. Every `Send` deadline is mandatory and absolute in the +request's injected monotonic-clock domain. Construct relative deadlines with +`head.write_deadline_after(duration)`, which anchors to captured `request_start`, clamps to +`DEADLINE_FAR_FUTURE`, and fails closed to `request_start` on arithmetic overflow. The default +factory combines `ResponseEgressCompletion::empty()` with `DEFAULT_RESPONSE_WRITE_BUDGET` measured +from request start. The effective bound is the earlier of the factory deadline and the default +response-egress policy, so a later factory deadline cannot extend the default policy and the policy +cannot extend an earlier factory deadline. Admission-selected detached responses retain their +response-owned deadline path and are outside this factory contract. + +Return `DetachedResponseEgressDecision::Abort` when no bounded response resource is available. +`Abort` creates no response or egress attempt and invokes no completion callback, observer, +handler, middleware, or body poll. Raw parser failures that occur before EdgeZero receives a +request, and platform-to-core head-conversion failures that occur before a normalized head exists, +remain outside this hook. + ## Response Types ### Text Responses @@ -464,6 +629,9 @@ EdgeError::internal("Unexpected failure") // 500 EdgeError::internal(some_error) // 500 (from any error type) ``` +`method_not_allowed` sorts and deduplicates the structured method list. Its response includes the +required `Allow` header; this remains true when an application error renderer supplies the body. + ## Custom Extractors Implement the `FromRequest` trait to create custom extractors: diff --git a/docs/guide/middleware.md b/docs/guide/middleware.md index efefe3d3b..8ec84e2dd 100644 --- a/docs/guide/middleware.md +++ b/docs/guide/middleware.md @@ -231,4 +231,4 @@ If you already hold an `Arc` (`BoxMiddleware`), register it with ## Next Steps - Learn about [Streaming](/guide/streaming) for progressive responses -- Explore [Proxying](/guide/proxying) for upstream forwarding +- Explore [Outbound HTTP](/guide/proxying) for upstream requests diff --git a/docs/guide/proxying.md b/docs/guide/proxying.md index 3dd8cafc3..d8e1dd2c5 100644 --- a/docs/guide/proxying.md +++ b/docs/guide/proxying.md @@ -1,63 +1,184 @@ -# Proxying +# Outbound HTTP -EdgeZero provides helpers for forwarding requests to upstream services while staying -provider-agnostic. +EdgeZero exposes one provider-neutral HTTP client to handlers. Adapters inject an `HttpClient` +into request extensions, while application code builds `OutboundRequest` values and receives +typed `OutboundResponse` or `EdgeError` outcomes. -## End-to-End Example +## Forward An Inbound Request -This example forwards the incoming request upstream, adjusts headers on the way in and out, and -returns a friendly 502 on proxy errors. It uses the adapter-provided proxy handle inserted by each -adapter. +This example preserves the inbound method and body, replaces the target URI, removes a private +header, and converts the outbound response back into a core response. ```rust use edgezero_core::action; -use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; -use edgezero_core::http::{Response, StatusCode, Uri}; -use edgezero_core::proxy::ProxyRequest; +use edgezero_core::http::{HeaderValue, Response, Uri}; +use edgezero_core::outbound::{OutboundCachePolicy, OutboundRequest}; +use std::num::NonZeroU64; +use std::time::Duration; #[action] -async fn proxy_with_auth(RequestContext(ctx): RequestContext) -> Result { - let target: Uri = "https://api.example.com".parse().unwrap(); +async fn forward_with_auth( + RequestContext(ctx): RequestContext, +) -> Result { + let client = ctx + .http_client() + .ok_or_else(|| EdgeError::not_implemented("outbound HTTP client not available"))?; + let target: Uri = "https://api.example.com/v1/data" + .parse() + .map_err(|_| EdgeError::bad_request("invalid upstream URI"))?; - let handle = ctx - .proxy_handle() - .ok_or_else(|| EdgeError::internal("proxy client not configured"))?; - - let mut proxy_request = ProxyRequest::from_request(ctx.into_request(), target); - proxy_request.headers_mut().insert( + let mut request = OutboundRequest::from_request(ctx.into_request(), target)? + .cache_policy(OutboundCachePolicy::Bypass) + .timeout(Duration::from_secs(2)) + .max_request_body_bytes(256 * 1024) + .max_encoded_response_bytes(2 * 1024 * 1024) + .max_decoded_response_bytes(4 * 1024 * 1024) + .max_response_bytes(4 * 1024 * 1024) + .max_response_header_bytes(64 * 1024) + .max_response_header_count(100) + .max_chunk_bytes(NonZeroU64::new(64 * 1024).unwrap_or(NonZeroU64::MIN)); + request.headers_mut().insert( "authorization", - "Bearer secret-token".parse().unwrap(), + HeaderValue::from_static("Bearer secret-token"), ); - proxy_request.headers_mut().remove("cookie"); - - match handle.forward(proxy_request).await { - Ok(mut response) => { - response - .headers_mut() - .insert("x-proxy-by", "edgezero".parse().unwrap()); - Ok(response) - } - Err(err) => { - tracing::error!("proxy failed: {}", err); - let response = Response::builder() - .status(StatusCode::BAD_GATEWAY) - .body(Body::from("Bad Gateway")) - .map_err(EdgeError::internal)?; - Ok(response) + request.headers_mut().remove("cookie"); + + let mut response = client.send(request).await?; + response + .headers_mut() + .insert("x-forwarded-by", HeaderValue::from_static("edgezero")); + response.into_response() +} +``` + +Outbound failures remain typed. Deadline expiry maps to `GatewayTimeout { cause }`; transport, +protocol, and codec failures map to inspectable `BadGatewayReason` values; resource limits use +`ResponseLimitReason`. Applications do not need to classify errors by matching messages. + +## Construct A Request + +Use `OutboundRequest::get`, `post`, or `new` for a new request. `from_request` is intended for +forwarding an inbound request and performs the same target validation and hop-by-hop header +normalization immediately. + +```rust +use edgezero_core::outbound::{OutboundCachePolicy, OutboundRequest}; +use std::num::NonZeroU64; +use std::time::Duration; + +let request = OutboundRequest::post("https://api.example.com/events")? + .cache_policy(OutboundCachePolicy::Bypass) + .timeout(Duration::from_secs(2)) + .max_request_body_bytes(256 * 1024) + .max_encoded_response_bytes(2 * 1024 * 1024) + .max_decoded_response_bytes(4 * 1024 * 1024) + .max_response_bytes(4 * 1024 * 1024) + .max_response_header_bytes(64 * 1024) + .max_response_header_count(100) + .max_chunk_bytes(NonZeroU64::new(64 * 1024).unwrap_or(NonZeroU64::MIN)) + .max_brotli_window_bits(24) + .max_decoder_bytes(32 * 1024 * 1024) + .header("accept", "application/json")? + .json(&payload)?; +let response = client.send(request).await?; +let decoded: ApiResponse = response.json()?; +``` + +`host_authority_override("virtual.example:8443")` changes only the outgoing HTTP authority. The +request URI still selects the connection destination, TLS SNI, and certificate identity. Axum and +Fastly support that separation, Cloudflare is BestEffort pending a deployed wire probe, and Spin +rejects the override during preflight. + +Buffered response mode is the default. Call `stream_response()` for a streamed response and +consume `response.into_body()` while its absolute request deadline is still active. Use +`into_bytes_bounded`, `into_bytes_bounded_until`, or `json_bounded_until` when application code +performs an additional collection step. + +`max_chunk_bytes` opt-in rechunks guest-visible response items before they reach the application. +It bounds emitted item shape, not allocation: split `Bytes` values can retain their original +backing allocation, and a provider SDK may allocate the source chunk before EdgeZero receives it. + +## Batch Requests + +`start_batch_until` accepts buffered request bodies and buffered response mode only. It yields +terminal slots in observed completion order while retaining each original input index. This lets +an application keep completed results when its own observation cutoff wins: + +```rust +use std::time::Duration; + +use edgezero_core::{EdgeError, OutboundBatchNext, OutboundBatchTermination}; + +let observation_cutoff = ctx + .monotonic_clock() + .deadline_after(Duration::from_secs(2)); +let mut batch = client.start_batch_until(requests, observation_cutoff); +let termination = loop { + match batch.next().await? { + OutboundBatchNext::Item(item) => match item.result.outcome { + Ok(response) => record_success(item.index, item.result.elapsed, response.status()), + Err(error) => record_failure(item.index, item.result.elapsed, error), + }, + OutboundBatchNext::Finished(termination) => break termination, + _ => return Err(EdgeError::internal(anyhow::anyhow!("unsupported batch event"))), + } +}; +assert!(matches!( + termination, + OutboundBatchTermination::Completed | OutboundBatchTermination::Cutoff +)); +``` + +Anchor relative observation cutoffs through the request clock as shown. `Deadline::after` uses the +process default clock and is intended only for code that also uses the default clock. The +observation cutoff stops batch observation regardless of any remaining per-request budget or later +per-request deadline. Passing an earlier observation cutoff intentionally leaves unresolved slots. + +After the last item, continue calling `next()` to await clean driver EOF and receive +`Finished(Completed)`. Item count alone is not completion: a trailing driver failure, duplicate +index, or out-of-range index still fails collection while retaining the previously collected +slots. Custom drivers must finish after their last item; zero-slot drivers are validated too. + +`client.send_all_until(requests, observation_cutoff).await` collects the same driver into an +index-aligned `Vec>` plus `results.termination`. `Completed` guarantees +every slot is present. Only `Cutoff` permits `None` in a successful result, meaning that slot was +unresolved when the observation cutoff won. A premature, malformed, or explicitly failed adapter +driver returns `OutboundBatchFailure` instead. Its `error` is the precise batch-level failure, and +its `slots` retains every terminal result collected before that failure: + +```rust +let results = match client + .send_all_until(requests, observation_cutoff) + .await +{ + Ok(results) => results, + Err(failure) => { + for (index, slot) in failure.slots.into_iter().enumerate() { + if let Some(slot) = slot { + retain_terminal_slot(index, slot); + } } + return Err(failure.error); } -} +}; ``` -## Notes +Driver failure never degrades into cutoff-like missing slots. Every terminal slot is timed from the +batch method-entry snapshot through its own terminal observation, including preflight and body +buffering. It is not pure network RTT. Bound the request count and every per-request body limit; +EdgeZero intentionally has no global batch-concurrency or memory cap. -- Fastly and Cloudflare stream outbound request bodies; Axum buffers them before sending, and Spin buffers them too, capping streamed-body collection at 16 MiB (larger streams error) while an already-buffered `Body::Once` bypasses that cap. -- Fastly, Cloudflare, and Spin automatically decode `gzip`/`br` responses for you. -- If you need a direct client (for tests or custom wiring), use the adapter clients - (`FastlyProxyClient`, `CloudflareProxyClient`, `SpinProxyClient`, `AxumProxyClient::try_new()?`). +On Axum, Cloudflare, and Spin, each child samples its terminal instant synchronously when it is +observed ready. Those samples follow poll-observation order in the shared monotonic clock domain, +so after one observation reaches the observation cutoff a later observation cannot be on time +unless an injected clock moves backwards. Fastly's provider-selected completion path remains +BestEffort as documented in the capability matrix. -## Next Steps +## Platform Behavior -- Learn about [Fastly](/guide/adapters/fastly), [Cloudflare](/guide/adapters/cloudflare), and [Spin](/guide/adapters/spin) adapter specifics +The API is portable, but not every runtime can provide identical timing, header, or lazy-stream +semantics. See [Capabilities](/guide/capabilities) for the exact support matrix, Fastly's dynamic +backend prerequisite, each adapter's lazy downstream handoff boundary, Cloudflare's manual +encoding boundaries, and complete resource-accounting exclusions. diff --git a/docs/guide/roadmap.md b/docs/guide/roadmap.md index 683198624..bd219f313 100644 --- a/docs/guide/roadmap.md +++ b/docs/guide/roadmap.md @@ -11,8 +11,6 @@ shift as the roadmap evolves. `edgezero.toml`, respect `RUST_LOG` for dev output, and bake in hot reload for `edgezero serve --adapter axum` (the local dev path; the standalone `dev` subcommand was reserved for a future dev-workflow command, see [CLI reference](./cli-reference#edgezero-demo)). -- Adapter behavior matrix: document which adapters buffer bodies, which preserve streaming, and - where proxy headers/automatic decompression apply so expectations match runtime behavior. - Example coverage: add focused guides for `axum.toml`, manifest `description` fields, logging precedence, and introspection routes + body-mode behavior to reduce ambiguity. - Provider additions: prototype a third adapter (e.g. AWS Lambda@Edge or Vercel Edge Functions) @@ -25,12 +23,12 @@ shift as the roadmap evolves. - Manifest ergonomics: established the `edgezero.toml` schema and CLI scaffolding for route triggers, env/secrets, and build targets. - Documentation baseline: published a single-source-of-truth docs set aligned with current APIs - (App::build_app entrypoints, adapter dispatch signatures, middleware signature, proxy handle - usage). -- Platform focus: Fastly Compute@Edge, Cloudflare Workers, and Fermyon Spin are the edge targets, - with Axum serving local development and native deployment needs. -- Spin support: first-class `edgezero-adapter-spin` (`wasm32-wasip2`) with its own adapter guide, - contract tests, and CLI scaffold target. + (`App::build::` entrypoints, adapter dispatch signatures, middleware signature, and outbound + HTTP capabilities). +- Outbound contract: shipped the portable client, typed limits/errors, per-slot timing, exact + capability matrix, and adapter-specific runtime implementations. +- Platform coverage: Fastly Compute, Cloudflare Workers, Fermyon Spin, and native Axum have + first-class adapters with executable contracts. - Core contracts: request/response mapping rules are now captured in the adapter contract docs. ## Open Design Questions (for later pickup) diff --git a/docs/guide/routing.md b/docs/guide/routing.md index 9c58c81da..556b34c51 100644 --- a/docs/guide/routing.md +++ b/docs/guide/routing.md @@ -15,11 +15,16 @@ handler = "my_app_core::handlers::hello" [[triggers.http]] id = "echo" +class = "interactive" path = "/echo/{name}" methods = ["GET", "POST"] handler = "my_app_core::handlers::echo" ``` +An optional `class` is opaque application metadata for ingress admission. It is available through +`RouteMetadata::class()` for matched and method-not-allowed resolutions and does not change the +route's stable identity. + You can also build routes programmatically using convenience methods: ```rust @@ -40,7 +45,7 @@ use edgezero_core::http::Method; let router = RouterService::builder() .route("/hello", Method::GET, hello_handler) - .route("/echo/{name}", Method::GET, echo_handler) + .route_with_class("/echo/{name}", Method::GET, "interactive", echo_handler) .route("/echo", Method::POST, echo_json_handler) .build(); ``` @@ -111,7 +116,9 @@ RouterService::builder() .build() ``` -EdgeZero automatically returns `405 Method Not Allowed` for requests that match a path but use an unsupported method. +EdgeZero automatically returns `405 Method Not Allowed` for requests that match a path but use an +unsupported method. The response includes an `Allow` header containing the sorted, deduplicated +method set registered for that path. ## Introspection Routes @@ -175,11 +182,12 @@ Axum-style `:name` parameters are **not supported**. Use `{name}` instead. Routes are matched by specificity (static segments first, then parameters, then catch-alls). Registering two routes that conflict for the same method (for example, two routes that both look -like `/users/{id}`) panics while the routes are registered (inside `build_app()`) with +like `/users/{id}`) panics while the routes are registered (inside `App::build::(platform)`) with `duplicate route definition for `. When that happens depends on the adapter: Axum builds the router once at startup, so the conflict -surfaces before any traffic, but the Fastly, Cloudflare, and Spin entry points call `build_app()` -per request, so a conflicting route deploys successfully and then panics on the first request. +surfaces before any traffic, but the Fastly, Cloudflare, and Spin entry points call +`App::build::(platform)` per request, so a conflicting route deploys successfully and then panics on +the first request. Run the app under the Axum dev server before deploying to an edge target. ## Next Steps diff --git a/docs/guide/streaming.md b/docs/guide/streaming.md index c7ba0eaab..651c27f23 100644 --- a/docs/guide/streaming.md +++ b/docs/guide/streaming.md @@ -1,6 +1,8 @@ # Streaming -EdgeZero supports streaming responses for large payloads, real-time data, and server-sent events. +EdgeZero preserves lazy streaming response bodies on every adapter. Each adapter owns body +delivery through its strongest platform boundary; no standard entrypoint collects the complete +downstream response before handoff. ## Streaming Responses @@ -33,30 +35,30 @@ async fn stream_data() -> Response { ## How Streaming Works -The core router passes `Body::Stream` through untouched; whether the client sees -chunks progressively depends on the adapter: +The router keeps `Body::Stream` intact until the adapter response coordinator: 1. Your handler returns `Body::stream(...)` with a `Stream` of chunks -2. Cloudflare wraps the stream in a `ReadableStream` (`Response::from_stream`), so - chunks reach the client as they are produced -3. Spin and Axum collect the stream into a buffer, and Fastly's standard - `run_app`/`serve_app` dispatch writes each chunk - into a host-side body that is only sent once complete (Spin rejects streamed - bodies over 16 MiB), so on all three the client receives the whole body at - once - -On Cloudflare, streaming also keeps memory flat, because each chunk is forwarded as -it is produced. On the buffering adapters a streamed body is collected in full -before the response goes out: Axum into an unbounded buffer, Spin into a buffer -capped at 16 MiB (larger bodies fail), and Fastly into a host-side body that is -only sent once complete. There `Body::stream` is an API-composability convenience, -not a memory saving, and a very large streamed response can exhaust memory or be -rejected. For progressive delivery (SSE or long-lived chunked responses), use Cloudflare's -standard dispatcher or Fastly's custom [`lifecycle::serve_custom` callbacks](/guide/adapters/fastly#custom-dispatch-and-streaming). -The Fastly callback must explicitly commit, pump and flush chunks, and finish the -writer. Convert with `request::into_core_request_with_registries` (the bare -`into_core_request` inserts no store registries) and drive `app.router().oneshot(..)` -yourself, so the core response stays available for streaming. +2. Core validates response framing and wraps declared-length enforcement around the source +3. The adapter polls one chunk at a time under its host's demand or write readiness +4. Accepted bytes, deadline/abort events, and one terminal outcome are recorded by that adapter + +Axum uses a connection-local Hyper body, Cloudflare a JavaScript stream writer, Fastly +`stream_to_client`, and Spin a WASI body writer. See [Capabilities](/guide/capabilities) for the +exact acceptance and completion boundary on each target. + +Application retention does not change this ownership. Every dispatch creates +fresh admission and response-egress state, even when the same successfully built +`App` is reused. Keep body sources, native writers, completion resources, and +pending work request-local rather than storing them in the app cache. + +Fastly's `serve_app` and `serve_app_with_hooks` retain an app through +`lifecycle::Sandbox` while the adapter owns sending and streaming. The hooks +borrow request/response values; they do not return a native response for the SDK +to send again. Cloudflare's `dispatch_app::(&app, req, env, ctx)` returns a +Worker response backed by its request-local coordinator. Spin's +`dispatch_app::(&app, req)` returns a raw `SpinResponse` whose body and +transmission lifetime remain owned by the adapter coordinator. None of these +paths collect the complete response before handoff. ## Server-Sent Events @@ -86,6 +88,12 @@ async fn events() -> Response { } ``` +::: warning Completion semantics +All adapters preserve progressive production, but their response-egress capability cells remain +BestEffort because host acceptance or close does not prove end-client receipt. Fastly source polls +and hostcalls are synchronous and cannot be preempted. +::: + ## Body Modes Routes can specify their body handling mode in the manifest. This is parsed today and reserved @@ -104,43 +112,43 @@ body-mode = "buffered" # or "stream" | `buffered` | Body is fully read into memory before handler runs | | `stream` | Body is passed as a stream for progressive processing | -## Transparent Decompression +## Outbound Response Decompression -On Fastly, Cloudflare, and Spin, EdgeZero automatically decompresses gzip and brotli -responses from upstream services (the Axum proxy client has no decode path): +The outbound client decodes a single bare `gzip`, `deflate`, or `br` content coding through shared +`edgezero-core` decoders. Unknown, parameterized, or stacked codings pass through unchanged. +Encoded transport bytes, decoded output, and final buffered bytes have independent limits; see +[Capabilities](/guide/capabilities#limits-and-accounting). ```rust -// Proxied response with Content-Encoding: gzip is automatically decoded -let response = proxy.forward(request).await?; -// response.body is now decompressed +let request = OutboundRequest::get("https://api.example.com/data")? + .max_encoded_response_bytes(2 * 1024 * 1024) + .max_decoded_response_bytes(8 * 1024 * 1024) + .stream_response(); +let body = client.send(request).await?.into_body(); ``` -This happens transparently in the adapter layer using shared decoders from `edgezero-core`. +Multi-member gzip streams are decoded through every member and drained to transport EOF. +For Brotli, the stream window is checked before decoder allocation and decoder state is charged +against the configured policy limit. ## Memory Considerations -On paths that forward chunks as they are produced (Cloudflare's standard -dispatcher and Fastly `lifecycle::serve_custom` callbacks; see -[How Streaming Works](#how-streaming-works)), streaming is what makes these -workloads fit: +Lazy streaming is useful for: - Large file downloads - Video/audio content - Real-time data feeds - Responses larger than available memory -::: warning Platform Limits -Edge platforms have memory constraints. A Fastly Compute instance has ~128MB by default. On Cloudflare, stream large responses rather than buffering. On Fastly's standard `run_app`/`serve_app` paths, Spin, and Axum a streamed body is still collected in full before it is sent, so keep responses within the instance's memory regardless of how you build the body. Spin additionally caps streamed-body collection at 16 MiB; an already-buffered `Body::Once` bypasses that cap. Fastly's [custom serving callbacks](/guide/adapters/fastly#custom-dispatch-and-streaming) can instead stream progressively with explicit sending. +::: warning Platform limits +Edge platforms have finite memory. Lazy response delivery removes whole-body collection but does +not bound provider queues, SDK allocations, source chunk size, or transport buffers. Bound source +chunks and application work, and use the capability matrix when certifying a deployment. ::: ## Chunked Transfer -When the response size is unknown, `Body::stream` lets the adapter send without a -`Content-Length`. Cloudflare delivers this as chunked transfer; the buffering -adapters compute the length once the body is collected. Fastly custom serving -callbacks may also commit a native streaming response; see -[Custom dispatch and streaming](/guide/adapters/fastly#custom-dispatch-and-streaming). -For example: +Applications may omit `Content-Length` when the response size is unknown: ```rust #[action] @@ -156,7 +164,11 @@ async fn dynamic_content() -> Response { } ``` +The provider owns the final wire framing. EdgeZero does not guarantee a particular HTTP/1 transfer +coding; it normalizes framing and enforces the application's declared payload length before the +adapter writes body bytes. + ## Next Steps -- Learn about [Proxying](/guide/proxying) for forwarding requests upstream -- Explore adapter-specific streaming in the [Fastly](/guide/adapters/fastly), [Cloudflare](/guide/adapters/cloudflare), and [Spin](/guide/adapters/spin) guides +- Learn about [Outbound HTTP](/guide/proxying) for upstream requests +- Explore adapter-specific streaming in the [Fastly](/guide/adapters/fastly), [Cloudflare](/guide/adapters/cloudflare), [Spin](/guide/adapters/spin), and [Axum](/guide/adapters/axum) guides diff --git a/docs/guide/what-is-edgezero.md b/docs/guide/what-is-edgezero.md index 23c4bbda9..9b16aa9eb 100644 --- a/docs/guide/what-is-edgezero.md +++ b/docs/guide/what-is-edgezero.md @@ -1,16 +1,16 @@ # What is EdgeZero? -EdgeZero is a production-ready toolkit for writing an HTTP workload once and deploying it across multiple edge providers. The core stays runtime-agnostic so it compiles cleanly to WebAssembly targets (Fastly Compute@Edge, Cloudflare Workers) and to native hosts (Axum/Tokio) without code changes. +EdgeZero is a production-ready toolkit for writing an HTTP workload once and deploying it across multiple edge providers. The core stays runtime-agnostic so it compiles cleanly to WebAssembly targets (Fastly Compute, Cloudflare Workers, Fermyon Spin) and to native hosts (Axum/Tokio) without code changes. ## Key Features EdgeZero provides developers with: - **Portable HTTP workloads** - Write your business logic once using the shared `edgezero-core` primitives, then compile to any supported target -- **Multiple deployment targets** - Deploy to Fastly Compute@Edge, Cloudflare Workers, or native Axum servers from the same codebase +- **Multiple deployment targets** - Deploy to Fastly Compute, Cloudflare Workers, Fermyon Spin, or native Axum servers from the same codebase - **Type-safe extractors** - Use ergonomic extractors like `Json`, `Path`, and `ValidatedQuery` for clean handler code - **Streaming support** - Stream responses progressively with `Body::stream` for long-lived or chunked responses -- **Proxy helpers** - Forward traffic upstream with built-in `ProxyRequest` and `ProxyService` abstractions +- **Outbound HTTP** - Send typed, bounded requests through one portable client with deadlines and partial-failure batching - **CLI tooling** - Scaffold projects, run dev servers, and deploy with the `edgezero` CLI ## How It Works diff --git a/docs/superpowers/plans/2026-06-01-spin-kv-backed-config.md b/docs/superpowers/plans/2026-06-01-spin-kv-backed-config.md index 25d6f31e2..a719d0f70 100644 --- a/docs/superpowers/plans/2026-06-01-spin-kv-backed-config.md +++ b/docs/superpowers/plans/2026-06-01-spin-kv-backed-config.md @@ -1,5 +1,10 @@ # Plan: Move Spin Config Store onto KV +**Implementation update (2026-10-02):** The eager config-store open described +below was superseded. Declared labels are now bound without host calls during +dispatch; `SpinConfigStore` opens inside the bounded extraction read, after +admission. The config design specifies the current behavior. + **Status:** v12 — REVISION after tenth reviewer pass. Ready for execution. **Reviewer green-lighted start.** diff --git a/docs/superpowers/plans/2026-06-17-blob-app-config.md b/docs/superpowers/plans/2026-06-17-blob-app-config.md index 00c806f82..3f8971d73 100644 --- a/docs/superpowers/plans/2026-06-17-blob-app-config.md +++ b/docs/superpowers/plans/2026-06-17-blob-app-config.md @@ -10,6 +10,25 @@ ## Global Constraints +PR #275 follow-up supersedes this plan's older owned-string store signatures and +per-request Axum file-reading assumptions. The current normative contract is spec +§6.3 and §9.1 plus the Axum guide's snapshot-allocation limits. + +- [x] Hard-cut raw config reads to shared `ConfigValue` across all adapters and fixtures. +- [x] Validate finite Axum startup/snapshot budgets and load before listener binding. +- [x] Bound actual reads, flat decoder staging, entry/key/value allocations and all-store residency. +- [x] Preserve failed declared bindings, reject duplicate keys and invalid registry metadata. +- [x] Stream canonical hashing and drop the extraction-owned raw handle before secrets. +- [x] Align demo, generator, public guides and CHANGELOG with snapshot/restart semantics. +- [x] Stream Cloudflare bounded KV reads through capped BYOB buffers; cancel acquired readers on termination. +- [x] Use caller-sized Fastly ABI buffers without growth/retry, preflight aggregate chunk limits and avoid early envelope DOM allocation. +- [ ] Establish a bounded Spin host/backend read interface or verified hosted allocation ceiling; stock list-valued reads are not sufficient. +- [ ] Qualify hosted provider allocation/cancellation evidence; do not promote capability cells without it. + +These follow-ups supersede older tasks below that describe Cloudflare text materialization, +Fastly SDK auto-growing reads or inner-envelope DOM verification in the bounded resolver. +See spec §9.2/§9.3 and the public capability guide for the current allocation charges. + - **Source of truth:** [`docs/superpowers/specs/2026-06-16-blob-app-config.md`](../specs/2026-06-16-blob-app-config.md). Every task numbered below cites the spec section it implements. - **Hard cutoff:** no `#[serde(default)]` compat fallback for missing legacy state, no runtime fallback to the old per-leaf app-config shape, no platform-side bridge for legacy state. Fastly's direct-or-pointer storage is a new v1 physical representation for the same blob envelope, not legacy compatibility. Per spec §1 / §10.1. - **Atomic cutover:** spec §10.1 + §10.2 require runtime extractor + per-adapter writers + app-demo migration + scaffold templates + CI gate to land in ONE commit (Phase C below). Intermediate trees with new runtime / old writer do NOT compile cleanly because the read-trait-driven inline diff and the gate cross-check would fail. **Bisect-safe annotations** appear in each task heading. @@ -30,7 +49,7 @@ | Phase | Spec § | Commit type | Acceptance | | ----- | ----------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | A | §4 | Pre-cutover infra | Canonical form + envelope + non-finite-float check ship in `edgezero-core`. No in-tree caller exercises them yet. Bisect-safe. | -| B | §3, §5, §6.3, §9.0 | Pre-cutover infra | `ConfigStoreBinding`, `EnvConfig::store_key`, manifest charset, `EdgeError::ConfigOutOfDate`, derive-macro extensions, read trait + per-adapter impls, and Fastly chunk-pointer helpers. None called yet. Bisect-safe. | +| B | §3, §5, §6.3, §9.0 | Pre-cutover infra | `ConfigStoreBinding`, `EnvConfig::store_key`, manifest charset, typed `StoreExtractionReason`, bounded read traits/policies, derive-macro extensions, per-adapter read impls, and Fastly chunk-pointer helpers. None called yet. Bisect-safe. | | C | §3.3, §8.2, §10.2, §10.2.1, §10.2.2 | **CUTOVER (not splittable)** | `AppConfig` extractor + `config push` rewrite + app-demo migration + scaffold templates + all three CI gates land together. Spec §10.1 forbids splitting. | | D | §8.1 | Post-cutover additive | `config diff` command, format renderers, `--exit-code`. Depends on Phase C's read flow + writers. | | E | §10 narrative | Post-cutover docs | Migration guide, smoke scripts, README updates. | @@ -998,7 +1017,7 @@ default = "feature__flags" Run: `cargo test -p edgezero-core --lib manifest` Expected: 3 new tests pass. -## Task B2 — `EdgeError::ConfigOutOfDate` variant + two constructors +## Task B2 — `EdgeError::ConfigOutOfDate` variant + explicit-pair constructor **Files:** @@ -1010,7 +1029,6 @@ Expected: 3 new tests pass. - Produces: - `EdgeError::ConfigOutOfDate { message: String, field_path: String }` - `EdgeError::config_out_of_date(message: impl Into, field_path: impl Into) -> Self` - - `EdgeError::config_out_of_date_from_serde(err: serde_path_to_error::Error) -> Self` - [ ] **Step 1: Add the variant.** @@ -1045,18 +1063,13 @@ Edit `crates/edgezero-core/src/error.rs` near the existing enum. Insert: } } - /// Construct from a `serde_path_to_error` error returned by - /// the deserialise wrapper around the blob's `data` field. - pub fn config_out_of_date_from_serde( - serde_err: serde_path_to_error::Error, - ) -> Self { - Self::ConfigOutOfDate { - message: serde_err.inner().to_string(), - field_path: serde_err.path().to_string(), - } - } ``` +Typed store deserialization is added by Task B2.5 through +`store_deserialization_from_serde`, which carries the inspectable +`StoreExtractionReason::Deserialization`. The earlier compatibility constructor is +intentionally absent so consumers must migrate to the reason-bearing API. + - [ ] **Step 3: Add `serde_path_to_error` to `crates/edgezero-core/Cargo.toml`.** In `[dependencies]`: @@ -1118,186 +1131,123 @@ In the existing `mod tests` block: Run: `cargo test -p edgezero-core --lib error` Expected: pass; no existing test broken (the three exhaustive matches above — `inner()`, `message()`, `status()` — and the `thiserror` `#[error(...)]` attribute on the new variant cover every dispatch surface). -## Task B3 — `EdgeError` response body adds `kind` field + `Retry-After` on `ConfigOutOfDate` +## Task B2.5 — Typed store-extraction reasons (must precede extractor code) **Files:** -- Modify: `crates/edgezero-core/src/error.rs` — extend `IntoResponse` impl. -- Modify: `crates/edgezero-core/src/error.rs` — add per-variant `kind` constants. +- Modify: `crates/edgezero-core/src/error.rs`. +- Modify: every exhaustive `EdgeError` match identified by `rg -n 'EdgeError::' crates`. **Interfaces:** -- Produces: response body shape `{ "error": { "status": , "kind": "", "message": "<…>", "field_path"?: "<…>" } }`. `field_path` ONLY on `ConfigOutOfDate`. `Retry-After: 60` header ONLY on `ConfigOutOfDate`. Per spec §6.3.1. +- Produces: the exact `#[non_exhaustive] StoreExtractionReason` enum and + `EdgeError::StoreExtraction { reason, message, field_path }` contract from spec §6.3. +- Produces: `EdgeError::store_extraction`, + `EdgeError::store_deserialization_from_serde`, and + `EdgeError::store_extraction_reason`. -- [ ] **Step 1: Add a `kind_str` private method per variant.** +- [ ] **Step 1: Write the red error-classification matrix.** Enumerate every reason from + spec §6.3. For each, assert the exact status, kind, `Retry-After`, optional field path, + and absence of a JSON `reason` member. Add constructor/accessor tests and one + `#[non_exhaustive]` external-match compile fixture. -```rust -impl EdgeError { - fn kind_str(&self) -> &'static str { - match self { - EdgeError::BadRequest { .. } => "bad_request", - EdgeError::Internal { .. } => "internal", - EdgeError::MethodNotAllowed { .. } => "method_not_allowed", - EdgeError::NotFound { .. } => "not_found", - EdgeError::NotImplemented { .. } => "not_implemented", - EdgeError::ServiceUnavailable { .. } => "service_unavailable", - EdgeError::Validation { .. } => "validation", - EdgeError::ConfigOutOfDate { .. } => "config_out_of_date", - } - } -} -``` +- [ ] **Step 2: Run the focused tests and record the expected compile-red.** -- [ ] **Step 2: Rewrite the `IntoResponse` impl's body-building block.** +Run: `cargo test -p edgezero-core --lib error::tests::store_extraction` +Expected: compilation fails because `StoreExtractionReason` and the new variant do not +exist. A malformed test is not an acceptable red. -Locate the existing `IntoResponse` impl (around `error.rs:159` per spec). The current block writes `{ "error": { "status": ..., "message": ... } }`. Replace with code that: +- [ ] **Step 3: Add the enum, variant, constructors, accessor, and centralized mapping.** + Keep `ConfigOutOfDate` for non-store callers. `StoreExtraction` delegates status/kind and + response-header policy to its reason. Migrate typed extraction to + `store_deserialization_from_serde`; do not retain a compatibility constructor for typed + store deserialization. Do not leave a typed store path on `config_out_of_date`, + serialize the reason, or include an empty `field_path`. -1. Picks the right status code per variant. -2. Builds a `serde_json::Value::Object` map with `status`, `kind`, `message`, and (for `ConfigOutOfDate`) `field_path`. -3. Sets `Retry-After: 60` ONLY for `ConfigOutOfDate`. +- [ ] **Step 4: Update every exhaustive match.** This includes `StoredError` in the inbound + body design once that work lands; its captured form stores reason/message/path and + reconstructs the same variant. Do not add a wildcard to silence compiler coverage. -Pseudocode shape (adapt to the exact existing `IntoResponse` form): +- [ ] **Step 5: Run the focused tests, then all core tests.** -```rust -impl IntoResponse for EdgeError { - fn into_response(self) -> Response { - let kind = self.kind_str(); - let (status, message, field_path_opt): (StatusCode, String, Option) = match &self { - EdgeError::BadRequest { message } => (StatusCode::BAD_REQUEST, message.clone(), None), - EdgeError::Internal { source } => (StatusCode::INTERNAL_SERVER_ERROR, source.to_string(), None), - EdgeError::MethodNotAllowed { message } => (StatusCode::METHOD_NOT_ALLOWED, message.clone(), None), - EdgeError::NotFound { message } => (StatusCode::NOT_FOUND, message.clone(), None), - EdgeError::NotImplemented { message } => (StatusCode::NOT_IMPLEMENTED, message.clone(), None), - EdgeError::ServiceUnavailable { message } => (StatusCode::SERVICE_UNAVAILABLE, message.clone(), None), - EdgeError::Validation { message } => (StatusCode::UNPROCESSABLE_ENTITY, message.clone(), None), - EdgeError::ConfigOutOfDate { message, field_path } => ( - StatusCode::SERVICE_UNAVAILABLE, - message.clone(), - Some(field_path.clone()), - ), - }; - let mut error_obj = serde_json::Map::new(); - error_obj.insert("status".into(), serde_json::Value::from(status.as_u16())); - error_obj.insert("kind".into(), serde_json::Value::from(kind)); - error_obj.insert("message".into(), serde_json::Value::from(message)); - if let Some(fp) = field_path_opt { - error_obj.insert("field_path".into(), serde_json::Value::from(fp)); - } - let body = serde_json::json!({ "error": serde_json::Value::Object(error_obj) }); - let body_bytes = serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()); - // Round-39 L-2: use the EdgeZero `crate::http` re-export, NOT - // direct `http::header::*` paths. Project convention at - // crates/edgezero-core/src/error.rs:8 imports through - // `crate::http::{header::CONTENT_TYPE, ...}`; new code follows - // the same pattern so application code never depends on the - // raw `http` crate. Implementation extends the existing `use` - // line to also bring in `RETRY_AFTER`. - let mut response = Response::builder() - .status(status) - .header(CONTENT_TYPE, "application/json") - .body(Body::from(body_bytes)) - .unwrap(); - if matches!(self, EdgeError::ConfigOutOfDate { .. }) { - response - .headers_mut() - .insert(RETRY_AFTER, HeaderValue::from_static("60")); - } - response - } -} +```bash +cargo test -p edgezero-core --lib error::tests::store_extraction +cargo test -p edgezero-core ``` -Use the actual `Response` / `Body` types as imported elsewhere in `error.rs`. +Expected: all pass. -- [ ] **Step 3: Run existing error tests to confirm nothing regressed (existing tests assert the old shape will fail — that's expected; we update them next).** - -Run: `cargo test -p edgezero-core --lib error` -Note which tests fail (they're asserting the old `{ status, message }` shape). Phase B Task B4 below adds the new tests. - -## Task B4 — Per-variant `kind` + `Retry-After` tests +## Task B3 — `EdgeError` response body adds `kind` and centralized header policy **Files:** -- Modify: `crates/edgezero-core/src/error.rs` (update existing IntoResponse tests + add new ones per spec §12.6.1). +- Modify: `crates/edgezero-core/src/error.rs` — extend `IntoResponse` impl. +- Modify: `crates/edgezero-core/src/error.rs` — add per-variant `kind` constants. **Interfaces:** -- Consumes: `EdgeError::kind_str` + `IntoResponse` impl from Task B3. +- Produces: response body shape `{ "error": { "status": , "kind": "", "message": "<…>", "field_path"?: "<…>" } }`. `field_path` is emitted only for field-anchored variants. `Retry-After: 60` is emitted only for the effective `config_out_of_date` kind, including the mapped store-extraction reasons. Per spec §6.3.1. -- [ ] **Step 1: Update existing tests that assert the old body shape.** Find tests asserting `body.error.status` + `body.error.message` only; extend them to also assert `body.error.kind`. Use the table from spec §12.6.1. +- [ ] **Step 1: Extend the existing `kind_str` match without replacing it.** Keep one + explicit arm for every `EdgeError` variant present after the outbound Phase 1a work and + Task B2.5. `StoreExtraction { reason, .. }` delegates to a total private + `StoreExtractionReason::wire_policy()` table. Do not add a wildcard: later additions such + as inbound `RequestTimeout` must produce a compiler error until their owner adds a kind. -- [ ] **Step 2: Add a new test per variant asserting the `kind` string.** +- [ ] **Step 2: Rewrite the `IntoResponse` impl's body-building block.** -```rust - #[test] - fn kind_strings_per_variant() { - let cases: &[(EdgeError, &str, u16)] = &[ - (EdgeError::BadRequest { message: "x".into() }, "bad_request", 400), - (EdgeError::Internal { source: anyhow::anyhow!("x").into() }, "internal", 500), - (EdgeError::MethodNotAllowed { message: "x".into() }, "method_not_allowed", 405), - (EdgeError::NotFound { message: "x".into() }, "not_found", 404), - (EdgeError::NotImplemented { message: "x".into() }, "not_implemented", 501), - (EdgeError::ServiceUnavailable { message: "x".into() }, "service_unavailable", 503), - (EdgeError::Validation { message: "x".into() }, "validation", 422), - (EdgeError::config_out_of_date("x", "f"), "config_out_of_date", 503), - ]; - for (err, expected_kind, expected_status) in cases { - // Clone the error if it doesn't impl Clone — work around with match. - let response = err.clone().into_response(); // adjust if not Clone - assert_eq!(response.status().as_u16(), *expected_status); - let body_bytes = ...; // collect body - let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); - assert_eq!(body["error"]["kind"], serde_json::Value::from(*expected_kind)); - } - } -``` +Locate the existing `IntoResponse` impl (around `error.rs:159` per spec). The current block writes `{ "error": { "status": ..., "message": ... } }`. Replace with code that: -(If `EdgeError` is not `Clone`, restructure to build each case in turn rather than collecting in a slice.) +1. Picks the right status code per variant. +2. Builds a `serde_json::Value::Object` map with `status`, `kind`, `message`, and a + non-empty `field_path` from either `ConfigOutOfDate` or `StoreExtraction`. +3. Sets `Retry-After: 60` only when centralized policy marks the effective kind + `config_out_of_date`; do not use a variant-only `matches!` expression. + +Preserve the actual `IntoResponse` signature, +`fn into_response(self) -> Result`, and the existing +`response_with_body`/JSON fallback path. Compute status, kind, message, optional non-empty +field path, and retry policy before consuming `self`. `ConfigOutOfDate` keeps its current +wire behavior. `StoreExtraction` derives all four decisions from its reason table, but the +reason itself is never serialized. Use `crate::http::header::{CONTENT_TYPE, RETRY_AFTER}`; +do not introduce a direct `http` dependency or invent message fields on structured variants. -- [ ] **Step 3: Add `Retry-After` presence assertion.** +- [ ] **Step 3: Run existing error tests to confirm nothing regressed (existing tests assert the old shape will fail — that's expected; we update them next).** -```rust - #[test] - fn retry_after_only_on_config_out_of_date() { - for (err, expected_retry_after) in [ - (EdgeError::BadRequest { message: "x".into() }, false), - (EdgeError::Internal { source: anyhow::anyhow!("x").into() }, false), - (EdgeError::ServiceUnavailable { message: "x".into() }, false), // round-10 H-3 narrowing - (EdgeError::config_out_of_date("x", "f"), true), - ] { - let response = err.into_response(); - // Round-39 L-2: same `crate::http` re-export rule as the - // production code above — test bodies use the same import. - let header = response.headers().get(RETRY_AFTER); - if expected_retry_after { - assert_eq!(header.unwrap().to_str().unwrap(), "60"); - } else { - assert!(header.is_none(), "unexpected Retry-After header"); - } - } - } -``` +Run: `cargo test -p edgezero-core --lib error` +Expected red before Task B4: only assertions that intentionally pin the old response shape +fail; compilation and unrelated behavior remain intact. -- [ ] **Step 4: Add `field_path` presence assertion.** +## Task B4 — Per-variant/reason `kind` + `Retry-After` tests -```rust - #[test] - fn field_path_only_on_config_out_of_date() { - let err = EdgeError::BadRequest { message: "x".into() }; - let body: serde_json::Value = parse_body(err.into_response()); - assert!(body["error"].get("field_path").is_none()); - - let err = EdgeError::config_out_of_date("x", "feature.new_checkout"); - let body: serde_json::Value = parse_body(err.into_response()); - assert_eq!(body["error"]["field_path"], "feature.new_checkout"); - } +**Files:** - fn parse_body(response: Response) -> serde_json::Value { - // Helper: collect body bytes synchronously. If body is async, - // use futures::executor::block_on; that's the in-tree pattern. - ... - } -``` +- Modify: `crates/edgezero-core/src/error.rs` (update existing IntoResponse tests + add new ones per spec §12.6.1). + +**Interfaces:** + +- Consumes: `EdgeError::kind_str` + `IntoResponse` impl from Task B3. + +- [ ] **Step 1: Update existing tests that assert the old body shape.** Find tests asserting `body.error.status` + `body.error.message` only; extend them to also assert `body.error.kind`. Use the table from spec §12.6.1. + +- [ ] **Step 2: Add one consumed case per variant and per reason.** `EdgeError` is not + `Clone`, so build an owned `Vec<(EdgeError, expected_kind, expected_status)>` and consume + it, or use a case factory. Construct structured variants with their real fields or public + constructors. Include all variants listed by the final §12.6.1 matrix and every known + `StoreExtractionReason`; no fake `message` fields and no wildcard matches. + +- [ ] **Step 3: Add `Retry-After` assertions.** Assert `60` for + `ConfigOutOfDate` and store reasons `Deserialization`, `MissingBlob`, `MissingSecret`, + and `Validation`. + Assert absence for every other reason and ordinary variant, including plain + `ServiceUnavailable` and `DeadlineExceeded`. + +- [ ] **Step 4: Add `field_path` assertions.** Assert omission for every ordinary variant + except a non-empty `ConfigOutOfDate` path. For `StoreExtraction`, table-drive every reason + with both `None` and a supplied path: emit it only where §6.3 permits a field-anchored + path, and never emit an empty string. Parse the existing `Result` + return through the in-tree body collection helper rather than assuming a synchronous + response type. - [ ] **Step 5: Run, confirm all pass.** @@ -2589,6 +2539,77 @@ This branch logic is intentionally large because spec §9.0 says "read-back uses Run: `cargo test --workspace --all-targets` Expected: pass. +## Task B13.5 — Bounded config/secret reads and capability cells + +**Files:** + +- Modify: `crates/edgezero-core/src/config_store.rs`. +- Modify: `crates/edgezero-core/src/secret_store.rs`. +- Modify: `crates/edgezero-core/src/app.rs` and `context.rs`. +- Modify: `crates/edgezero-core/src/manifest.rs` and + `crates/edgezero-adapter/src/registry.rs`. +- Modify: all four adapter config/secret-store implementations and contract tests. + +**Interfaces:** + +- Produces: `ConfigExtractionLimits`, its five defaults, `BoundedStoreRead`, lower-level + deadline/size errors, the bounded trait methods, and matching `ConfigStoreHandle`, + `SecretHandle`, and `BoundSecretStore` forwarding methods from spec §6.3.2. +- Produces: `config-read-allocation-bounds` and `config-read-deadlines` capability cells. +- Preserves: unbounded `ConfigStore::get` and `SecretStore::get_bytes` for hand-managed + callers. `AppConfig` must not call them. + +- [ ] **Step 1: Write red core contract tests.** Test default constants and validation; + exact-cap success; first-byte-over failure; checked cumulative accounting overflow; one + absolute deadline shared across blob, Fastly chunks, and secrets; expiry winning a + simultaneous source result; backend-byte reports below returned value length or above + the supplied allowance; and typed + `ValueTooLarge`/`DeadlineExceeded` mappings. A scripted store records every passed + deadline and decreasing backend allowance and proves the deadline never changes. + +- [ ] **Step 2: Run the focused tests and record the expected compile-red.** + +Run: `cargo test -p edgezero-core --lib config_store::tests::bounded` +Expected: compilation fails on the missing limits and bounded trait methods. + +- [ ] **Step 3: Add the core policy and object-safe bounded methods.** Validate limits when + `App` is finalized, copy them into each `RequestContext`, and keep extractor-private + `ConfigExtractionBudget` state with separate checked backend-byte and retained-value + charging. All helper methods take the same `Deadline` and remaining backend allowance; + none accepts an optional or relative timeout. + +- [ ] **Step 4: Implement Axum's Native paths.** Read local files incrementally with + `max_bytes + 1` bounded allocation and race async reads against the absolute deadline. + Drop/cancel the read on expiry. Add fake-time tests for first-byte/inter-read expiry and a + filesystem test proving an oversized value is rejected without allocating the whole file. + Return exact guest-visible `backend_bytes` for every logical read. + +- [ ] **Step 5: Implement Cloudflare, Fastly, and Spin honestly.** Pass the absolute + deadline and remaining backend allowance through all helper layers, check before host + entry and after return, and discard over-cap materialized values. Fastly counts its root + pointer plus every fetched chunk in `BoundedStoreRead::backend_bytes`; direct reads count + the exposed value bytes. Use native cancellation only where the SDK exposes it. Do not + label a post-return length check as an allocation bound or a synchronous host call as + deadline-preemptible. + +- [ ] **Step 6: Add capability parsing, display, build/deploy enforcement, and matrix tests.** + Pin the initial cells exactly as in spec §6.3.2. Tests fail closed for unknown values and + for a manifest requiring Native on a weaker adapter. + +- [ ] **Step 7: Add deployed timing probes.** Each non-Axum probe uses a delayed backend, + records requested deadline versus observed return/cancellation, and emits a machine-readable + artifact. A passing probe is necessary but not sufficient to upgrade allocation support; + host API documentation must also establish pre-materialization behavior. + +- [ ] **Step 8: Run focused and workspace tests.** + +```bash +cargo test -p edgezero-core --lib config_store::tests::bounded +cargo test --workspace --all-targets +``` + +Expected: all pass. + ## Task B14 — Commit Phase B - [ ] **Step 1: Run the four CI gates.** @@ -2639,10 +2660,12 @@ docs/superpowers/specs/2026-06-16-blob-app-config.md. Config::default() still returns ConfigStoreHandle (unwraps binding.handle); new default_binding() accessor returns the binding for the typed extractor. -- §6.3.1 EdgeError gains the ConfigOutOfDate variant + two - constructors (config_out_of_date / config_out_of_date_from_serde). +- §6.3 EdgeError gains non-exhaustive StoreExtractionReason and + the typed StoreExtraction variant before extractor code lands. Response body adds a stable `kind` field on every variant; - Retry-After: 60 fires only on ConfigOutOfDate. + Retry-After: 60 fires only for the effective config_out_of_date + kind. Typed extraction uses bounded config/secret reads under one + absolute deadline and cumulative byte budget. - §3.3.1 SecretKind::KeyInNamedStore { store_ref_field } added; the #[derive(AppConfig)] macro accepts #[secret(store_ref = \"field\")], rejects #[serde(skip_serializing / skip_serializing_if @@ -2702,40 +2725,15 @@ where { async fn from_request(ctx: &RequestContext) -> Result { let binding = ctx.config_store_default_binding().ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no default config store registered — check [stores.config] in edgezero.toml" - )) - })?; - let key = &binding.default_key; - let raw = binding - .handle - .get(key) - .await - .map_err(|e| EdgeError::internal(anyhow::anyhow!(e)))? - .ok_or_else(|| EdgeError::config_out_of_date( - format!("missing typed app-config blob at key `{key}` — run ` config push` for this deploy"), - String::new(), - ))?; - let envelope: BlobEnvelope = serde_json::from_str(&raw) - .map_err(|e| EdgeError::internal(anyhow::anyhow!("envelope parse failed: {e}")))?; - envelope - .verify() - .map_err(|e| EdgeError::internal(anyhow::anyhow!("envelope verification failed: {e}")))?; - let mut data = envelope.into_data(); - // Secret walk per §3.3.3. - secret_walk::(ctx, &mut data).await?; - // Deserialise via serde_path_to_error to preserve field_path - // for ConfigOutOfDate per §4.3. - use serde::de::IntoDeserializer as _; - let cfg: C = serde_path_to_error::deserialize(data.into_deserializer()) - .map_err(EdgeError::config_out_of_date_from_serde)?; - cfg.validate().map_err(|err| { - EdgeError::config_out_of_date( - err.to_string(), - first_violating_field(&err).unwrap_or_default(), + EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, + "no default config store registered — check [stores.config] in edgezero.toml", + None, ) })?; - Ok(AppConfig(cfg)) + extract_from_handle::(ctx, &binding.handle, &binding.default_key) + .await + .map(AppConfig) } } @@ -2745,7 +2743,7 @@ where /// just `"service"`). Round-34 M-1: earlier draft only looked /// at top-level keys, which collapsed /// `service.timeout_ms` → `"service"` and made -/// `EdgeError::ConfigOutOfDate.field_path` useless for any +/// the typed store-extraction `field_path` useless for any /// `#[validate(nested)]` failure (which is the common case in /// app-demo + scaffold templates). fn first_violating_field(errors: &validator::ValidationErrors) -> Option { @@ -2805,32 +2803,39 @@ fn first_violating_field(errors: &validator::ValidationErrors) -> Option async fn secret_walk( ctx: &RequestContext, + budget: &mut ConfigExtractionBudget, data: &mut serde_json::Value, ) -> Result<(), EdgeError> where C: AppConfigMeta, { let data_obj = data.as_object_mut().ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!("blob `data` is not a JSON object")) + EdgeError::store_extraction( + StoreExtractionReason::Deserialization, + "blob `data` is not a JSON object", + None, + ) })?; for field in C::SECRET_FIELDS { let key_name = data_obj .get(field.name) .and_then(|v| v.as_str()) - .ok_or_else(|| EdgeError::config_out_of_date( + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::Deserialization, format!("missing or non-string value at `{}`", field.name), - field.name.to_owned(), + Some(field.name.to_owned()), ))? .to_owned(); let (bound, resolved_store_id) = match field.kind { SecretKind::KeyInDefault => { let bound = ctx.secret_store_default().ok_or_else(|| { - EdgeError::config_out_of_date( + EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, format!( "secret field `{}` has kind KeyInDefault but no default secret store is registered", field.name, ), - field.name.to_owned(), + Some(field.name.to_owned()), ) })?; let id = bound.store_name().to_owned(); @@ -2841,22 +2846,52 @@ where let store_id_str = data_obj .get(store_ref_field) .and_then(|v| v.as_str()) - .ok_or_else(|| EdgeError::config_out_of_date( + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::Deserialization, format!("missing store_ref `{store_ref_field}` for secret field `{}`", field.name), - field.name.to_owned(), + Some(field.name.to_owned()), ))? .to_owned(); let bound = ctx.secret_store(&store_id_str).ok_or_else(|| { - EdgeError::config_out_of_date( - format!("blob declared store_ref `{store_id_str}` but [stores.secrets] has no such id"), - field.name.to_owned(), + EdgeError::store_extraction( + StoreExtractionReason::UnknownStore, + format!( + "secret field `{}` references an unregistered store (identifier redacted)", + field.name, + ), + Some(field.name.to_owned()), ) })?; (bound, store_id_str) } }; - let secret = bound.require_str(&key_name).await.map_err(|err| { - map_secret_error(err, field.name, &resolved_store_id, &key_name) + let read = bound + .get_bytes_bounded( + &key_name, + budget.clock(), + budget.deadline(), + budget.remaining_backend_bytes(), + budget.max_secret_bytes(), + ) + .await + .map_err(|err| map_secret_error(err, field.name, &resolved_store_id, &key_name))?; + budget.charge_backend(read.backend_bytes)?; + let secret_bytes = read.value + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::MissingSecret, + format!( + "the secret referenced by `{}` was not found in its store (identifier redacted)", + field.name, + ), + Some(field.name.to_owned()), + ))?; + budget.charge_value(secret_bytes.len())?; + let secret = String::from_utf8(secret_bytes.to_vec()).map_err(|_| { + EdgeError::store_extraction( + StoreExtractionReason::InvalidSecretValue, + format!("secret for field `{}` is not UTF-8", field.name), + Some(field.name.to_owned()), + ) })?; data_obj.insert(field.name.to_owned(), serde_json::Value::String(secret)); } @@ -2866,25 +2901,77 @@ where fn map_secret_error( err: crate::secret_store::SecretError, field_name: &str, - store_id: &str, - key_name: &str, + _store_id: &str, + _key_name: &str, ) -> EdgeError { use crate::secret_store::SecretError; match err { - SecretError::NotFound { name } => EdgeError::config_out_of_date( - format!("secret `{name}` in store `{store_id}` not found"), - field_name.to_owned(), + SecretError::DeadlineExceeded => EdgeError::store_extraction( + StoreExtractionReason::DeadlineExceeded, + format!("secret resolution for `{field_name}` exceeded its deadline"), + Some(field_name.to_owned()), ), - SecretError::Validation(msg) => EdgeError::config_out_of_date( - format!("secret `{key_name}` in store `{store_id}` rejected: {msg}"), - field_name.to_owned(), + SecretError::NotFound { .. } => EdgeError::store_extraction( + StoreExtractionReason::MissingSecret, + format!( + "the secret referenced by `{field_name}` was not found in its store (identifier redacted)" + ), + Some(field_name.to_owned()), + ), + SecretError::Validation(_msg) => EdgeError::store_extraction( + StoreExtractionReason::InvalidKey, + format!( + "the secret referenced by `{field_name}` was rejected by its store (details redacted)" + ), + Some(field_name.to_owned()), + ), + SecretError::Unavailable => EdgeError::store_extraction( + StoreExtractionReason::SecretBackendUnavailable, + format!("the secret store for `{field_name}` is unreachable"), + Some(field_name.to_owned()), + ), + SecretError::Internal(_source) => EdgeError::store_extraction( + StoreExtractionReason::BackendFailure, + format!("secret resolution for `{field_name}` failed (details redacted)"), + Some(field_name.to_owned()), + ), + SecretError::ValueTooLarge => EdgeError::store_extraction( + StoreExtractionReason::ValueTooLarge, + format!( + "the secret referenced by `{field_name}` exceeds its configured byte limit" + ), + Some(field_name.to_owned()), + ), + } +} + +fn map_config_store_error(err: ConfigStoreError) -> EdgeError { + match err { + ConfigStoreError::DeadlineExceeded => EdgeError::store_extraction( + StoreExtractionReason::DeadlineExceeded, + "typed app-config store read deadline exceeded", + None, + ), + ConfigStoreError::InvalidKey { .. } => EdgeError::store_extraction( + StoreExtractionReason::InvalidKey, + "typed app-config store rejected the requested key (details redacted)", + None, + ), + ConfigStoreError::Unavailable { .. } => EdgeError::store_extraction( + StoreExtractionReason::BackendUnavailable, + "typed app-config store is unavailable", + None, + ), + ConfigStoreError::Internal { .. } => EdgeError::store_extraction( + StoreExtractionReason::BackendFailure, + "typed app-config store read failed (details redacted)", + None, + ), + ConfigStoreError::ValueTooLarge => EdgeError::store_extraction( + StoreExtractionReason::ValueTooLarge, + "typed app-config store value exceeded its read limit", + None, ), - SecretError::Unavailable => EdgeError::service_unavailable(format!( - "secret store `{store_id}` unreachable" - )), - SecretError::Internal(source) => EdgeError::internal(anyhow::anyhow!( - "secret `{key_name}` in store `{store_id}` produced unexpected store error: {source}" - )), } } ```` @@ -2906,9 +2993,11 @@ where /// store and prefer the bare `C` over wrapping/unwrapping. pub async fn named(ctx: &RequestContext, key: &str) -> Result { let binding = ctx.config_store_default_binding().ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no default config store registered — check [stores.config] in edgezero.toml" - )) + EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, + "no default config store registered — check [stores.config] in edgezero.toml", + None, + ) })?; extract_from_handle::(ctx, &binding.handle, key).await } @@ -2922,9 +3011,11 @@ where key: Option<&str>, ) -> Result { let binding = ctx.config_store_binding(store_id).ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no config store registered for id `{store_id}`" - )) + EdgeError::store_extraction( + StoreExtractionReason::UnknownStore, + format!("no config store registered for id `{store_id}`"), + None, + ) })?; let key = key.unwrap_or(&binding.default_key); extract_from_handle::(ctx, &binding.handle, key).await @@ -2942,36 +3033,49 @@ async fn extract_from_handle( where C: DeserializeOwned + AppConfigMeta + Validate + Send + 'static, { - // ConfigStoreError → EdgeError uses the existing `impl - // From for EdgeError` at - // `crates/edgezero-core/src/error.rs:148`, which maps - // Unavailable → ServiceUnavailable (503), InvalidKey → - // BadRequest (400), Internal → Internal (500). Per spec §6.3's - // mapping table. NEVER `map_err(EdgeError::internal)` here — - // that collapses backpressure / bad-key signals into 500s and - // dashboards lose the distinction. - let raw = handle - .get(key) + let mut budget = ConfigExtractionBudget::start(ctx.config_extraction_limits())?; + let read = handle + .get_bounded( + key, + budget.clock(), + budget.deadline(), + budget.remaining_backend_bytes(), + budget.max_blob_bytes(), + ) .await - .map_err(EdgeError::from)? - .ok_or_else(|| EdgeError::config_out_of_date( + .map_err(map_config_store_error)?; + budget.charge_backend(read.backend_bytes)?; + let raw = read.value + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::MissingBlob, format!("missing typed app-config blob at key `{key}` — run ` config push` for this deploy"), - String::new(), + None, ))?; + budget.charge_value(raw.len())?; let envelope: BlobEnvelope = serde_json::from_str(&raw) - .map_err(|e| EdgeError::internal(anyhow::anyhow!("envelope parse failed: {e}")))?; + .map_err(|_| EdgeError::store_extraction( + StoreExtractionReason::MalformedEnvelope, + "typed app-config envelope is invalid", + None, + ))?; envelope.verify().map_err(|e| { - EdgeError::internal(anyhow::anyhow!("envelope verification failed: {e}")) + EdgeError::store_extraction( + StoreExtractionReason::IntegrityMismatch, + format!("typed app-config integrity check failed: {e}"), + None, + ) })?; let mut data = envelope.into_data(); - secret_walk::(ctx, &mut data).await?; + secret_walk::(ctx, &mut budget, &mut data).await?; use serde::de::IntoDeserializer as _; let cfg: C = serde_path_to_error::deserialize(data.into_deserializer()) - .map_err(EdgeError::config_out_of_date_from_serde)?; + .map_err(EdgeError::store_deserialization_from_serde)?; cfg.validate().map_err(|err| { - EdgeError::config_out_of_date( - err.to_string(), - first_violating_field(&err).unwrap_or_default(), + let field = first_violating_field(&err).unwrap_or_default(); + EdgeError::store_extraction( + StoreExtractionReason::Validation, + "typed app-config failed validation", + (!field.is_empty()).then_some(field), ) })?; Ok(cfg) @@ -2988,9 +3092,11 @@ where { async fn from_request(ctx: &RequestContext) -> Result { let binding = ctx.config_store_default_binding().ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no default config store registered — check [stores.config] in edgezero.toml" - )) + EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, + "no default config store registered — check [stores.config] in edgezero.toml", + None, + ) })?; let key = binding.default_key.clone(); extract_from_handle::(ctx, &binding.handle, &key).await.map(AppConfig) @@ -3002,47 +3108,30 @@ where Per spec §12.1 "AppConfig extractor" bullet list: "`named(key)` reads a different key from the same store" — assert it. Plus add a test for `from_store` reading a non-default `[stores.config]` id. -- [ ] **Step 4: Write four extractor error-path tests covering the full §6.3 mapping table.** - -The `ConfigStoreError → EdgeError` path is delegated to the existing `From` impl, but the extractor is the contract surface — tests need to fail loudly if a future refactor accidentally re-introduces `map_err(EdgeError::internal)`. - -```rust - #[test] - fn app_config_extractor_returns_config_out_of_date_on_missing_blob() { - // Mock a ConfigStore whose get() returns Ok(None). - struct EmptyStore; - #[async_trait(?Send)] - impl ConfigStore for EmptyStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Ok(None) } - } - // ... build a request context with that store via a binding ... - // Run extract; assert Err(EdgeError::ConfigOutOfDate { .. }). - // Assert message contains "missing typed app-config blob" + "run ` config push`". - } - - #[test] - fn app_config_extractor_maps_config_store_unavailable_to_service_unavailable() { - // Mock store returning Err(ConfigStoreError::Unavailable { ... }). - // Run extract. - // Assert Err(EdgeError::ServiceUnavailable { .. }) — NOT - // EdgeError::Internal. Per §6.3 mapping table. - } - - #[test] - fn app_config_extractor_maps_config_store_invalid_key_to_bad_request() { - // Mock store returning Err(ConfigStoreError::InvalidKey { ... }). - // Run extract. - // Assert Err(EdgeError::BadRequest { .. }). - } - - #[test] - fn app_config_extractor_maps_config_store_internal_to_internal() { - // Mock store returning Err(ConfigStoreError::Internal { source }). - // Run extract. - // Assert Err(EdgeError::Internal { .. }) and that the source - // chain still carries the original anyhow::Error. - } -``` +- [ ] **Step 4: Write extractor error-path tests covering the complete §6.3 reason table.** + +Use scripted config/secret stores and malformed envelope/schema fixtures to produce every +known `StoreExtractionReason`: backend failure/unavailability, deadline, integrity, +envelope/key/secret validity, missing blob/registry/secret, schema mismatch, secret backend +unavailability, unknown store, and byte overflow. Assert the returned variant and reason +directly, then assert its wire status/kind/header/path. Include these boundary cases: + +- `get_bounded` receives the one captured deadline and root cap; the secret walk receives + the same deadline and per-secret cap. +- An exactly-at-cap blob/secret and cumulative total succeed; one byte above each fails as + `ValueTooLarge` before parse or insertion. +- A deadline that expires while the host result becomes ready yields `DeadlineExceeded`. +- `ConfigStoreError::{InvalidKey,Unavailable,Internal}` map to + `InvalidKey`, `BackendUnavailable`, and `BackendFailure`, respectively. +- A missing root returns `MissingBlob`; missing/unknown registries are distinct; a missing + secret returns `MissingSecret`; non-UTF-8 secret bytes return `InvalidSecretValue`. +- Envelope parse, unsupported version/discriminator, and SHA mismatch map to + `MalformedEnvelope`, `UnsupportedVersion`, and `IntegrityMismatch`; deserialize and + validator failures map independently to `Deserialization` and `Validation`, with + redacted paths and messages that never include stored or resolved secret values. + +Do not use the generic `From for EdgeError` in this extractor: that impl is +for hand-managed reads and intentionally lacks extraction context. - [ ] **Step 5: Run.** Expected: pass. @@ -3054,7 +3143,9 @@ Run: `cargo test -p edgezero-core --lib extractor::tests::app_config` - Modify: `crates/edgezero-cli/src/config.rs` — `run_config_push_typed` builds ONE `BlobEnvelope`, serialises it, resolves the target key, and calls the EXISTING `adapter.push_config_entries(...)` writer with exactly one logical `(key, envelope_json)` entry. - Modify: each adapter under `crates/edgezero-adapter-*/src/cli.rs` (and `push_sqlite.rs` / `push_cloud.rs` for Spin) ONLY for: (a) any pre-blob-model "flatten + per-leaf loop" preprocessing the writer did, (b) per-platform cap checks on the single logical entry, and (c) Fastly's adapter-private expansion of an oversized logical entry into physical chunk entries plus a root pointer. Adapters do NOT construct `BlobEnvelope` from `C` — the `(String, String)` writer surface stays exactly as it is at `crates/edgezero-adapter/src/registry.rs:277`. -- Modify: `crates/edgezero-adapter-fastly/src/config_store.rs` — runtime `FastlyConfigStore::get` resolves direct-or-pointer values through the same Fastly chunk helper. +- Modify: `crates/edgezero-adapter-fastly/src/config_store.rs` — runtime + `FastlyConfigStore::{get,get_bounded}` resolve direct-or-pointer values through the same + Fastly chunk helper; the bounded path counts pointer plus chunk bytes. **Ownership rationale (round-26 H-2, updated for Fastly chunking):** the existing `push_config_entries` trait method takes `entries: &[(String, String)]` — a sequence of key/value pairs. Adapters don't see `C` or `cfg`. Earlier draft of this task told each adapter to build the envelope from `cfg`, which would have required widening the trait to take `C` (breaking the adapter abstraction) OR duplicating envelope-construction code across all four adapters. The clean ownership remains: **CLI builds the envelope once, then hands the writer ONE logical entry: `(resolved_key, envelope_json)`.** Fastly may expand that one logical entry into multiple physical config-store entries internally; every other adapter writes the logical entry directly. @@ -3144,7 +3235,10 @@ The existing cap check at line 90 (`if pair.len() >= MAX_ARGV_BYTES_PER_INVOCATI - Fastly local push writes root pointer plus literal dotted chunk keys under `[local_server.config_stores..contents]`. - Fastly local dry-run reports chunking and does not edit `fastly.toml`. - Simulated Fastly failure before the pointer write leaves the previous root active; this is tested at helper/writer level by asserting the root write is last. -- Runtime `FastlyConfigStore::get` resolves a direct value unchanged and reconstructs a pointer value by fetching chunks from the same store. +- Runtime `FastlyConfigStore::{get,get_bounded}` resolve a direct value unchanged and + reconstruct a pointer value by fetching chunks from the same store. The bounded path + threads one deadline/remaining allowance through every fetch and reports the full + guest-visible byte count. - [ ] **Step 5: Run.** @@ -3947,7 +4041,7 @@ app-demo handlers in the same commit). secret walk per Model A (#[secret] / #[secret(store_ref)] / #[secret(store_ref = \"field\")]), serde_path_to_error deserialise, validator::Validate::validate. Missing blob → - EdgeError::ConfigOutOfDate per Q3 (d). + EdgeError::StoreExtraction with reason MissingBlob per Q3 (d). - §8.2 config push rewrite: single logical envelope per [stores.config] root key per adapter. Per-adapter writers (Axum file map / Cloudflare bulk put with namespace-id+remote / @@ -4507,7 +4601,11 @@ trap - EXIT assert_contains "$result" "large-fastly-blob" "fastly chunk-pointer runtime read" ``` -The actual fixture should generate the repeated text programmatically rather than checking in an enormous TOML file. The important assertion is that runtime `FastlyConfigStore::get` follows the pointer and returns the reconstructed normal envelope to the core extractor. +The actual fixture should generate the repeated text programmatically rather than checking +in an enormous TOML file. Assert that runtime `FastlyConfigStore::get_bounded` follows the +pointer, reports pointer plus chunk bytes, and returns the reconstructed normal envelope to +the core extractor; the hand-managed `get` compatibility path returns the same logical +value. **Spin Cloud exception.** Spin Cloud's `Unsupported` read-back per spec §8.3 means the smoke script can't `config diff` against Cloud, but the push + extractor path is the same shape; the smoke loop's `spin:--local` row exercises Spin's SQLite read path (per round-29 H-1's path-resolver fixtures, the SQLite resolution already has unit-test coverage; the smoke just adds the end-to-end composition check). The Spin Cloud branch is exercised by the SEPARATE block below. @@ -4577,7 +4675,7 @@ Mapping spec §s → tasks: | §3.3.2 (writer behaviour + structural checks) | B11 | | §3.3.3 (extractor secret walk) | C1 | | §3.3.4 (blob layout) | C2 (envelope creation) | -| §3.3.6 (SecretError → EdgeError mapping) | C1 (`map_secret_error`) | +| §3.3.6 (SecretError → EdgeError mapping) | B2.5, B13.5, C1 (`map_secret_error`) | | §3.3.7 (sha + secret-key interaction) | A2 + C1 | | §3.3.8 (push vs runtime validation) | B9 + C4 | | §4.1 (envelope shape) | A4 | @@ -4592,10 +4690,11 @@ Mapping spec §s → tasks: | §6.2 (explicit-key `named(key)` form) | C1 (Step 2 inherent impl) | | §6.2.1 (cross-store `from_store(id, key)` form) | C1 (Step 2 inherent impl) | | §6.2.2 (runtime validation) | C1 (`cfg.validate()`) | -| §6.3 (errors — bullet list) | B2, B3, C1 | -| §6.3.1 (`ConfigOutOfDate` body shape) | B2, B3, B4 | +| §6.3 (typed extraction errors) | B2, B2.5, B3, B4, C1 | +| §6.3.1 (`config_out_of_date` wire shape) | B2, B2.5, B3, B4 | +| §6.3.2 (bounded/cancellable reads) | B13.5, C1 | | §6.4 (no caching) | C1 (re-reads every call) | -| §6.5 (existing `ConfigStore` trait stays) | (no code work — kept) | +| §6.5 (existing unbounded store methods stay) | B13.5 (preserved beside bounded methods) | | §7.x (SHA discussion) | A2 (canonical form) | | §8.1 (config diff) | D1, D2 | | §8.2 (config push + consent) | C2, C3, C4 | diff --git a/docs/superpowers/plans/2026-07-04-edgezero-app-macro-state.md b/docs/superpowers/plans/2026-07-04-edgezero-app-macro-state.md index f8a44f632..ea0668778 100644 --- a/docs/superpowers/plans/2026-07-04-edgezero-app-macro-state.md +++ b/docs/superpowers/plans/2026-07-04-edgezero-app-macro-state.md @@ -202,7 +202,7 @@ pub struct DemoState { /// Returns the shared app state, referenced by `app!(..., state = crate::app_state())`. /// /// IMPORTANT: `app!(state = )` emits this call inside the macro-generated -/// `build_router()`, which every adapter's `run_app` invokes via `A::build_app()` +/// `build_router()`, which every adapter's `run_app` invokes via `App::build::(platform)` /// — once at startup for long-lived runtimes (Axum), but **once per request** on /// Fastly Compute (each request is a fresh Wasm instance). So `app_state()` must /// be **cheap**: build the heavy state once and hand out clones. Here a diff --git a/docs/superpowers/plans/2026-07-04-edgezero-fastly-dispatch-fidelity.md b/docs/superpowers/plans/2026-07-04-edgezero-fastly-dispatch-fidelity.md index 03070b3ad..b19962c88 100644 --- a/docs/superpowers/plans/2026-07-04-edgezero-fastly-dispatch-fidelity.md +++ b/docs/superpowers/plans/2026-07-04-edgezero-fastly-dispatch-fidelity.md @@ -1,18 +1,21 @@ # EdgeZero P0-C — Fastly `run_app` Dispatch Fidelity Implementation Plan +> **Status:** Implemented and superseded. The 2026-09-15 consumer-alignment plan replaces the +> request-only Fastly hook below with the closed request/response lifecycle API. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Bring Fastly's `run_app` to parity with hand-written custom dispatch: preserve multi-value response headers (`Set-Cookie`), let an app opt out of the adapter's logger init, and add a pre-dispatch hook that reads raw-`fastly::Request` signals (JA4 / H2 / client IP) into the core request's extensions. **Architecture:** Three independent Fastly-adapter changes plus one cross-adapter `Hooks` method. C1 swaps `set_header`→`append_header` on the (fresh) response and fixes the proxy-response conversion to append per value. C2 adds a platform-neutral `Hooks::owns_logging()` that every adapter's entrypoint gates its logger init on, with an `app!(owns_logging = …)` macro argument. C3 adds `run_app_with_request_extensions` that runs an app closure against a scratch `Extensions` **before** request conversion, then `extend`s it into the core request. -**Tech Stack:** Rust 1.95, edition 2021. `edgezero-adapter-fastly` (behind the `fastly` feature), `edgezero-core` (`Hooks` trait, `http::Extensions`), `edgezero-macros` (`app!`). `http` crate `HeaderMap`/`Extensions` via `edgezero_core::http`. +**Tech Stack:** Rust 1.95, edition 2024. `edgezero-adapter-fastly` (behind the `fastly` feature), `edgezero-core` (`Hooks` trait, `http::Extensions`), `edgezero-macros` (`app!`). `http` crate `HeaderMap`/`Extensions` via `edgezero_core::http`. **Source spec:** `docs/superpowers/specs/2026-07-03-edgezero-fastly-dispatch-and-appstate-design.md` (P0-C), verified against `65afbd3`. ## Global Constraints -- **Rust 1.95.0**, edition 2021. +- **Rust 1.95.0**, edition 2024. - **Strict clippy gate** — `[workspace.lints.clippy] restriction = { level = "deny" }`. Every restriction lint is an ERROR. The ones that bite here: - `missing_trait_methods` — an `impl Trait` may not inherit a defaulted method. Adding `Hooks::owns_logging()` forces the **macro-emitted** `impl Hooks` to emit it explicitly (the two in-file `Hooks` test stubs already carry `#[expect(clippy::missing_trait_methods)]`, so they need no change). - `arbitrary_source_item_ordering` — module items / struct fields / enum variants must be alphabetical; place new test fns and struct fields in the correct position, don't append. @@ -27,7 +30,7 @@ ``` (cd crates/edgezero-adapter-fastly && cargo test --features fastly --lib ) ``` - This builds to `wasm32-wasip1` and runs the test binary under Viceroy (0.17.0, pinned in `.tool-versions`), which provides the hostcalls — so `FastlyResponse::from_status`/`append_header`/`get_header_all`, `FastlyRequest::new`/`get_url_str`, and even `get_client_ip_addr` all work at runtime. **Every `cargo test -p edgezero-adapter-fastly --features fastly …` command in the tasks below MUST be run in this `(cd crates/edgezero-adapter-fastly && cargo test --features fastly …)` form** — the commands are written the short way for brevity. + This builds to `wasm32-wasip1` and runs the test binary under Viceroy (0.21.0, pinned in `.tool-versions`), which provides the hostcalls — so `FastlyResponse::from_status`/`append_header`/`get_header_all`, `FastlyRequest::new`/`get_url_str`, and even `get_client_ip_addr` all work at runtime. **Every `cargo test -p edgezero-adapter-fastly --features fastly …` command in the tasks below MUST be run in this `(cd crates/edgezero-adapter-fastly && cargo test --features fastly …)` form** — the commands are written the short way for brevity. - `crates/edgezero-adapter-fastly/tests/contract.rs` is `#![cfg(all(feature = "fastly", target_arch = "wasm32"))]` (the same wasm/Viceroy path). CI runs it via `cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contract`. - `cargo clippy`/`cargo check --features fastly` type-check on the **host** (no link), so clippy runs from the workspace root as usual. - **CI gates (all must pass):** @@ -818,7 +821,7 @@ where let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); init_logger(endpoint, logging.level, logging.echo_stdout)?; } - let app = A::build_app(); + let app = App::build::(FASTLY_PLATFORM)?; request::dispatch_with_registries( &app, req, diff --git a/docs/superpowers/plans/2026-07-10-outbound-http-implementation.md b/docs/superpowers/plans/2026-07-10-outbound-http-implementation.md new file mode 100644 index 000000000..a134089f3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-outbound-http-implementation.md @@ -0,0 +1,150 @@ +# Outbound HTTP Implementation Index + +> **Status:** Phase index, not an executable implementation plan. +> +> The authoritative contract is +> [`2026-05-21-outbound-http-design.md`](../specs/2026-05-21-outbound-http-design.md). +> This file deliberately does not duplicate adapter algorithms, capability matrices, or +> migration details from that specification. Read the relevant spec sections against the +> current tree before authoring each phase plan. + +## Current Baseline + +- Rust 1.95, edition 2024, resolver 2. +- Four runtime adapters: Axum, Cloudflare, Fastly, and Spin SDK 7 / WASI HTTP 0.3. +- Adapter dispatch has both `execute(..)` and `execute_capture(..)` entry points. +- The action set includes staged deploy, version emission, healthcheck, and rollback in + addition to build/serve/deploy/auth. +- The outbound design declares exactly twelve **outbound** capabilities. The matrix and its + footnotes in spec §3.5.2 are the only authority for those support levels; other specs may + add non-outbound cells to the shared enum. + +## Locked Scope + +- The public `proxy` API becomes `outbound` without compatibility aliases. Templates, + public docs, generated projects, and `examples/app-demo` migrate in the same implementation + series. +- Core remains runtime-independent and WASM-compatible: no Tokio, reqwest, Fastly, worker, + or Spin SDK dependency enters `edgezero-core`. +- `EdgeError` gains `BadGateway { reason: BadGatewayReason }`, attributed + `GatewayTimeout`, and the distinct + `ResponseTooLarge { reason: ResponseLimitReason }` outcome. Response overflow is not + collapsed into `BadGateway`, and reason/provenance fields remain outside the JSON wire + envelope. +- `DispatchBudget` and `dispatch_budget` land together after `OutboundRequest` exposes its + private budget-input carrier. They are not part of Phase 1a. +- Capability types are owned by `edgezero-core::manifest`. Consequently, + `edgezero-adapter` adds a direct dependency on `edgezero-core` for the registry trait's + public capability signature. +- Outbound capability enforcement applies to construction/deployment of the current + runtime: build, serve, deploy, staged deploy, and demo. New outbound-scoped + `execute_runtime(..)` and `execute_capture_runtime(..)` entry points accept an owned + runtime and no separate adapter/action identity; they derive both values from that + runtime and gate exactly once before shell or registry dispatch. Existing operational + dispatch remains unchanged for auth, version emission, healthcheck, and rollback; + provision and config are also outside this gate. +- Spin request-component setters and request-option setters have different error types. + `RequestOptionsError::NotSupported` retains the outer monotonic deadline race and logs a + BestEffort degradation; `Immutable` and `Other(..)` are internal setup failures. +- The inbound `RequestContext` body-state migration is owned by + [`2026-08-22-inbound-body-design.md`](../specs/2026-08-22-inbound-body-design.md). The + outbound spec depends only on its `into_request()` contract. Ingress admission, + request-start/read deadlines, and raw framing rejection are not prerequisites for Phase + 1a and must not be implemented opportunistically in an outbound phase. +- The downstream response-write lifetime starts after a core `Response` reaches an adapter + converter. Adapter phases may implement the bounded fallback required by this outbound + design, but must not claim a general write deadline, native abort, or exactly-once egress + completion until a dedicated response-egress contract is reviewed. +- GitHub accepts `workflow_dispatch` events only for workflow files already present on the + default branch ([GitHub workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#onworkflow_dispatch)). Before Phase 4 begins, land a standalone infrastructure bootstrap PR that + adds the inert, protected, exact-SHA dispatcher shells + `.github/workflows/outbound-cloudflare-deployed.yml` and + `.github/workflows/outbound-fastly-characterization.yml`. Those default-branch workflows + check out only a maintainer-reviewed `commit_sha` and execute the probe driver from that + tree. This bootstrap is the sole exception to the branch-only no-intermediate-merge rule: + it publishes no API, capability cell, runtime behavior, or secret-bearing automatic trigger. + +## Phase Sequence + +| Phase | Scope | Plan status | +| --- | --- | --- | +| 0 | Bootstrap inert default-branch exact-SHA probe dispatchers and provision protected disposable environments/origin | Not landed; required only before promoting provider capabilities from `BestEffort`: [`2026-09-06-outbound-http-phase0-probe-bootstrap.md`](2026-09-06-outbound-http-phase0-probe-bootstrap.md) | +| 1a | Add `BadGatewayReason`, `BudgetSource`, typed `BadGateway`, attributed `GatewayTimeout`, `Deadline`, and the three deadline constants | Implemented; plan: [`2026-07-10-outbound-http-phase1a-error-time.md`](2026-07-10-outbound-http-phase1a-error-time.md) | +| 1b | Add the pinned direct `url` dependency, outbound request/response types, one-time canonical URI construction, resource-limit builders, `DispatchBudget`, and `dispatch_budget` in a buildable dependency order | Implemented; plan: [`2026-09-06-outbound-http-phase1b-core-types-budget.md`](2026-09-06-outbound-http-phase1b-core-types-budget.md) | +| 2 | Typed body errors, typed response-limit errors, encoded/header/Brotli/chunk controls, bounded/deadline-aware drains, normalization, and shared decoder primitives | Implemented; plan: [`2026-09-06-outbound-http-phase2-body-response-limits.md`](2026-09-06-outbound-http-phase2-body-response-limits.md) | +| 3 | Manifest capability declarations, fail-closed adapter metadata contract, paired target/contract resolver, CLI gates, and selected-component Spin host-drift validation | Implemented; plan: [`2026-09-06-outbound-http-phase3-capabilities-cli.md`](2026-09-06-outbound-http-phase3-capabilities-cli.md) | +| 4 | Axum and Cloudflare outbound implementations, response-converter scheduling, host-event fairness, and contract tests | Implemented; deployed Cloudflare promotion evidence remains external: [`2026-09-06-outbound-http-phase4-axum-cloudflare.md`](2026-09-06-outbound-http-phase4-axum-cloudflare.md) | +| 5 | Spin hand-built WASI HTTP request/response state machines, completion-error mapping, and cooperative raw/decoded-input fairness | Implemented with repository-pinned SDK-resource execution: [`2026-09-06-outbound-http-phase5-spin.md`](2026-09-06-outbound-http-phase5-spin.md) | +| 6 | Fastly dispatch/harvest engine, dynamic backends, timers, and test seams | Implemented; deployed Fastly characterization remains external: [`2026-09-06-outbound-http-phase6-fastly.md`](2026-09-06-outbound-http-phase6-fastly.md) | +| 7 | Templates, `app-demo`, public docs, generated-project checks, hard legacy-API removal, and remaining live-host characterization | Implemented; live-host promotion evidence remains external: [`2026-09-06-outbound-http-phase7-migration-docs.md`](2026-09-06-outbound-http-phase7-migration-docs.md) | + +After the inert workflow bootstrap above, the phase numbers after 1a are organizational +guidance, not permission to split an +invariant across unbuildable commits. Phases 1b and 2 stage the new core module alongside +the old one; Phases 4-6 migrate adapters; Phase 7 migrates external consumers and deletes +the old module. That temporary dual surface is branch-only scaffolding: no intermediate +state is releasable, there are no aliases between old and new names, and the series is not +mergeable until Phase 7's scoped legacy-symbol gate passes. Each task still leaves its +current repository/worktree buildable. + +Phase 3 leaves each in-tree adapter on the registry method's `Unsupported` default. The +exact Axum/Cloudflare, Spin, and Fastly matrix rows are published only in Phases 4, 5, and +6 respectively, in the same commit series as their passing behavior contracts and any +required host evidence. This prevents the CLI from accepting a capability before the +runtime implements it. + +Phases 4-6 include each adapter's direct dependencies, independent `test-utils` feature, +native contract tests, and explicit native/WASM CI activation from spec §5.5. These gates +land with their adapter implementation rather than waiting for phase 7. Cloudflare's raw +subrequest encoding bridge and host-observed cancellation/encoding tests belong to phase 4; +Workers-only response conversion (including `Headers.getAll("set-cookie")`) runs in the +locked workerd fixture, while browser WASM covers only portable bridge behavior. +Phase 2 includes the shared decoder's gzip-member/native-completion contract, transport-side +encoded cap, opt-in rechunker, response-header caps, and pre-allocation Brotli-window check +(§3.4.1/§3.4.5). +Phase 4 includes Axum's connection-owned Hyper HTTP/1 response-delivery boundary and +Cloudflare's host-event yield quotas, frozen-clock regressions, manual response-body encoding, +and null-body 205 branch (§4.1/§4.2). Provider capability promotion still requires the deployed +evidence named by the authoritative design. +Phase 5 Task 0 must verify the Spin SDK-resource runner/harness compatibility gate in spec +§5.5; HTTP/Preview 3 registration flags alone are not execution evidence. Tasks 1-6 remain +blocked until the committed crate-local runner executes the named real-SDK resource tests +with a nonzero count. After that proof, the implementation must route `client::send`, +`request_done`, and response completion through the same typed classifier and make +continuously-ready raw/decoded input yield cooperatively. Phase 6 +includes known-`SendErrorCause` boundary tests and the buffered-batch versus streamed-upload +cleanup distinction (§4.3). These requirements do not change Phase 1a. + +## Plan-Authoring Gate + +Before writing any later phase plan: + +1. Re-read the relevant spec sections and inspect the current implementations and pinned + SDK source. +2. Enumerate every touched file, including Cargo manifests and generated/template surfaces. +3. Keep platform behavior at adapter boundaries and portable value/arithmetic layers in + core. +4. Define executable test seams, their Cargo features, target gates, and CI commands using + spec §5.5. Confirm each command executes tests rather than reporting zero tests behind + a whole-file platform gate, and validate SDK-resource runner/harness compatibility. + Test constructible known SDK enum variants directly; do not fabricate nonexistent variants of + `#[non_exhaustive]` enums with unsafe code or test-only public variants. +5. Separate no-runtime contract tests from live-host characterization. A mock must not be + used to claim host cancellation or wire behavior. +6. Include the resource-limit precedence matrix, every typed reason value, multi-member + gzip drain-to-native-EOF cases, and the platform's exact abort/completion owner. A + rechunker test may claim bounded emitted item size, not bounded transport allocation or + process RSS. +7. Run the repository gates required by `CLAUDE.md`, plus generated-project, + `examples/app-demo`, adapter-target, and documentation checks for phases that touch those + surfaces. Generated-project verification explicitly runs + `cargo test -p scaffold-probe-core --lib`; a build-only command is insufficient. + +## Readiness + +Phases 1a-7 are implemented on PR 275. Phase 0's protected default-branch probe +dispatchers and the resulting deployed-provider observations remain external promotion +evidence; their absence keeps the documented provider limitations at `BestEffort` and does +not turn local or WASM-runner tests into host-certification evidence. The master design +remains the normative behavior contract; these phase plans preserve the implementation +history without replacing it. diff --git a/docs/superpowers/plans/2026-07-10-outbound-http-phase1a-error-time.md b/docs/superpowers/plans/2026-07-10-outbound-http-phase1a-error-time.md new file mode 100644 index 000000000..58269f505 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-outbound-http-phase1a-error-time.md @@ -0,0 +1,814 @@ +# Outbound HTTP — Phase 1a: typed `EdgeError` 502/504 + `time.rs` primitives + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Land the **additive, no-new-dependency on the current baseline** core primitives from the outbound-HTTP spec ([`2026-05-21-outbound-http-design.md`](../specs/2026-05-21-outbound-http-design.md)): `EdgeError::BadGateway { reason: BadGatewayReason }`, `GatewayTimeout { cause: BudgetSource }`, and the `edgezero-core::time` module's `Deadline` + budget constants. `BadGatewayDecodeReason`, `BadGatewayReason`, and `BudgetSource` land in `error.rs`, Task 1, because the variants name them; `dispatch_budget` remains Phase 1b. Neither task touches the `proxy → outbound` rename or `Body`, so each task keeps `cargo test --workspace` green. **Scope caveat:** per-task verification is a deliberate local subset (Task 3); generated projects, `app-demo`, and expanded adapter-specific WASM matrices remain CI backstops. + +**Architecture:** `edgezero-core` only. Additive: two new `EdgeError` variants, three non-exhaustive reason/provenance enums, and a new `time` module. The `EdgeError` enum is `#[non_exhaustive]`, but matches inside its defining crate remain exhaustive and must gain both arms. `BadGatewayReason` distinguishes pre-response unreachability from later transport/protocol failures, while `BadGatewayDecodeReason` preserves the EdgeZero-owned codec identity. No reason/cause field is serialized. `message()` preserves Rust-side diagnostics, while HTTP conversion emits fixed `"bad gateway"` / `"gateway timeout"` category strings. No adapter, CLI, or app-demo change. **`DispatchBudget` and `dispatch_budget` are both deferred to Phase 1b**; Phase 1a lands `Deadline` + constants only. + +**Round-59 scope alignment:** the master spec's response resource limits, canonical URL +parser, adapter scheduling/completion rules, ingress ownership, and response-egress gate are +later work. The corrected `Body::Stream` constructors, +Fastly buffered-upload caveat, Axum/Cloudflare upload-pull boundary checks, and Spin +exchange state machine are later outbound/adapter work. They do not alter any Phase 1a +task, file, API, or verification command. In particular, this plan must not opportunistically +change `Body`, `proxy`, or an adapter while landing the error/time primitives. + +**Tech Stack:** Rust 1.95 (edition 2024), `thiserror`, `serde_json`, `web-time` (behind the public `MonotonicInstant` alias), `futures::executor::block_on` for async tests. + +> **Post-Phase 1a note:** later ingress/outbound hardening added the EdgeZero-owned +> `MonotonicClock` handle and made `Deadline::{is_expired_at,remaining_at}` public so one +> application clock can be paired across deferred work. The implementation excerpt below records +> the smaller Phase 1a landing shape; the current API is normative in the outbound design. + +## Global Constraints (from the master design and phase index) + +- **WASM-first:** no `tokio`/runtime deps; public APIs use + `edgezero_core::time::MonotonicInstant`, backed internally by `web_time::Instant`, not + `std::time::Instant`. Core stays `default-features = false`. +- **Colocated tests** (`#[cfg(test)]` same file); async tests use `futures::executor::block_on`. +- **Verbatim constants:** `DEFAULT_NO_DEADLINE_BUDGET = 30 s`, `DEADLINE_FAR_FUTURE = 7 days`, `BATCH_DISPATCH_SLACK_MAX = 25 ms`. +- **CI gates must stay green:** `cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-targets`; `cargo check --workspace --all-targets --features "fastly cloudflare spin"`; `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`. +- **Verified against the current worktree's `crates/edgezero-core/src/error.rs`** (re-confirm with the compiler-driven Step 6 rather than trusting line numbers): `EdgeError` today has variants `BadRequest, ConfigOutOfDate, Internal, MethodNotAllowed, NotFound, NotImplemented, ServiceUnavailable, Validation`. The two new arms must be added to **nine** exhaustive matches: five implementation matches (`inner`, `kind_str`, `message`, `status`, and `IntoResponse`'s `field_path_opt`) plus four explicit `ConfigOutOfDate` test matches. The compiler remains the source of truth. Three per-variant tests gain rows for both variants. Reason/provenance tests additionally cover every known enum value and wire-shape isolation. `web-time` presence is confirmed in Task 0. +- **`cargo test` accepts only ONE positional filter** — `cargo test -p X a b` fails with `unexpected argument 'b'` (verified). Use a single common substring or two separate commands. +- **The Clippy gate is STRICT — read this before writing any code.** The root `Cargo.toml` sets `restriction = { level = "deny", priority = -1 }`, and the following are **not** allow-listed, so they are hard errors in **production** code: + - `missing_inline_in_public_items` → **every public fn needs `#[inline]`** (error.rs already carries 14). + - `min_ident_chars` → no single-char idents (`d` → `duration`). + - `arithmetic_side_effects` → **no bare `+` / `-`** on `Instant`/`Duration`; use `checked_add` / `checked_duration_since`. + - `expect_used`, `unwrap_used`, `as_conversions` → forbidden in production; use `?`/`ok_or`/`From`/`TryFrom`. + - **`unseparated_literal_suffix`** → integer suffixes need an underscore: `502_u16`, not `502u16` (verified: `502u16` errors; the opposing `separated_literal_suffix` is allow-listed, so the underscore form is the one that passes). + - **`arbitrary_source_item_ordering`** → **items must be ALPHABETICAL** — consts, enum **variants**, and impl fns alike (verified). This is why `error.rs`'s variants and methods are already alphabetized. Insert new items **in place**; never append. + - **`duration_suboptimal_units`** (pedantic; CI runs `-D warnings`, so it *fails the build*) → use the largest readable unit: `Duration::from_hours(168)` not `from_secs(7*24*60*60)`; `Duration::from_mins(1)` not `from_secs(60)`. + - **Verified end-to-end:** this exact `Deadline` code + these constants compile **clean** under the repo's full lint table (`restriction = deny` + `pedantic` + the real allow-list). `std_instead_of_core`/`std_instead_of_alloc` **are** allow-listed, so `use std::time::Duration;` is fine. + - **In TESTS**, the root `clippy.toml` sets `allow-expect-in-tests = true`, `allow-unwrap-in-tests = true`, `allow-panic-in-tests = true`, `allow-indexing-slicing-in-tests = true` — so `.expect(..)` in tests is fine. **`arithmetic_side_effects` is NOT test-exempt**, which is why the tests below use `checked_add(..).expect("no overflow")` rather than `base + dur`. + +--- + +### Task 0: Confirm the `web-time` dependency + +**Files:** Inspect `crates/edgezero-core/Cargo.toml` + +- [ ] **Step 1: Check whether `web-time` is already a dependency** + +Run: `rg -n 'web-time|web_time' crates/edgezero-core/Cargo.toml` +Expected: a line like `web-time = { workspace = true }`. + +- [ ] **Step 2: If absent, STOP and update/re-review this plan** + +Do not add the dependency as part of this task. The plan's locked premise is that +`web-time` already exists in both the workspace and `edgezero-core`; silently adding it +would contradict the no-new-dependency goal and turn Task 0 from verification into +implementation. + +- [ ] **Step 3: Verify it compiles** — Run: `cargo check -p edgezero-core` — Expected: `Finished`. + +--- + +### Task 1: typed `EdgeError::BadGateway` (502) + `GatewayTimeout` (504) + +**Files:** +- Modify: `crates/edgezero-core/src/error.rs` (enum + constructors + **9 exhaustive matches: 5 impl + 4 test panic-arms**) +- Test: `crates/edgezero-core/src/error.rs` (colocated `#[cfg(test)]`) + +**Interfaces:** +- Produces `bad_gateway(msg)` with `reason: BadGatewayReason::Unspecified`, + `bad_gateway_with_reason(msg, reason)`, `gateway_timeout(msg)` with + `cause: BudgetSource::Unspecified`, and `gateway_timeout_caused(msg, cause)`. + `BadGatewayReason::{Decode(BadGatewayDecodeReason), Protocol, Transport, Unreachable, + Unspecified}`, `BadGatewayDecodeReason::{Brotli, Gzip, Json}`, and + `BudgetSource::{BatchDeadline, Default, PerCallTimeout, Unspecified}` are + `#[non_exhaustive]`, `Clone + Copy + Debug + Eq`. Consumers inspect fields by matching the + variant. The JSON envelope remains `{ "error": { "status", "kind", "message" } }` with + no `field_path`, `reason`, or `cause`. +- `BudgetSource::Unspecified` means no EdgeZero budget source was proven. It covers both + timeout APIs outside a dispatch budget and an independently controlled provider timeout + observed before the selected absolute dispatch deadline expired. A provider phase timer + explicitly configured from the selected budget may retain that budget's source even when + it intentionally fires before the total deadline; an unconfigured DNS/write/host timeout + may not. Adapters must not stamp a configured source onto an early provider event merely + because its SDK error name contains `Timeout`; the outbound adapter phases own the exact + per-variant classification rule. + +- [ ] **Step 1: Write the failing tests (surface, typed fields, and wire isolation)** + +The existing `#[cfg(test)] mod tests` already imports `StatusCode`, `CONTENT_TYPE`, `HeaderValue`, `str` and does `use super::*;`, and has a `parse_body(response) -> serde_json::Value` helper (`tests::parse_body`). Add — **no new imports** (re-importing under `-D warnings` fails): + +This code is **pre-wrapped to rustfmt's canonical form at the final nesting depth** (inside `mod tests` → `fn` → `for`). Written as one-liners, the array-of-tuples rows and the message-bearing `assert!` exceed `max_width = 100` once indented into the test module and rustfmt rewraps them — which would surface as a diff at the Task 3 `cargo fmt --all -- --check` gate. (Step 8's `cargo fmt` would rewrap them for you, but the plan shows the landed form.) + +```rust +#[test] +fn bad_gateway_and_gateway_timeout_surface() { + for (err, code, msg) in [ + ( + EdgeError::bad_gateway("upstream refused"), + StatusCode::BAD_GATEWAY, + "upstream refused", + ), + ( + EdgeError::gateway_timeout("deadline expired"), + StatusCode::GATEWAY_TIMEOUT, + "deadline expired", + ), + ] { + assert_eq!(err.status(), code); + assert_eq!(err.message(), msg); + assert!(err.inner().is_none()); + // Display must render the new variants (not just the pre-existing ones). Assert + // the message is present rather than pinning an exact format, so this survives a + // format tweak while still proving `Display` covers `BadGateway`/`GatewayTimeout`. + assert!(err.to_string().contains(msg)); + } +} + +#[test] +fn bad_gateway_and_gateway_timeout_json_shape() { + for (err, code, kind, msg) in [ + ( + EdgeError::bad_gateway("nope"), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::bad_gateway_with_reason("nope", BadGatewayReason::Protocol), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::bad_gateway_with_reason("nope", BadGatewayReason::Transport), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::bad_gateway_with_reason("nope", BadGatewayReason::Unreachable), + 502_u16, + "bad_gateway", + "bad gateway", + ), + ( + EdgeError::gateway_timeout("late"), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + // Every OTHER cause too — wire-isolation must hold for ALL FOUR BudgetSource + // values, so a conditional serializer cannot leak `cause` for any of them + // (BatchDeadline / Default / PerCallTimeout / Unspecified are all covered). + ( + EdgeError::gateway_timeout_caused("late", BudgetSource::PerCallTimeout), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + ( + EdgeError::gateway_timeout_caused("late", BudgetSource::BatchDeadline), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + ( + EdgeError::gateway_timeout_caused("late", BudgetSource::Default), + 504_u16, + "gateway_timeout", + "gateway timeout", + ), + ] { + let response = err.into_response().expect("response"); + assert_eq!(response.status().as_u16(), code); + let body_json = parse_body(response); // existing helper -> serde_json::Value + assert_eq!(body_json["error"]["status"], code); + assert_eq!(body_json["error"]["kind"], serde_json::Value::from(kind)); + assert_eq!(body_json["error"]["message"], serde_json::Value::from(msg)); + assert!( + body_json["error"].get("field_path").is_none(), + "502/504 carry no field_path" + ); + // Typed classification is Rust-side only; the JSON must not leak either field. + assert!( + body_json["error"].get("reason").is_none(), + "reason is not part of the wire shape" + ); + assert!( + body_json["error"].get("cause").is_none(), + "cause is not part of the wire shape" + ); + } +} + +#[test] +fn bad_gateway_decode_reason_is_not_serialized() { + for reason in [ + BadGatewayDecodeReason::Brotli, + BadGatewayDecodeReason::Gzip, + BadGatewayDecodeReason::Json, + ] { + let err = EdgeError::bad_gateway_with_reason( + "nope", + BadGatewayReason::Decode(reason), + ); + let response = err.into_response().expect("response"); + let body_json = parse_body(response); + assert_eq!(body_json["error"]["status"], 502_u16); + assert_eq!(body_json["error"]["kind"], "bad_gateway"); + assert_eq!(body_json["error"]["message"], "bad gateway"); + assert!(body_json["error"].get("reason").is_none()); + } +} + +#[test] +fn bad_gateway_reason_is_typed() { + let EdgeError::BadGateway { reason, .. } = EdgeError::bad_gateway("x") else { + panic!("expected BadGateway"); + }; + assert_eq!(reason, BadGatewayReason::Unspecified); + + for expected in [ + BadGatewayReason::Decode(BadGatewayDecodeReason::Brotli), + BadGatewayReason::Decode(BadGatewayDecodeReason::Gzip), + BadGatewayReason::Decode(BadGatewayDecodeReason::Json), + BadGatewayReason::Protocol, + BadGatewayReason::Transport, + BadGatewayReason::Unreachable, + BadGatewayReason::Unspecified, + ] { + let EdgeError::BadGateway { reason, .. } = + EdgeError::bad_gateway_with_reason("x", expected) + else { + panic!("expected BadGateway"); + }; + assert_eq!(reason, expected); + } +} + +// The typed timeout-attribution contract (§3.3.2 / §3.4.3). WITHOUT these, a mis-wired +// constructor that dropped the cause, or that always stored the wrong variant, would pass +// the suite. These are the exact assertions compile-verified in the errsurface scaffold. +#[test] +fn bare_gateway_timeout_is_unspecified() { + // `let-else`, NOT `match … { other => panic! }`: a catch-all arm over the + // multi-variant `EdgeError` trips the denied `clippy::wildcard_enum_match_arm` + // (compile-verified against the real enum shape under edition 2024). `let-else` + // has no wildcard arm. + let EdgeError::GatewayTimeout { cause, .. } = EdgeError::gateway_timeout("x") else { + panic!("expected GatewayTimeout"); + }; + assert_eq!(cause, BudgetSource::Unspecified); +} +#[test] +fn gateway_timeout_caused_preserves_cause() { + // Loop var is `expected` (NOT a single char — `min_ident_chars`). **Includes + // `Unspecified`** so an impl that special-cased the bare constructor's cause is + // caught. `let-else` again (no `wildcard_enum_match_arm`). rustfmt-canonical + // (edition 2024) wrapped form, verified. + for expected in [ + BudgetSource::BatchDeadline, + BudgetSource::Default, + BudgetSource::PerCallTimeout, + BudgetSource::Unspecified, + ] { + let EdgeError::GatewayTimeout { cause, .. } = + EdgeError::gateway_timeout_caused("x", expected) + else { + panic!("expected GatewayTimeout"); + }; + assert_eq!(cause, expected); + } +} +``` + +In the **same red test edit**, extend all three existing per-variant matrices. These rows +reference the not-yet-created constructors, just like the focused tests above, so they belong +in the same compile-red state rather than being deferred until after implementation. The +following are three labeled insertion fragments for their named existing test functions, +not one contiguous block to paste at a single location: + +```rust + // `kind_strings_per_variant` + assert_kind!(EdgeError::bad_gateway("x"), "bad_gateway", 502_u16); + assert_kind!(EdgeError::gateway_timeout("x"), "gateway_timeout", 504_u16); + + // `retry_after_only_on_config_out_of_date` + assert_retry_after!(EdgeError::bad_gateway("x"), false); + assert_retry_after!(EdgeError::gateway_timeout("x"), false); + + // `field_path_only_on_config_out_of_date` (there is no helper macro here) + for err in [ + EdgeError::bad_gateway("x"), + EdgeError::gateway_timeout("x"), + ] { + let body = parse_body(err.into_response().expect("response")); + assert!( + body["error"].get("field_path").is_none(), + "field_path should be absent for gateway errors" + ); + } +``` + +Only `kind_strings_per_variant` is exhaustive today; the other two are subset checks. Add +the rows to all three anyway so 502/504 are pinned for kind/status, absence of +`Retry-After`, and absence of `field_path` from the first red run onward. + +- [ ] **Step 2: Run to verify it fails** — Run both focused commands: + `cargo test -p edgezero-core bad_gateway` (matches the two shared surface/wire tests plus + `bad_gateway_reason_is_typed`) and `cargo test -p edgezero-core gateway_timeout` (matches + the two shared tests plus both timeout-attribution tests). Expected: FAIL to compile + (`no variant or associated item named bad_gateway`/`gateway_timeout`). A single + `gateway_timeout` filter does **not** match `bad_gateway_reason_is_typed` and therefore + cannot prove the new 502 classification went red. + +- [ ] **Step 3: Add the two variants** in `pub enum EdgeError` — **ALPHABETICALLY, not appended.** + +`clippy::arbitrary_source_item_ordering` is a denied restriction lint and it **does police enum-variant order** (verified: appending `BadGateway` after `Validation` errors with *"incorrect ordering of items (must be alphabetically ordered)"*). The existing variants are already alphabetical (`BadRequest, ConfigOutOfDate, Internal, MethodNotAllowed, NotFound, NotImplemented, ServiceUnavailable, Validation`), so insert **in place**: +- `BadGateway` goes **before** `BadRequest` (BadG < BadR), +- `GatewayTimeout` goes **between** `ConfigOutOfDate` and `Internal` (C < G < I). + +Resulting order: `BadGateway, BadRequest, ConfigOutOfDate, GatewayTimeout, Internal, MethodNotAllowed, NotFound, NotImplemented, ServiceUnavailable, Validation`. + +```rust + /// Upstream or transport failure (DNS, TLS, connect, unreachable, or a + /// non-timeout send failure). HTTP 502. + #[error("{message}")] + BadGateway { + message: String, + reason: BadGatewayReason, + }, + /// A wall-clock deadline or per-request timeout fired. HTTP 504. + /// Carries typed provenance naming which configured budget input selected the + /// effective deadline. This is not the physical timer phase, proof that the named + /// deadline elapsed, or a retry/batch-abandonment decision. + #[error("{message}")] + GatewayTimeout { message: String, cause: BudgetSource }, +``` + +`BadGatewayDecodeReason`, `BadGatewayReason`, and `BudgetSource` are defined **in `error.rs` +in THIS task (Task 1)**, NOT in `time.rs` (Task 2). That ordering is load-bearing: Task 1 +lands/commits/builds **before** Task 2, and the two variants name these enums, so a +`time`-module home would make the standalone Task-1 commit fail to compile. Define all three +immediately **before `EdgeError`** in alphabetical item order (`BadGatewayDecodeReason`, +`BadGatewayReason`, `BudgetSource`, `EdgeError`); +`time.rs` (Task 2) and `dispatch_budget` (Phase 1b) later +`use crate::error::BudgetSource;`. + +The **derives and variant order are compile-verified** (a throwaway crate under the repo's +`arbitrary_source_item_ordering` deny + `cargo check`): +```rust +// Debug: EdgeError derives Debug and contains both reason enums. +// Clone + Copy: error carriers pass classification/provenance by value. +// PartialEq + Eq: contract tests assert exact values. +// Variants ALPHABETICAL: `arbitrary_source_item_ordering` (denied) rejects any other order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BadGatewayDecodeReason { + Brotli, + Gzip, + Json, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BadGatewayReason { + Decode(BadGatewayDecodeReason), + Protocol, + Transport, + Unreachable, + Unspecified, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +// non_exhaustive: public enum that must be able to gain a future budget-input source +// without a breaking change. Verified: intra-crate exhaustive matches +// still compile clean under the denied `wildcard_enum_match_arm`. +#[non_exhaustive] +pub enum BudgetSource { + BatchDeadline, + Default, + PerCallTimeout, + Unspecified, +} +``` + +- [ ] **Step 4: Add the constructors** in `impl EdgeError` — **also alphabetically** (the impl's fns are already ordered `bad_request, config_out_of_date, inner, internal, kind_str, message, status, validation`): put `bad_gateway` **before** `bad_request`, and `gateway_timeout` **between** `config_out_of_date` and `inner`. + +```rust + #[inline] + pub fn bad_gateway>(message: S) -> Self { + EdgeError::BadGateway { + message: message.into(), + reason: BadGatewayReason::Unspecified, + } + } + #[inline] + pub fn bad_gateway_with_reason>( + message: S, + reason: BadGatewayReason, + ) -> Self { + EdgeError::BadGateway { + message: message.into(), + reason, + } + } + #[inline] + pub fn gateway_timeout>(message: S) -> Self { + EdgeError::GatewayTimeout { + message: message.into(), + cause: BudgetSource::Unspecified, + } + } + #[inline] + pub fn gateway_timeout_caused>(message: S, cause: BudgetSource) -> Self { + EdgeError::GatewayTimeout { + message: message.into(), + cause, + } + } +``` + +(All four literals are shown in rustfmt's canonical split form, verified with +`rustfmt --edition 2024`.) + +- [ ] **Step 5: Update ALL nine exhaustive matches (crate won't compile until every one is done)** + +`impl` sites: +- `kind_str()` — add `EdgeError::BadGateway { .. } => "bad_gateway",` and `EdgeError::GatewayTimeout { .. } => "gateway_timeout",` +- `status()` — add `EdgeError::BadGateway { .. } => StatusCode::BAD_GATEWAY,` and `EdgeError::GatewayTimeout { .. } => StatusCode::GATEWAY_TIMEOUT,` +- `message()` — add **`EdgeError::BadGateway { message, .. }`** and + **`EdgeError::GatewayTimeout { message, .. }`** to the "clone the `message`" arm. The + `..` is required because both variants carry a second typed field. +- `inner()` — add both variants to the `=> None` arm list. +- `IntoResponse::into_response`'s `field_path_opt` match — add both variants to the `=> None` arm list. + +**Test-module sites (these have explicit panic-arms listing every non-`ConfigOutOfDate` variant, NO `_`):** in each of the **four** `match err { … }` blocks (the fourth is the root-error sentinel test; Step 6's compiler-driven `E0004` sweep is the source of truth for their exact locations), add `| EdgeError::BadGateway { .. } | EdgeError::GatewayTimeout { .. }` to the `=> panic!("expected ConfigOutOfDate")` arm. + +- [ ] **Step 6: Compiler-driven catch — build and fix any remaining non-exhaustive match** + +Run: `cargo build -p edgezero-core --tests` +If it reports `E0004 non-exhaustive patterns` anywhere, add the two arms at that exact site (the compiler prints the file:line). Repeat until it builds. Expected end state: builds clean. + +- [ ] **Step 7: Run the new + matrix tests to verify they pass** + +Run: `cargo test -p edgezero-core bad_gateway`, then +`cargo test -p edgezero-core gateway_timeout` (the **same two filters as red Step 2**), then +`cargo test -p edgezero-core kind_strings_per_variant`, then +`cargo test -p edgezero-core only_on_config_out_of_date` (one filter matches both the +retry_after_* and field_path_* matrices). +Expected: PASS. + +- [ ] **Step 8: Format, lint, full-crate test** + +Run: `cargo fmt -p edgezero-core && cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings && cargo test -p edgezero-core` +Expected: clean, all green. + +- [ ] **Step 9: Commit** + +```bash +git add crates/edgezero-core/src/error.rs +git commit -m "feat(core): add typed gateway errors" +``` + +--- + +### Task 2: `time` module — constants + `Deadline` + +**Files:** +- Create: `crates/edgezero-core/src/time.rs` +- Modify: `crates/edgezero-core/src/lib.rs` (add `pub mod time;`) +- Test: `crates/edgezero-core/src/time.rs` (colocated) + +**Interfaces:** +- Produces (for Phase 1b `dispatch_budget` + all adapters): public + `type MonotonicInstant = web_time::Instant`, injectable `MonotonicClock` with + `deadline_after(Duration)`, and `Deadline` (`Copy`), + `Deadline::after(Duration) -> Self`, `::at_instant(MonotonicInstant) -> Self`, + `::instant(&self) -> MonotonicInstant`, `::remaining(&self) -> Option`, + `::is_expired(&self) -> bool`; consts `DEFAULT_NO_DEADLINE_BUDGET` (30 s), + `DEADLINE_FAR_FUTURE` (7 days), `BATCH_DISPATCH_SLACK_MAX` (25 ms). + **`DispatchBudget` ships in Phase 1b with `dispatch_budget`.** Downstream crates can name, + construct, and compare public timing values without declaring a direct `web-time` + dependency. + +**Deadline semantics (matches spec §3.3.2 `deadline <= now => expired`):** a deadline whose instant is **exactly now** is **expired** — `is_expired()` is `true` and `remaining()` is `None` at equality, not `Some(0)`. A naive `checked_duration_since(now).is_none()` gets this wrong (it returns `Some(ZERO)` at equality), so the impl below uses `checked_duration_since(..).filter(|r| !r.is_zero())` — the zero case is filtered explicitly. + +- [ ] **Step 1: Write the failing tests (deterministic — bounded by explicit instants, no wall-clock tolerance windows)** + +Create `crates/edgezero-core/src/time.rs` with only the test module + `use`: + +```rust +use std::time::Duration; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn monotonic_instant_is_public_clock_type() { + let start = MonotonicInstant::now(); + let deadline = Deadline::at_instant(start); + let _: MonotonicInstant = deadline.instant(); + } + + // The public API promises `Deadline: Copy` (adapters copy it into per-slot budgets + // rather than borrowing). Pin it at COMPILE time — a later `#[derive]` edit that + // drops `Copy` must fail the build, not silently change the API. + #[test] + fn deadline_is_copy() { + fn assert_copy() {} + assert_copy::(); + } + + #[test] + fn constants_have_exact_values() { + assert_eq!(DEFAULT_NO_DEADLINE_BUDGET, Duration::from_secs(30)); + assert_eq!(DEADLINE_FAR_FUTURE, Duration::from_hours(168)); + assert_eq!(BATCH_DISPATCH_SLACK_MAX, Duration::from_millis(25)); + } + + // EXACT + deterministic: every assertion pins BOTH the deadline instant and the + // `now` it is compared against, via the pure `*_at(now)` helpers. No wall-clock + // tolerance windows, no assumption about how fast the test resumes. + + #[test] + fn deadline_before_now_is_expired() { + let base = MonotonicInstant::now(); + let past = Deadline::at_instant(base); + let now = base + .checked_add(Duration::from_secs(1)) + .expect("no overflow"); + assert!(past.is_expired_at(now)); + assert_eq!(past.remaining_at(now), None); + } + + #[test] + fn deadline_exactly_now_is_expired() { + // The equality boundary: deadline instant == now. `deadline <= now` is + // expired, but `checked_duration_since` returns Some(ZERO) here, so a naive + // impl would wrongly report NOT expired. + let base = MonotonicInstant::now(); + let at_now = Deadline::at_instant(base); + assert_eq!( + at_now.remaining_at(base), + None, + "zero remaining is expired, not Some(0)" + ); + assert!( + at_now.is_expired_at(base), + "a deadline exactly at now is expired" + ); + } + + #[test] + fn deadline_in_future_has_exact_remaining() { + let base = MonotonicInstant::now(); + let future = Deadline::at_instant( + base.checked_add(Duration::from_mins(1)) + .expect("no overflow"), + ); + assert!(!future.is_expired_at(base)); + // EXACT equality — both instants are explicit, so there is no elapsed-time slop. + assert_eq!(future.remaining_at(base), Some(Duration::from_mins(1))); + } + + #[test] + fn after_clamps_duration_max_to_far_future() { + // Prove the 7-DAY CLAMP via bounds on the resulting INSTANT (no second + // now()-snapshot to race against). + let before = MonotonicInstant::now(); + let deadline = Deadline::after(Duration::MAX); + let after = MonotonicInstant::now(); + // `after()` computed `t0 + FAR_FUTURE` for some t0 in [before, after], + // so the instant must land within [before+FAR_FUTURE, after+FAR_FUTURE]. + let lower = before + .checked_add(DEADLINE_FAR_FUTURE) + .expect("no overflow"); + let upper = after.checked_add(DEADLINE_FAR_FUTURE).expect("no overflow"); + assert!(deadline.instant() >= lower, "clamped below the 7-day bound"); + assert!( + deadline.instant() <= upper, + "Duration::MAX was NOT clamped to 7 days" + ); + } + + // Public smoke tests: the live-clock wrappers (which call MonotonicInstant::now()) actually + // delegate to the pure *_at helpers. The *_at tests above cover exact arithmetic; + // these guard the public surface AND that `after` honours its duration argument. + #[test] + fn public_remaining_and_is_expired_smoke() { + // Tight instant-bracket around `after`: the resulting deadline MUST land in + // [before + 1h, after + 1h]. Any mutation that perturbs the duration — dropping + // 30s, or clamping to DEADLINE_FAR_FUTURE = 7 days — falls outside the bracket. + // (A loose "remaining is (59 min, 1 h]" check would survive a 30s-off mutant.) + let before = MonotonicInstant::now(); + let far = Deadline::after(Duration::from_hours(1)); + let after = MonotonicInstant::now(); + assert!(!far.is_expired()); + // rustfmt-canonical SPLIT form (verified with `rustfmt --edition 2024`): the + // `.checked_add(..).expect(..)` chain exceeds `chain_width = 60`, so rustfmt + // breaks it across lines — a one-liner here would fail `cargo fmt --check`. + let lo = before + .checked_add(Duration::from_hours(1)) + .expect("no overflow"); + let hi = after + .checked_add(Duration::from_hours(1)) + .expect("no overflow"); + assert!( + far.instant() >= lo && far.instant() <= hi, + "after() must land exactly `now + duration`" + ); + assert!(far.remaining().is_some()); + // `after(ZERO)` yields `now`; by the time is_expired()/remaining() read a later + // MonotonicInstant::now(), it is at-or-past — no `checked_sub` (would underflow a fresh + // WASM Instant near its epoch). + let now_deadline = Deadline::after(Duration::ZERO); + assert!(now_deadline.is_expired()); + assert_eq!(now_deadline.remaining(), None); + } + + #[test] + fn instant_round_trips() { + let base = MonotonicInstant::now() + .checked_add(Duration::from_secs(10)) + .expect("no overflow"); + assert_eq!(Deadline::at_instant(base).instant(), base); + } +} +``` + +> The test snippet above is **pre-wrapped to rustfmt's canonical form** — verified by running `rustfmt --edition 2024` on it, not by eyeballing width. The `.checked_add(..).expect(..)` chains split because they exceed **`chain_width = 60`** (NOT `max_width = 100` — an earlier note wrongly cited the 100 limit; the chain heuristic is the binding one, which is why `lo`/`hi` are multi-line here even though they'd fit on one line width-wise). Copy it verbatim and the Task 3 `cargo fmt --all -- --check` gate stays a no-op. + +- [ ] **Step 2: Wire the module in and run to verify failure** + +Add `pub mod time;` to `crates/edgezero-core/src/lib.rs` (alphabetical position among the `pub mod` lines). +Run: `cargo test -p edgezero-core --lib time::` +Expected: FAIL to compile (`cannot find value DEFAULT_NO_DEADLINE_BUDGET`, `cannot find type Deadline`). + +- [ ] **Step 3: Implement constants + `Deadline`** + +Insert the implementation between the existing `use std::time::Duration;` and +`#[cfg(test)]`. Define the public alias in this module; do not import `web_time::Instant` +under its dependency-owned name: + +> **This code is written to pass the repo's strict Clippy gate.** The workspace sets +> `restriction = { level = "deny", priority = -1 }` (root `Cargo.toml`), and **none** of +> `missing_inline_in_public_items`, `min_ident_chars`, `arithmetic_side_effects`, +> `expect_used`, or `as_conversions` is allow-listed. Therefore: every public method +> carries **`#[inline]`** (matching the 14 existing `#[inline]`s in `error.rs`); no +> single-char idents (`d` → `duration`); and **no bare `-`/`+`** — all arithmetic is +> `checked_*`. The private `*_at(now)` helpers additionally make the logic **pure and +> deterministically testable** (no hidden `MonotonicInstant::now()` inside the assertion). + +```rust + +/// Max adapter overhead tolerated before a fan-out slot fails closed. +pub const BATCH_DISPATCH_SLACK_MAX: Duration = Duration::from_millis(25); +/// Hard clamp on any caller-supplied duration, so construction cannot panic. +pub const DEADLINE_FAR_FUTURE: Duration = Duration::from_hours(168); +/// Budget applied when a request sets neither a timeout nor a deadline. +pub const DEFAULT_NO_DEADLINE_BUDGET: Duration = Duration::from_secs(30); + +/// An absolute, copyable monotonic deadline. A deadline at or before now is expired. +#[derive(Debug, Clone, Copy)] +pub struct Deadline(MonotonicInstant); + +/// Portable monotonic clock instant used by EdgeZero timing APIs. +pub type MonotonicInstant = web_time::Instant; + +impl Deadline { + /// Returns a deadline `now + min(duration, DEADLINE_FAR_FUTURE)`; never panics. + #[inline] + #[must_use] + pub fn after(duration: Duration) -> Self { + let now = MonotonicInstant::now(); + let clamped = duration.min(DEADLINE_FAR_FUTURE); + Deadline(now.checked_add(clamped).unwrap_or(now)) + } + + /// Constructs a deadline from an absolute instant. + #[inline] + #[must_use] + pub fn at_instant(instant: MonotonicInstant) -> Self { + Deadline(instant) + } + + /// Returns the absolute deadline instant. + #[inline] + #[must_use] + pub fn instant(&self) -> MonotonicInstant { + self.0 + } + + /// Returns `true` once the deadline instant is at or before now. + #[inline] + #[must_use] + pub fn is_expired(&self) -> bool { + self.is_expired_at(MonotonicInstant::now()) + } + + fn is_expired_at(&self, now: MonotonicInstant) -> bool { + self.remaining_at(now).is_none() + } + + /// Returns the remaining time, or `None` once the deadline is reached or passed. + #[inline] + #[must_use] + pub fn remaining(&self) -> Option { + self.remaining_at(MonotonicInstant::now()) + } + + fn remaining_at(&self, now: MonotonicInstant) -> Option { + self.0 + .checked_duration_since(now) + .filter(|remaining| !remaining.is_zero()) + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** — Run: `cargo test -p edgezero-core --lib time::` — Expected: PASS (all eight). + +- [ ] **Step 5: Format, lint, full-crate test** + +Run: `cargo fmt -p edgezero-core && cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings && cargo test -p edgezero-core` +Expected: clean, all green. + +- [ ] **Step 6: Commit** + +```bash +git add crates/edgezero-core/src/time.rs crates/edgezero-core/src/lib.rs +git commit -m "feat(core): add time module (Deadline + budget constants)" +``` + +--- + +### Task 3: Core CI-gate verification (the five CLAUDE.md gates) + +**Files:** none (verification only). Run from the repo root. + +**Scope:** Phase 1a is **additive, core-only** (new `EdgeError` variants and typed +reason/provenance enums plus a new `time` module; no adapter, CLI, template, or `app-demo` +change), so this task runs the **five CLAUDE.md gates** over the workspace **plus one +core-only wasm32-unknown-unknown check** (Step 5). It deliberately does **not** run the +generated-project build, the `examples/app-demo` build, or expanded per-adapter WASM +test/clippy matrices beyond the required Spin check. **This is a risk-reduced local subset, NOT a proof those are +unaffected:** every one of them compiles `edgezero-core`, so a core change *could* in +principle break them (an unexpected new export collision, a feature-gate interaction). The +judgement is that this purely additive core surface is very unlikely to, and **full CI runs +all of them on the PR regardless** — so Task 3 is a fast local gate, and CI is the actual +backstop. If local certainty is required, run the full workspace + `examples/app-demo` +builds too; otherwise rely on CI. Phases that touch adapters/templates/app-demo add those +locally. The one WASM target that is *not* redundant here is +`wasm32-unknown-unknown`: `web-time::Instant` resolves to its JS +`Date`/`performance.now()` path there, whereas the Spin `wasm32-wasip2` gate uses the WASI +clock — so the new `time` module's `web-time` dependency must be compiled on that target +too. + +- [ ] **Step 1: Format check + workspace test** + +Run: `cargo fmt --all -- --check && cargo test --workspace --all-targets` +Expected: no diff; all green. (Confirms the additive changes broke no crate.) + +- [ ] **Step 2: Clippy (all targets, all features)** + +Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings` +Expected: clean. + +- [ ] **Step 3: Feature-combo check** + +Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +Expected: `Finished`. + +- [ ] **Step 4: Spin wasm target check** + +Run: `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +Expected: `Finished`. + +- [ ] **Step 5: Core wasm32-unknown-unknown check (web-time JS path)** + +Run: `cargo check -p edgezero-core --target wasm32-unknown-unknown` +Expected: `Finished`. (Compiles the new `time` module against `web-time`'s browser/JS clock backend — the Cloudflare target — which the Spin `wasip2` gate does not exercise. Core-only, so no adapter is pulled in.) + +(Steps 1–4 collectively execute all five repo CI commands from `CLAUDE.md`; Step 5 adds the `wasm32-unknown-unknown` core check. Do not skip the wasm targets — they are the ones most likely to catch an accidental `std::time` / non-WASM import.) + +--- + +## Self-Review + +- **Spec coverage:** Task 1 = §3.4.3/§7 `error.rs` (both variants, both typed enums, + constructors, full match surface, Rust-side classification, and JSON isolation); Task 2 = + §§3.3.1/3.3.4 (`Deadline` and all three constants). `DispatchBudget` + + `dispatch_budget()` (§3.3.2) are deferred **together** to Phase 1b — a stated sequencing + boundary, not a gap. +- **Compile-safety (the class of bug a prior review caught):** the nine exhaustive matches (5 impl + 4 test panic-arms) are enumerated *and* backed by a compiler-driven catch step; focused tests and all six matrix rows enter the same compile-red edit; the `cargo test` single-filter rule is applied; `is_expired_at` treats a **zero** remaining as expired (`remaining_at` filters out a zero `Duration`), so a deadline exactly at now reads as expired. +- **No placeholders / no flaky tests:** every step has exact code, paths, single-filter commands, expected output; timing tests are bounded by explicit `at_instant` instants (no `now() - 1s` underflow, no wide tolerance windows), and the clamp test proves the 7-day bound. + +## Next (not this plan; each is its own plan, NOT one atomic step) + +Phase 1b must respect the producer's type dependency: `dispatch_budget(&OutboundRequest, ..)` +cannot land before `OutboundRequest` and its private `budget_inputs()` accessor exist. The +next plan must add the pinned direct workspace/`edgezero-core` `url` dependency and either +(1) land `OutboundRequest`/`ResponseMode`, one-time canonical URI construction, +resource-limit builders, `validate_for_dispatch`, `BudgetInputs`, `DispatchBudget`, and +`dispatch_budget` in one buildable slice, or (2) land the request type/accessor in an +earlier buildable slice and the budget carrier/producer immediately after it. +`OutboundResponse`, the typed `ResponseLimitReason` work, the `Body::Stream` error change, +and the `proxy → outbound` rename can then be sequenced around the four-adapter atomic +migration, but no slice may name a type that does not yet exist. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase0-probe-bootstrap.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase0-probe-bootstrap.md new file mode 100644 index 000000000..d8d5c7b3a --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase0-probe-bootstrap.md @@ -0,0 +1,159 @@ +# Outbound HTTP Phase 0: Protected Probe Bootstrap Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement +> this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Put inert exact-SHA Cloudflare and Fastly probe dispatchers on the default branch +and provision their disposable protected environments before any implementation phase needs +host evidence. + +**Architecture:** GitHub delivers `workflow_dispatch` only for workflow files already on the +default branch. Two manually dispatched workflows validate a reviewed SHA without secrets, +then run only that SHA's probe driver in a protected disposable environment. They have no +automatic trigger and publish no outbound API or capability behavior. + +**Tech Stack:** GitHub Actions, actionlint 1.7.7, `gh`, disposable Cloudflare/Fastly accounts, +and one authenticated observable probe origin. + +--- + +## Preconditions + +- [ ] The outbound design and implementation index are approved. +- [ ] A repository administrator can create protected environments, environment secrets, + required-reviewer rules, and the fixed `refs/heads/outbound-probe-reviewed` branch. +- [ ] No Phase 4 or Phase 6 capability publication has started. This bootstrap is the sole + intermediate merge allowed by the implementation index. + +## Task Protocol + +For each task, make the stated change, run every exact local command, inspect the workflow +permissions and secret boundaries, and commit only the listed files. A skipped job, absent +secret, missing driver, zero probe count, or mismatched SHA is never success. + +### Task 1: Add inert default-branch dispatchers + +**Files:** +- Modify: `.tool-versions` +- Create: `.github/workflows/outbound-cloudflare-deployed.yml` +- Create: `.github/workflows/outbound-fastly-characterization.yml` + +- [ ] Pin actionlint 1.7.7 in `.tool-versions`. Require + `test "$(actionlint -version | head -n 1)" = "1.7.7"` before validation. +- [ ] Give each workflow only `workflow_dispatch` with one required lowercase 40-hex + `commit_sha` string input and top-level `permissions: contents: read`. Do not add + `pull_request_target`, `pull_request`, `push`, `schedule`, reusable-workflow inputs, or a + user-selectable ref/command/path. Put the exact input SHA in the workflow `run-name` and + protected job name so an environment reviewer can compare it with the reviewed ref before + approving secret access. +- [ ] Pin every third-party action to a reviewed full commit SHA and annotate its release name; + floating major-version tags are forbidden in these secret-bearing workflows. Give both jobs + finite `timeout-minutes`, disable checkout credential persistence, and do not cache any path + that candidate code can write across protected runs. +- [ ] Give each provider workflow a fixed repository-wide `concurrency` group with + `cancel-in-progress: false`. Fastly runs must serialize because they deploy into the same two + disposable services; a queued run may not cancel an active run before its cleanup trap + restores prior service state. +- [ ] Add an unprivileged `validate` job with no environment and no secret references. It + fetches only `refs/heads/outbound-probe-reviewed`, rejects a non-lowercase/full SHA, + requires that commit to be reachable from the fetched ref, checks out the exact object, + and requires `git rev-parse HEAD` to equal the input byte-for-byte. Export only that + validated SHA as a job output. +- [ ] Add a `probe` job that `needs: validate`, binds only its named protected environment, + checks out the validated SHA, and repeats the byte-for-byte HEAD check. Dependency setup, + metadata checks, and fixture builds receive no secrets. Reference disposable secrets only + in the final driver step's `env` block. +- [ ] The Cloudflare driver is exactly + `npm --prefix crates/edgezero-adapter-cloudflare run test:deployed-timing` and receives only + `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_WORKERS_SUBDOMAIN`, + `OUTBOUND_PROBE_ORIGIN_URL`, and `OUTBOUND_PROBE_ORIGIN_TOKEN`. The Fastly driver is exactly + `bash crates/edgezero-adapter-fastly/tests/host/deployed.sh` and receives only + `FASTLY_DYNAMIC_SERVICE_ID`, `FASTLY_DYNAMIC_SERVICE_URL`, `FASTLY_DISABLED_SERVICE_ID`, + `FASTLY_DISABLED_SERVICE_URL`, `FASTLY_API_TOKEN`, `OUTBOUND_PROBE_ORIGIN_URL`, and + `OUTBOUND_PROBE_ORIGIN_TOKEN`. +- [ ] Before the Cloudflare secret-bearing driver step, install the exact Rust and Node + versions from `.tool-versions`, add `wasm32-unknown-unknown`, run + `npm ci --prefix crates/edgezero-adapter-cloudflare`, run the fixture's locked Cargo + metadata and Worker 0.8.5 tree assertions, install + `worker-build 0.8.3 --locked`, assert `worker-build --version` plus locked Wrangler's + version, assert fixture/template compatibility settings, and run + `npm --prefix crates/edgezero-adapter-cloudflare run build:outbound-fixture`. None of these + setup/build steps receives provider or origin secrets. +- [ ] Before the Fastly secret-bearing driver step, install the exact Rust and Fastly CLI + versions from `.tool-versions`, add `wasm32-wasip1`, require + `fastly version` to report 15.1.0, assert both locked dependency graphs select Fastly SDK + 0.12.1, run the standalone fixture's locked metadata/tree checks, and build it without + secrets using + `fastly compute build --non-interactive --dir crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly`. + Require the fixed + `tests/fixtures/outbound-fastly/pkg/edgezero-outbound-probe.tar.gz` artifact. The final + driver may deploy only that prebuilt package; it must not compile, download candidate + dependencies, or select a different artifact after secrets enter its environment. +- [ ] Run `actionlint .github/workflows/outbound-cloudflare-deployed.yml + .github/workflows/outbound-fastly-characterization.yml` and `git diff --check`. Then run + the following negative trigger audit; status 1 is success, a match or scan error is failure: + +```sh +if git grep -nE 'pull_request_target|pull_request:|push:|schedule:' -- \ + .github/workflows/outbound-cloudflare-deployed.yml \ + .github/workflows/outbound-fastly-characterization.yml; then + exit 1 +else + status=$? + test "$status" -eq 1 +fi +``` +- [ ] Commit: `ci: bootstrap protected outbound probes`. + +### Task 2: Provision disposable protected resources + +**Repository/environment state, no source files:** + +- [ ] Create `outbound-cloudflare-probe` and `outbound-fastly-probe` as protected + environments with required maintainers, no self-approval, no production secrets, and no + deployment branches except the default-branch workflow. Populate only the exact secrets + listed in Task 1. +- [ ] Create or verify `refs/heads/outbound-probe-reviewed` as a repository-owned protected + branch with deletion and force pushes disabled. A maintainer reproduces a reviewed tree on + this ref with a fast-forward-only push before requesting environment approval; workflows + reject any SHA not reachable from it. Never dispatch a fork SHA directly. The environment + reviewer compares the SHA shown in the run/job name with the reviewed commit before approval. +- [ ] Provision a disposable Workers account/subdomain and a least-privilege token limited to + Workers Scripts edit in that account. Provision two disposable Fastly services: one with + dynamic backends enabled and one with them disabled, plus a token limited to those services. +- [ ] Freeze the observable origin protocol: authenticated `POST /v1/probes/{run_id}/arm` + creates isolated state; requests use `/v1/probes/{run_id}/{case_id}` for raw bytes, delayed + headers/chunks, stalled upload reads, and cancellation; authenticated + `GET /v1/probes/{run_id}/observations` returns timestamped bytes/EOF/disconnect facts; + authenticated `DELETE /v1/probes/{run_id}` removes the state. Run IDs and tokens are + unguessable, state is isolated per run, and stale state has a bounded retention policy. +- [ ] Verify the origin arm/observe/delete control plane with a unique smoke run and delete + it. Verify both provider tokens can inspect only their disposable resources. Record the + environment settings and resource identifiers in the repository's private operational + record, never in committed logs or docs. + +### Task 3: Merge and prove dispatch availability + +- [ ] Open and review the bootstrap PR independently of the implementation branch. Confirm + its diff contains only `.tool-versions` and the two inert workflow files. +- [ ] Merge it to the default branch. Run + `gh workflow view outbound-cloudflare-deployed.yml --ref main` and + `gh workflow view outbound-fastly-characterization.yml --ref main`; both must identify the + merged workflow and `workflow_dispatch` trigger. +- [ ] Record the bootstrap merge SHA in the outbound implementation PR. Do not dispatch a + probe until its owning phase has added a reviewed nonzero driver; a missing driver must + fail rather than skip. + +## Phase Verification + +- [ ] `test "$(actionlint -version | head -n 1)" = "1.7.7"` +- [ ] `actionlint .github/workflows/outbound-cloudflare-deployed.yml .github/workflows/outbound-fastly-characterization.yml` +- [ ] Repeat Task 1's negative trigger audit and require exit 0 with no matches. +- [ ] `git diff --check` +- [ ] Both workflows are manually visible from the default branch. +- [ ] Both protected environments require reviewer approval and contain only disposable + resources/secrets. +- [ ] The observable origin smoke state was deleted. + +Expected result: later phases can dispatch immutable reviewed SHAs through protected +default-branch workflows without merging any partial outbound implementation. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase1b-core-types-budget.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase1b-core-types-budget.md new file mode 100644 index 000000000..06dcec3b9 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase1b-core-types-budget.md @@ -0,0 +1,269 @@ +# Outbound HTTP Phase 1b: Core Types and Budget Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Status:** Implemented on PR 275. The unchecked steps below are retained as the original implementation record, not current readiness state. +> **Superseded batch API:** The 2026-09-15 consumer-alignment plan replaces this plan's +> `send_all` surface and budget names. Do not use its batch snippets as current API guidance. +> **Superseded response API:** The Phase 2 Content-Type provenance hardening requires the +> application clock and original provenance in the six-argument response constructor, and +> returns provenance as the fifth `into_parts()` element. The response snippets below are +> historical; use the outbound design's current response contract. + +**Goal:** Add the runtime-independent outbound request, response, client, URL, and dispatch-budget value layer specified in §§3.1-3.3 of the outbound design. + +**Architecture:** Build the new `edgezero_core::outbound` module alongside the legacy `proxy` module so the repository compiles while adapters migrate in Phases 4-6. This coexistence is branch-only scaffolding, not a compatibility promise or a releasable state. Phase 7 deletes `proxy` and proves that no public alias remains. Core canonicalizes a URL once, owns all policy values, and exposes only provider-neutral data to adapters. + +**Tech Stack:** Rust 1.95, `http`, `bytes`, `serde_json`, `url = "=2.5.8"`, `web-time`, `async-trait`, `futures`. + +--- + +## Preconditions and Merge Rule + +- [ ] Confirm Phase 1a tests pass: `cargo test --offline --locked -p edgezero-core --lib`. +- [ ] Read spec §§3.1-3.3, §5.1, §6, and the core entries in §7. +- [ ] Do not publish or merge the series until Phase 7 removes `edgezero_core::proxy`, `ProxyService`, and every legacy public name. Do not add aliases between old and new names. +- [ ] Keep `BudgetInputs` private and limited to `deadline` and `timeout`; response size does not participate in budget selection. + +## Task Protocol + +For every task below: add only the named tests first; run the exact focused command and require a nonzero failure caused by the missing behavior; implement the smallest listed surface; rerun the same command to zero failures; run `cargo test --offline --locked -p edgezero-core --lib` and `git diff --check`; then stage only the task's files and make the stated commit. A compile error for a not-yet-added public item is an acceptable red result. An already-passing new test is not. The contract declarations below are in the repository's required alphabetical item order; keep every actual module item, struct field, enum variant, and impl method in that order, and add `#[inline]` to every public function as required by the denied workspace lints. + +**Required exact test names:** `outbound_request_defaults_and_parts_round_trip`, `outbound_request_canonicalizes_url_table`, `outbound_request_rejects_invalid_target_table`, `outbound_request_rejects_empty_userinfo`, `outbound_request_rejects_backslash_authority_forms`, `outbound_request_preserves_percent_encoded_hash`, `outbound_request_from_request_normalizes_immediately`, `dispatch_validation_precedence_table`, `request_normalization_strips_connection_nominations`, `request_normalization_is_idempotent`, `dispatch_budget_selects_and_attributes_minimum`, `dispatch_budget_rejects_expired_or_zero`, `http_client_delegates_send_and_send_all`, `http_client_preserves_per_slot_elapsed`, and `outbound_response_parts_preserve_method_headers_and_body`. + +**Expected red:** Task 2 initially fails to resolve `edgezero_core::outbound`; Task 3 initially observes nominated/hop-by-hop headers or accepts an invalid request; Task 4 fails to resolve `dispatch_budget`; Task 5 fails to resolve `HttpClient`/`OutboundResponse`. Dependency Task 1 is a lock/dependency precondition and is green when the exact tree assertion passes. + +### Task 1: Pin the canonical URL parser + +**Files:** +- Modify: `Cargo.toml` +- Modify: `crates/edgezero-core/Cargo.toml` +- Modify: `Cargo.lock` +- Modify: `examples/app-demo/Cargo.lock` + +- [ ] Add `url = "=2.5.8"` to workspace dependencies and `url = { workspace = true }` to core. +- [ ] Refresh both independent lock graphs with `cargo check --offline -p edgezero-core` and `cargo check --offline --manifest-path examples/app-demo/Cargo.toml -p app-demo-core`; these commands are intentionally unlocked in this dependency task. +- [ ] Run `cargo tree --offline --locked -p edgezero-core -e normal | rg 'url v2\.5\.8'`; expect exactly the pinned normal dependency. +- [ ] Run `cargo tree --offline --locked --manifest-path examples/app-demo/Cargo.toml -p app-demo-core -e normal | rg 'url v2\.5\.8'`; expect the same exact version in the excluded demo graph. +- [ ] Commit: `build(core): pin outbound URL parser`. + +### Task 2: Add canonical outbound requests + +**Files:** +- Create: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-core/src/lib.rs` + +**Contract:** + +```rust +pub struct OutboundRequest { + body: Body, + deadline: Option, + headers: HeaderMap, + max_decoder_bytes: u64, + max_brotli_window_bits: u8, + max_chunk_bytes: Option, + max_decoded_response_bytes: Option, + max_encoded_response_bytes: Option, + max_request_body_bytes: u64, + max_response_header_bytes: Option, + max_response_header_count: Option, + method: Method, + response_mode: ResponseMode, + timeout: Option, + uri: Uri, +} + +pub struct OutboundRequestParts { + pub body: Body, + pub deadline: Option, + pub headers: HeaderMap, + pub max_decoder_bytes: u64, + pub max_brotli_window_bits: u8, + pub max_chunk_bytes: Option, + pub max_decoded_response_bytes: Option, + pub max_encoded_response_bytes: Option, + pub max_request_body_bytes: u64, + pub max_response_header_bytes: Option, + pub max_response_header_count: Option, + pub method: Method, + pub response_mode: ResponseMode, + pub timeout: Option, + pub uri: Uri, +} + +pub enum ResponseMode { Buffered { max_bytes: u64 }, Streamed } + +impl OutboundRequest { + pub fn body(self, body: impl Into) -> Self; + pub fn deadline(self, deadline: Deadline) -> Self; + pub fn from_parts(parts: OutboundRequestParts) -> Result; + pub fn from_request(request: Request, target: Uri) -> Result; + pub fn get(uri: impl AsRef) -> Result; + pub fn header, V: AsRef<[u8]>>( + self, + name: N, + value: V, + ) -> Result; + pub fn headers(&self) -> &HeaderMap; + pub fn headers_mut(&mut self) -> &mut HeaderMap; + pub fn into_parts(self) -> OutboundRequestParts; + pub fn json(self, value: &T) -> Result; + pub fn max_decoder_bytes(self, bytes: u64) -> Self; + pub fn max_brotli_window_bits(self, bits: u8) -> Self; + pub fn max_chunk_bytes(self, bytes: NonZeroU64) -> Self; + pub fn max_decoded_response_bytes(self, bytes: u64) -> Self; + pub fn max_encoded_response_bytes(self, bytes: u64) -> Self; + pub fn max_request_body_bytes(self, bytes: u64) -> Self; + pub fn max_response_bytes(self, bytes: u64) -> Self; + pub fn max_response_header_bytes(self, bytes: u64) -> Self; + pub fn max_response_header_count(self, count: u64) -> Self; + pub fn method(&self) -> &Method; + pub fn new(method: Method, uri: Uri) -> Result; + pub fn post(uri: impl AsRef) -> Result; + pub fn stream_response(self) -> Self; + pub fn timeout(self, timeout: Duration) -> Self; + pub fn uri(&self) -> &Uri; +} +``` + +- [ ] Add failing `outbound_request_*` tests for defaults, every builder/accessor, `into_parts`/`from_parts`, JSON content type, and header byte validation. `from_request` must preserve the exact method/body while immediately stripping hop-by-hop, nominated, `Host`, `Content-Length`, and `Transfer-Encoding` headers. +- [ ] Add URL table tests for scheme/host case, default ports, DNS, IPv4, bracketed IPv6, path/query WHATWG normalization, and exact canonical serialization. +- [ ] Add rejection rows for non-HTTP schemes, relative/no-authority targets, nonempty and empty userinfo, raw `#` fragments, every raw-backslash special-URL separator form (`//`, `\\`, `/\\`, `\\/` around an empty-userinfo authority), and malformed UTF-8/header bytes. Add positive rows proving percent-encoded `%23` and `%40` remain path/query data rather than being rejected as fragment/userinfo delimiters. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib outbound::tests::outbound_request_`; expect compile/test failure. +- [ ] Implement the types and builders from spec §3.1. Store the canonical `Uri`; adapters must not parse the caller's source string again. +- [ ] Implement raw-string construction in this order: reject a literal `#`; reject every raw `\\` byte before WHATWG parsing; isolate the raw authority between literal `://` and the first `/`, `?`, or `#` and reject any literal `@` (including `https://@example.com/`); `Url::parse`; reject scheme/authority/remaining userinfo violations; clear default ports; serialize once; parse that serialization into `http::Uri`. Rejecting backslashes is the exact pre-parser rule because WHATWG special URLs normalize them as separators; do not add a second ad hoc authority parser. `new(Uri)` runs the same post-parse validation but cannot recover syntax discarded before it received the typed URI. +- [ ] Implement `from_request` by preserving method/body, replacing only the target, and applying the same request normalizer immediately. Adapters still reapply normalization immediately before SDK construction because `headers_mut` can introduce unsafe fields later. +- [ ] Add and test provider accessors `backend_target`, `cert_host`, `host_authority`, `host_name`, and `sni_hostname`. `host_name` is the host-only value required by Fastly backend identity and must not include IPv6 brackets or a port. +- [ ] Set exact defaults: final Buffered response 1 MiB, request body 8 MiB, Brotli window bits 24, and Brotli decoder-requested heap 32 MiB. Encoded and decoded response caps default to unset; all byte caps and counters are `u64`. +- [ ] Rerun the focused test and `cargo test --offline --locked -p edgezero-core --lib`; expect success. +- [ ] Commit: `feat(core): add canonical outbound request types`. + +### Task 3: Add dispatch validation and request normalization + +**Files:** +- Modify: `crates/edgezero-core/src/outbound.rs` + +- [ ] Add failing `dispatch_validation_*` tests for the portable method set, GET/HEAD body rules, streamed-body detection, streamed-response detection, invalid policy combinations, and configured Brotli window values below 10 or above 30. The infallible builder stores any `u8`; only dispatch validation rejects the invalid range. +- [ ] Add failing `request_normalization_*` tests for standard hop-by-hop fields, every valid `Connection` nomination, repeated `Connection` fields, malformed tokens, `Host`, `Content-Length`, `Transfer-Encoding`, and idempotence. +- [ ] Run both filters separately; expect failures: + - `cargo test --offline --locked -p edgezero-core --lib dispatch_validation_` + - `cargo test --offline --locked -p edgezero-core --lib request_normalization_` +- [ ] Implement `validate_for_dispatch`, `normalize_for_dispatch`, `is_stream_body`, `is_stream_response`, and private `budget_inputs`. Validation must happen before body polling; normalization must be repeatable at adapter boundaries. +- [ ] Rerun both filters and the core library suite; expect success. +- [ ] Commit: `feat(core): validate outbound dispatch requests`. + +### Task 4: Add dispatch budget arithmetic + +**Files:** +- Modify: `crates/edgezero-core/src/time.rs` +- Modify: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-core/src/lib.rs` + +**Contract:** + +```rust +pub struct DispatchBudget { + pub cause: BudgetSource, + pub deadline: Deadline, + pub duration: Duration, +} + +pub fn dispatch_budget( + request: &OutboundRequest, + now: Instant, +) -> Result; +``` + +- [ ] Add failing `dispatch_budget_*` tests for default, timeout only, deadline only, both orderings, equal values favoring `PerCallTimeout`, zero/expired inputs returning attributed 504, seven-day clamping, and one shared `now` across a batch. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib dispatch_budget_`; expect failure. +- [ ] Implement only checked `Instant`/`Duration` arithmetic. Preserve the winning `BudgetSource` through every clamp. +- [ ] Rerun the focused and core suites; expect success. +- [ ] Commit: `feat(core): compute outbound dispatch budgets`. + +### Task 5: Add client and response value surfaces + +**Files:** +- Modify: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: `crates/edgezero-core/src/lib.rs` + +**Contract:** + +```rust +#[derive(Debug)] +#[non_exhaustive] +pub struct OutboundSlotResult { + pub elapsed: Duration, + pub outcome: Result, +} + +#[async_trait(?Send)] +pub trait OutboundHttpClient: Send + Sync { + async fn send(&self, request: OutboundRequest) -> Result; + async fn send_all( + &self, + requests: Vec, + ) -> Vec; +} + +#[derive(Clone)] +pub struct HttpClient { + inner: Arc, +} + +pub struct OutboundResponse { + body: Body, + headers: HeaderMap, + request_method: Method, + status: StatusCode, +} + +impl HttpClient { + pub fn new(client: Arc) -> Self; + pub async fn send(&self, request: OutboundRequest) + -> Result; + pub async fn send_all(&self, requests: Vec) + -> Vec; + pub fn with_client(client: C) -> Self; +} + +impl OutboundResponse { + pub fn body(&self) -> &Body; + pub fn headers(&self) -> &HeaderMap; + pub fn headers_mut(&mut self) -> &mut HeaderMap; + pub fn into_body(self) -> Body; + pub fn into_parts(self) -> (Method, StatusCode, HeaderMap, Body); + pub fn is_success(&self) -> bool; + pub fn new( + request_method: Method, + status: StatusCode, + headers: HeaderMap, + body: Body, + ) -> Self; + pub fn status(&self) -> StatusCode; +} +``` + +- [ ] Add failing `http_client_*` tests proving `send`/`send_all` delegation, empty-batch forwarding, index alignment, and preservation of each mock slot's distinct `elapsed` plus mixed outcome. Core delegates these values unchanged; complete preflight and real clock ownership belong to each adapter contract in Phases 4-6. +- [ ] Add failing `outbound_response_*` tests for originating method, status/success, immutable and adapter-facing mutable header access, repeated headers, borrowed/consuming body access, and `into_parts`. `into_response` is owned by Phase 2 Task 3 because it depends on response normalization. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib http_client_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib outbound_response_`; expect a nonzero failure. +- [ ] Implement `HttpClient::send` and `send_all` only. Do not expose the inner client and do not recreate `ProxyService::forward`. +- [ ] Implement `OutboundResponse::new`, `headers_mut`, `status`, `is_success`, `headers`, `body`, `into_body`, and `into_parts() -> (Method, StatusCode, HeaderMap, Body)`. Bounded drains, JSON, and `into_response` remain Phase 2. +- [ ] Add `RequestContext::http_client()` using `self.request.extensions()` directly. Keep `proxy_handle()` only as temporary branch scaffolding and add no alias between the two client types. +- [ ] Rerun focused tests, `cargo test --offline --locked -p edgezero-core --lib`, and `cargo check --workspace --all-targets --features "fastly cloudflare spin"`. +- [ ] Commit: `feat(core): add outbound HTTP client surface`. + +## Phase Verification + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `cargo check -p edgezero-core --target wasm32-unknown-unknown` +- [ ] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- [ ] `(cd examples/app-demo && cargo test --locked --workspace --all-targets)` +- [ ] `git diff --check` + +Expected result: both core modules compile temporarily, the new outbound API and budget layer are fully tested, and no adapter behavior has changed. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase2-body-response-limits.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase2-body-response-limits.md new file mode 100644 index 000000000..37a7dcc99 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase2-body-response-limits.md @@ -0,0 +1,339 @@ +# Outbound HTTP Phase 2: Typed Bodies, Decoding, and Response Limits Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Status:** Implemented on PR 275. The unchecked steps below are retained as the original implementation record, not current readiness state. + +**Goal:** Complete the portable response pipeline: exact typed stream errors, response-limit errors, header/body normalization, encoded and decoded caps, Brotli preflight, gzip/deflate/Brotli completion, optional rechunking, and deadline-aware drains. + +**Architecture:** Keep all transport-independent byte accounting and decoding in core. Adapters retain timers, abort handles, trailer/completion protocols, and native body ownership. The pipeline order is fixed: cumulative adapter-visible upstream field accounting before normalization, normalization, synthetic proxy-marker accounting, body disposition and sound `Content-Length` checks, encoded limit, lazy Brotli WBITS/decoder-charge gate, decoder/native EOF, decoded limit for identity or EdgeZero-decoded bodies only, rechunker, outer deadline/cancellation wrapper, then either the independent final Buffered collection cap or a lazy Streamed body. + +**Tech Stack:** Rust 1.95, `bytes`, `futures`, exact manifest-pinned `async-compression = 0.4.43`, `http`, `serde`, `serde_json`. + +--- + +## Preconditions + +### Content-Type Provenance Hardening + +This follow-up preserves original header ambiguity without retaining another raw header copy. + +- [x] Add failing normalization-path tests for missing, single UTF-8, repeated/conflicting, + comma-containing, opaque, mixed valid/opaque, and Connection-nominated Content-Type values. +- [x] Return body disposition and typed Content-Type provenance from the shared normalizer; + inspect all values before filtering and certify only a retained, unchanged textual singleton. +- [x] Require provenance in outbound response construction, preserve it in decomposition and + conversion, and invalidate eligibility after mutable header access without upgrading an + original ineligible/unknown result. MIME parsing and redaction remain application-owned. +- [x] Carry provenance through every Axum/Fastly/Spin conversion path. Cloudflare reports Unknown + because its string-valued, combined headers cannot establish original field/byte identity. +- [x] Exercise actual adapter conversion paths, update demo/template fixtures and public docs, + and run core/workspace tests, strict host/WASM lint, documentation guards and generated builds. + +Verification includes 750 core tests; 200 Axum unit and 13 contract tests, including a raw-TCP +valid-plus-opaque duplicate; 161 Fastly runtime tests; 29 Cloudflare browser runtime tests; +and 35 Spin hosted contracts. Spin executes its provenance matrix through the hosted contract +because the standalone lib harness lacks required host imports; the host rejects Connection +fields before normalization. Demo tests, fresh generated host/three-WASM builds, strict +host/three-WASM Clippy, API docs and documentation format/lint/build checks also pass. + +- [ ] Phase 1b is complete and `cargo test --offline --locked -p edgezero-core --lib` passes. +- [ ] Re-read spec §§3.1.4, 3.3.3-3.4.5, §5.1, and the core file summary in §7. +- [ ] Preserve `Body::into_bytes_bounded(usize)` as the inbound 400 helper. Outbound response drains are separate `u64`/502 APIs. +- [ ] Header-limit ties resolve `HeaderCount` before `HeaderBytes`, matching the specified per-entry algorithm. A malformed or conflicting payload-bearing `Content-Length` is `BadGatewayReason::Protocol`; HEAD/304 representation metadata is preserved without body interpretation. + +## Task Protocol + +For every task: add only the named tests; run the exact focused filter and require failure from the missing behavior; implement the listed API/algorithm; rerun that filter and the full core library suite; run `git diff --check`; then stage only the task's files and make the stated commit. For stream tests, use poll-counting scripted sources so early-stop, order, and EOF claims are executable assertions rather than comments. + +**Required exact test names:** `response_too_large_preserves_every_reason_without_serializing_it`, `response_too_large_constructors_preserve_unspecified_and_specific_reason`, `body_from_stream_preserves_edge_error`, `body_from_external_stream_maps_to_internal`, `body_into_bytes_bounded_preserves_edge_error`, `body_stream_accepts_infallible_bytes`, `body_bounded_checks_before_append`, `response_normalization_precedence_table`, `response_header_limiter_accumulates_field_sections`, `response_resource_header_limit_count_wins_tie`, `content_encoding_classifier_covers_every_visible_shape`, `payload_content_length_rejects_before_body_poll`, `payload_content_length_explicit_identity_rejects_before_body_poll`, `payload_content_length_passthrough_uses_buffered_not_decoded_cap`, `payload_content_length_skips_output_caps_for_compressed_body`, `outbound_response_into_response_reapplies_normalization`, `encoded_limit_stops_before_decoder`, `decoded_limit_bypasses_raw_passthrough`, `buffered_collection_limit_is_independent`, `rechunk_stream_is_lazy_and_ordered`, `outbound_response_until_deadline_wins_ready_result`, `outbound_response_json_error_classification`, `decoder_carrier_restores_exact_edge_error`, `decode_gzip_drains_every_member_to_native_eof`, `decode_brotli_rejects_trailing_data`, `brotli_memory_charge_is_pinned_and_checked`, and `brotli_window_rejects_before_decoder_allocation`. + +**Expected red:** each task either fails to resolve its new type/helper or observes the old behavior: 500/untyped limit, erased stream error, retained unsafe header, over-limit poll/append, cap winning an expired deadline, decoder stopping at its first end marker, or decoder construction before WBITS rejection. Do not accept a panic as the intended red result. + +**Portable pipeline skeleton:** adapters compose the helpers in this exact nesting; an implementation with a different nesting fails the order tests. + +```rust +let encoding = classify_content_encoding(&headers); +let max_buffered_response_bytes = match response_mode { + ResponseMode::Buffered { max_bytes } => Some(max_bytes), + ResponseMode::Streamed => None, +}; +enforce_payload_content_length( + &headers, + &encoding, + max_buffered_response_bytes, + max_decoded_response_bytes, + max_encoded_response_bytes, +)?; +let raw = limit_encoded_stream(native_body, max_encoded_response_bytes); +let decoded = match encoding { + ContentEncoding::Brotli => decode_brotli_stream( + raw, + max_brotli_window_bits, + max_decoder_bytes, + ), + ContentEncoding::Deflate => decode_deflate_stream(raw, max_decoder_bytes), + ContentEncoding::Gzip => decode_gzip_stream(raw, max_decoder_bytes), + ContentEncoding::Identity => raw, + ContentEncoding::Passthrough(_) => raw, +}; +if matches!(encoding, ContentEncoding::Brotli | ContentEncoding::Deflate | ContentEncoding::Gzip) { + headers.remove(CONTENT_ENCODING); + headers.remove(CONTENT_LENGTH); +} +let output_limited = match encoding { + ContentEncoding::Brotli | ContentEncoding::Deflate | ContentEncoding::Gzip | ContentEncoding::Identity => { + limit_decoded_stream(decoded, max_decoded_response_bytes) + } + ContentEncoding::Passthrough(_) => decoded, +}; +let shaped = rechunk_stream(output_limited, max_chunk_bytes); +let timed = adapter_deadline_wrapper(shaped, budget, native_completion); +let body = match response_mode { + ResponseMode::Buffered { max_bytes } => { + Body::from(collect_response_stream(timed, max_bytes).await?) + } + ResponseMode::Streamed => Body::from_stream(timed), +}; +``` + +`adapter_deadline_wrapper` and `native_completion` above are adapter-owned placeholders, not core APIs; they show why rechunking must remain inside the outer timer/cancellation wrapper. +The wrapper rechecks the absolute deadline whenever an inner resource error becomes ready, +so an already-expired adapter budget retains the spec's 504 precedence. The decoded limiter +must remain inside that wrapper, while the final collection helper remains outside it and +preserves the narrower generic-consumer precedence documented in §3.4.5. + +Every transport-independent stream helper accepts and returns the same concrete erased type; +none returns a branch-specific opaque `impl Stream`: + +```rust +pub type BodyStream = LocalBoxStream<'static, Result>; + +pub const BROTLI_DECODER_FIXED_CHARGE_BYTES: u64 = 16_777_216; +pub fn brotli_decoder_memory_charge(window_bits: u8) -> Result; +pub fn decode_brotli_stream( + stream: BodyStream, + max_window_bits: u8, + max_decoder_bytes: u64, +) -> BodyStream; +pub fn decode_deflate_stream(stream: BodyStream, max_decoder_bytes: u64) -> BodyStream; +pub fn decode_gzip_stream(stream: BodyStream, max_decoder_bytes: u64) -> BodyStream; +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ContentEncoding { Brotli, Deflate, Gzip, Identity, Passthrough(PassthroughReason) } +#[non_exhaustive] +pub enum PassthroughReason { Stacked, Unsupported(String), Malformed } +pub fn classify_content_encoding(headers: &HeaderMap) -> ContentEncoding; +pub async fn collect_response_stream(stream: BodyStream, max: u64) -> Result; +pub fn enforce_payload_content_length( + headers: &HeaderMap, + encoding: &ContentEncoding, + max_buffered_bytes: Option, + max_decoded_bytes: Option, + max_encoded_bytes: Option, +) -> Result<(), EdgeError>; +pub fn limit_decoded_stream(stream: BodyStream, max: Option) -> BodyStream; +pub fn limit_encoded_stream(stream: BodyStream, max: Option) -> BodyStream; +pub fn rechunk_stream(stream: BodyStream, max: Option) -> BodyStream; + +pub struct ResponseHeaderLimiter { /* private checked-u64 counters and limits */ } +impl ResponseHeaderLimiter { + pub fn new(max_bytes: Option, max_count: Option) -> Self; + pub fn observe(&mut self, headers: &HeaderMap) -> Result<(), EdgeError>; +} +``` + +Ownership is exact: `BodyStream` lives at `edgezero_core::body::BodyStream`; +`BROTLI_DECODER_FIXED_CHARGE_BYTES`, `ContentEncoding`, +`brotli_decoder_memory_charge`, `classify_content_encoding`, `decode_brotli_stream`, and +`decode_gzip_stream` live in `edgezero_core::compression`; `ResponseHeaderLimiter`, +`collect_response_stream`, `enforce_payload_content_length`, `limit_decoded_stream`, +`limit_encoded_stream`, and `rechunk_stream` live in `edgezero_core::outbound`. Core re-exports +every one at the `edgezero_core` root, and adapter crates use that single stable import +surface. Core owns typed source restoration and codec/accounting errors; adapters own the +outer deadline, cancellation guard, and native-completion protocol. + +### Task 1: Add typed response-limit errors + +**Files:** +- Modify: `crates/edgezero-core/src/error.rs` +- Modify: `crates/edgezero-core/src/lib.rs` + +- [ ] Add failing `response_too_large_*` tests for all reasons: `BrotliWindow`, `BufferedBody`, `DecodedBody`, `DecoderMemory`, `EncodedBody`, `HeaderBytes`, `HeaderCount`, and `Unspecified`. +- [ ] Assert status 502, kind `response_too_large`, no `reason`, `field_path`, or `Retry-After` in the wire response, and preservation of the Rust-side reason. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib response_too_large_`; expect failure. +- [ ] Add `ResponseLimitReason` and `EdgeError::ResponseTooLarge`; update every exhaustive match in production and tests in alphabetical order. +- [ ] Add and test both required constructors: `response_too_large(message)` stores `Unspecified`; `response_too_large_with_reason(message, reason)` stores the supplied reason. +- [ ] Rerun the focused test and core suite; expect success. +- [ ] Commit: `feat(core): add typed outbound response limits`. + +### Task 2: Make body stream errors exact + +**Files:** +- Modify: `crates/edgezero-core/src/body.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: `crates/edgezero-core/src/proxy.rs` +- Modify: `crates/edgezero-adapter-axum/src/request.rs` +- Modify: `crates/edgezero-adapter-axum/src/response.rs` +- Modify: `crates/edgezero-adapter-axum/src/proxy.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/response.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/proxy.rs` +- Modify: `crates/edgezero-adapter-fastly/src/response.rs` +- Modify: `crates/edgezero-adapter-fastly/src/proxy.rs` +- Modify: `crates/edgezero-adapter-spin/src/response.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` + +**Contract:** + +```rust +pub type BodyStream = LocalBoxStream<'static, Result>; + +pub enum Body { + Once(Bytes), + Stream(BodyStream), +} + +pub fn from_external_stream(stream: S) -> Self; // E -> Internal +pub fn from_stream(stream: S) -> Self; // exact EdgeError +pub async fn into_bytes_bounded(self, max_size: usize) -> Result; +pub fn into_stream(self) -> Option; +pub fn stream(stream: S) -> Self; // infallible Bytes +``` + +- [ ] Add failing tests for typed error identity, external error conversion, infallible input, `From`, exact-limit success, first-byte-over-limit failure, checked overflow, and no poll after failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib body::tests::`; expect failure. +- [ ] Implement `BodyStream`, all three constructors, `into_stream`, `From`, and pre-append checked accounting. `into_bytes_bounded` must propagate an existing `EdgeError` unchanged. Add one typed-source test for every public error family and a foreign sentinel proving `from_external_stream` sanitizes before collection. Use `from_external_stream` only at actual platform/foreign-error inputs; a platform inbound producer either uses that compatibility boundary or deliberately maps to a public `EdgeError` before `from_stream`. Remove redundant `map_err(EdgeError::internal)` where it would erase an existing `EdgeError`. +- [ ] Run `rg -n 'Body::from_stream|Body::from_external_stream|Body::Stream|Self::Stream|\.into_stream\(' crates examples/app-demo --glob '*.rs' --glob '*.hbs'`. Classify construction/consumption sites, ignoring definitions, comments, and introspection-only matches. Add error-identity regressions at EdgeZero-owned error-producing boundaries; inbound platform streams receive only compile-required `from_external_stream` migration that preserves their existing Internal mapping. +- [ ] Run the core suite and `cargo test --workspace --all-targets`; expect success. +- [ ] Commit: `refactor(core): preserve typed body stream errors`. + +### Task 3: Normalize response metadata and body disposition + +**Files:** +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/src/outbound.rs` + +- [ ] Add failing `response_normalization_*` tests for repeated and malformed `Connection`, nominated `content-encoding`/`content-length`, UTF-8 filtering, standard hop-by-hop fields, and idempotence. +- [ ] Add body-disposition cases for HEAD, every 1xx class, 204, positive/zero/absent 205, 304, and ordinary payload-bearing responses. +- [ ] Add explicit malformed, duplicate-conflicting, and comma-list `Content-Length` cases. Payload-bearing/205 ambiguity must fail as protocol; HEAD/304 metadata is retained without parsing it as a body promise. +- [ ] Add `outbound_response_into_response_reapplies_normalization`: mutate headers through `headers_mut`, then prove `into_response` strips hop-by-hop and nominated fields, preserves repeated end-to-end fields, keeps the body lazy, and returns typed protocol/internal errors rather than an infallible or generic conversion. +- [ ] Run both focused commands; each must execute a nonzero matching test count and fail for missing behavior: + - `cargo test --offline --locked -p edgezero-core --lib response_normalization_` + - `cargo test --offline --locked -p edgezero-core --lib outbound_response_into_response_` +- [ ] Implement `normalize_response_headers` returning `ResponseHeadNormalization` (body disposition and pre-filter Content-Type provenance), a private checked `parse_content_length`, plus `OutboundResponse::into_response`. Preserve HEAD/304 representation metadata; strip prohibited 1xx/204 framing; validate payload/205 length syntax; rewrite successful 205 to `Content-Length: 0` only after capturing `declared_body`. `into_response` must reapply the same helper idempotently after any `headers_mut` use, without upgrading original provenance. +- [ ] Rerun focused/core tests; expect success. +- [ ] Commit: `feat(core): normalize outbound response metadata`. + +### Task 4: Implement response header, encoded-byte, and chunk wrappers + +**Files:** +- Modify: `crates/edgezero-core/src/compression.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/Cargo.toml` +- Modify: `crates/edgezero-core/src/outbound.rs` + +**Pre-poll length contract:** adapters call this only after response normalization and only +for `ResponseBodyDisposition::Payload`: + +```rust +pub fn enforce_payload_content_length( + headers: &HeaderMap, + encoding: &ContentEncoding, + max_buffered_bytes: Option, + max_decoded_bytes: Option, + max_encoded_bytes: Option, +) -> Result<(), EdgeError>; +``` + +- [ ] Add failing `response_resource_*` tests for exact and over-limit header count/bytes, repeated values, checked overflow, count-before-bytes precedence, and cumulative accounting across successive adapter-visible informational/final/trailer `ResponseHeaderLimiter::observe` calls before normalization. Prove stripped fields still count and a limit cannot reset at a field-section boundary; separately charge EdgeZero's synthetic proxy marker after normalization. Adapters still document whether those sections are exposed before host allocation. +- [ ] Add classifier tables for absent and exactly one bare `identity`, `gzip`, `deflate`, or `br`; ASCII case and surrounding space/tab; repeated fields; comma-stacked, parameterized, empty, malformed/non-UTF-8, and unknown values. The declared results are `Brotli`, `Deflate`, `Gzip`, `Identity`, and typed `Passthrough(PassthroughReason)`; every adapter consumes this helper rather than inspecting the header itself. +- [ ] Add payload-length tables for absent/zero/exact/over values; malformed, comma-list, and conflicting values; effective identity (absent or one bare `identity`) versus gzip/deflate/br/passthrough coding; encoded-only, decoded-only, final-buffer-only, and combined caps. Encoded applies to every coding. Decoded rejects early only for effective identity. The final Buffered cap rejects early for identity and passthrough, where wire bytes are final bytes, but never for gzip/deflate/Brotli that EdgeZero decodes. Include explicit-identity and passthrough final-buffer overages that reject before the scripted body is polled, plus a passthrough body larger than the decoded cap that succeeds when the independent final cap permits it. HEAD/1xx/204/304 never call this helper; 205 uses its disposition protocol. +- [ ] Add encoded-limit tests for exact/over limit, empty chunks counting as zero but remaining observable, cumulative gzip-member input, early stop, and typed source-error preservation. +- [ ] Add decoded-limit tests for exact/over cumulative identity, gzip, and Brotli output; checked overflow; empty chunks; early stop; and typed source-error preservation. A pipeline table proves passthrough never enters this wrapper. +- [ ] Add `rechunk_*` tests for lazy pulls, order preservation, exact maximum item size, empty items, source error ordering, early drop, and no claim about backing allocation/RSS. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib response_resource_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib content_encoding_classifier_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib payload_content_length_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib encoded_limit_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib rechunk_`; expect a nonzero failure. +- [ ] Implement `ContentEncoding` and `classify_content_encoding` in `compression.rs`; + implement `ResponseHeaderLimiter`, `enforce_payload_content_length`, + `limit_decoded_stream`, `limit_encoded_stream`, and `rechunk_stream` in `outbound.rs`, with the exact shared + signatures above. Re-export every new public item from `lib.rs`. The classifier uses + `HeaderMap::get_all`; only one bare visible field can select Identity/Gzip/Brotli, and + every repeated/stacked/parameterized/malformed/non-UTF-8/unknown shape is typed + `Passthrough(PassthroughReason)`. No + helper may poll after a terminal error or cap result. +- [ ] Rerun focused/core tests; expect success. +- [ ] Commit: `feat(core): enforce outbound response resources`. + +### Task 5: Add deadline-aware response drains and JSON + +**Files:** +- Modify: `crates/edgezero-core/src/outbound.rs` + +- [ ] Add failing `outbound_response_drain_*` tests for empty and nonempty `Once` plus `Stream`; exact/over final collection limits; typed source failures; pre-append accounting; and `ResponseLimitReason::BufferedBody`. Exercise the same `collect_response_stream` helper adapters use before constructing a Buffered response. +- [ ] Add `_until` tests for entry, pre-poll, post-ready chunk, source error, cap result, and EOF checks. If expired when a decision is ready, 504 wins with `BudgetSource::Unspecified`, because this API receives only `Deadline`. Separately prove an adapter-style wrapper preserves its supplied `DispatchBudget::cause`. +- [ ] Add `outbound_response_json_*` tests for valid/malformed JSON, buffered/streamed modes, and streamed `json` invalid-state mapping to protocol 502. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib outbound_response_drain_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib outbound_response_until_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib outbound_response_json_`; expect a nonzero failure. +- [ ] Implement `collect_response_stream` plus synchronous `json(&self)` for `Body::Once`, returning protocol 502 for `Body::Stream`; implement consuming `into_bytes_bounded`, `into_bytes_bounded_until`, `json_bounded`, and `json_bounded_until`. These helpers own a final collection cap and report `BufferedBody`; the request's independent decoded-output policy has already wrapped eligible streams. Malformed upstream JSON is 502 with `BadGatewayReason::Decode(BadGatewayDecodeReason::Json)`. Do not delegate to the inbound body helper. +- [ ] Rerun focused/core tests; expect success. +- [ ] Commit: `feat(core): add bounded outbound response drains`. + +### Task 6: Replace decoder error plumbing and completion semantics + +**Files:** +- Modify: `crates/edgezero-core/src/compression.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/proxy.rs` +- Modify: `crates/edgezero-adapter-fastly/src/proxy.rs` +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Modify: `examples/app-demo/Cargo.lock` + +- [ ] Add failing `decoder_carrier_*` tests proving all `EdgeError` variants survive the `io::Error` bridge unchanged and decoder-originated errors become `BadGatewayReason::Decode(BadGatewayDecodeReason::Gzip)` or `Decode(BadGatewayDecodeReason::Brotli)` with diagnostics. +- [ ] Change the workspace requirements to exact `async-compression = "=0.4.43"`, `brotli = "=8.0.4"`, and `brotli-decompressor = "=5.0.1"`; add the decompressor as a core dev dependency so Cargo constrains the same implementation used transitively. Refresh both lockfiles with exact `cargo update --offline --precise` commands for all three packages. +- [ ] Assert both graphs resolve exactly 0.4.43, 8.0.4, and 5.0.1 with no duplicate Brotli decoder version: + - `cargo tree --offline --locked -p edgezero-core -e normal | rg 'async-compression v0\.4\.43'` + - `cargo tree --offline --locked -p edgezero-core | rg 'brotli v8\.0\.4|brotli-decompressor v5\.0\.1'` + - `cargo tree --offline --locked --manifest-path examples/app-demo/Cargo.toml -p app-demo-core -e normal | rg 'async-compression v0\.4\.43'` + - `cargo tree --offline --locked --manifest-path examples/app-demo/Cargo.toml -p app-demo-core | rg 'brotli v8\.0\.4|brotli-decompressor v5\.0\.1'` +- [ ] Add `decode_gzip_*` tests for one member, concatenated/empty/split members, cumulative output, corrupt/truncated later members, trailing garbage, late source errors, empty chunks, and drain to native EOF. +- [ ] Add `decode_brotli_*` tests for one stream, buffered read-ahead recovery, trailing bytes, a second stream, late source errors, empty chunks, and native EOF. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib decoder_carrier_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib decode_gzip_`; expect a nonzero failure. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib decode_brotli_`; expect a nonzero failure. +- [ ] Implement the private exact carrier and the concrete `BodyStream -> BodyStream` decoder signatures; enable `GzipDecoder::multiple_members(true)`; recover and inspect Brotli unread input and continue to native EOF. Migrate the existing Cloudflare/Fastly shared-decoder call sites in this same task so the commit leaves the workspace buildable and does not erase `EdgeError`. Never drain after cap/error/timeout. +- [ ] Rerun focused/core tests; expect success. +- [ ] Commit: `fix(core): preserve decoder completion and errors`. + +### Task 7: Gate Brotli allocation from the stream prefix + +**Files:** +- Modify: `crates/edgezero-core/src/compression.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/src/outbound.rs` + +- [ ] Add failing `brotli_window_*` tests for all WBITS values 10 through 30, standard and two-byte large-window forms, every split prefix boundary, exact/over configured window limits, malformed/truncated prefixes, replay fidelity, and proof that both window and decoder-charge rejection occur before decoder construction. Add `brotli_decoder_memory_charge_*` tables asserting `16_777_216 + 2^WBITS` with checked `u64` arithmetic, exact/one-byte-under policy boundaries, default WBITS 24 fitting the 32 MiB default, and no decoder/source poll after a failed preconstruction gate. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib brotli_window_`; expect failure. +- [ ] Audit the exact locked `brotli 8.0.4 -> brotli-decompressor 5.0.1` source graph, including both allocator crates. Record the per-family allocation-payload derivation and explicit unquantified-headroom scope in `docs/audits/2026-09-27-brotli-decoder-memory-accounting.md`, link it from the `BROTLI_DECODER_FIXED_CHARGE_BYTES = 16_777_216` code comment, and separately account for the maximum `2^WBITS` ring window. This source proof owns the accounting constant but does not claim a complete RSS bound. Corpus/allocation measurements are regression evidence only and may not substitute for the audit. Add exact normal dependency, version, registry-source, and checksum assertions so a dependency update cannot retain the old charge silently. +- [ ] Implement one lazy Brotli state machine in `decode_brotli_stream(stream, max_window_bits, max_decoder_bytes)`: read only the fixed-size prefix, compute the source-audited charge, return `BrotliWindow` or `DecoderMemory` before decoder construction, replay the prefix, then construct and drive the decoder. Invalid syntax is `Decode(Brotli)`. Because construction happens on first poll, adapter polling places prefix reads and allocation inside the outer absolute deadline/cancellation wrapper. +- [ ] Compose only the **core-owned segment** in a private core test helper: classify -> payload-length checks -> encoded counter -> decoder (including the lazy Brotli prefix/charge gate) -> decoded counter only for Identity/Deflate/Gzip/Brotli -> optional rechunker -> final collection cap. Add table tests for absent/identity, bare case-insensitive gzip/deflate/br, unknown, parameterized, stacked, and repeated encodings. Give decoded and final caps unequal values in both directions; prove passthrough bypasses only the decoded cap while every Buffered disposition still receives `BufferedBody` at its final cap. The helper mutates test headers to assert the contract that known decoded layers remove visible `content-encoding` and `content-length`, while Identity and Passthrough preserve them. Do not add a production response orchestrator, adapter deadline/cancellation wrapper, native-completion source, or platform response constructor in core. Phases 4-6 compose these public helpers at each native boundary and perform the specified header mutation before `OutboundResponse` construction. +- [ ] Rerun focused/core tests; expect success. +- [ ] Commit: `feat(core): gate brotli decoder allocation`. + +## Phase Verification + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `cargo check -p edgezero-core --target wasm32-unknown-unknown` +- [ ] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- [ ] `(cd examples/app-demo && cargo test --locked --workspace --all-targets)` +- [ ] `rg -n 'map_err\(EdgeError::internal\)' crates/edgezero-*` and inspect every remaining foreign-error boundary. +- [ ] `git diff --check` + +Expected result: core exposes one typed, ordered response-processing pipeline. Platform timers and cancellation remain adapter work in Phases 4-6. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase3-capabilities-cli.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase3-capabilities-cli.md new file mode 100644 index 000000000..8c8dc52d4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase3-capabilities-cli.md @@ -0,0 +1,189 @@ +# Outbound HTTP Phase 3: Capabilities and CLI Enforcement Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Status:** Implemented on PR 275. The unchecked steps below are retained as the original implementation record, not current readiness state. + +**Goal:** Make outbound requirements declarative and enforce them against one pinned app/runtime pair before build, serve, deploy, staged deploy, or demo execution. + +**Architecture:** Core owns the manifest schema and host grammar; macros bake the validated contract; the adapter registry reports support; the CLI resolves one target and its associated contract once, then gates before shell or registry dispatch. Spin platform-host validation compares canonical atomic host sets for only the selected component. + +**Tech Stack:** Rust 1.95, `serde`, `toml`, `validator`, proc macros/`trybuild`, adapter registry, CLI integration tests. + +--- + +## Preconditions + +- [ ] Phase 2 passes all repository gates. +- [ ] Read spec §3.5 in full, CLI portions of §§5-7, and current adapter discovery paths. +- [ ] Do not edit generated templates, app-demo, or public guide content in this phase; Phase 7 owns consumer declarations and docs. + +## Task Protocol + +For each task: write only the named unit/integration/trybuild cases; run the exact package/filter command and require a nonzero compile/assertion failure; implement the listed surface; rerun focused and package tests; run `git diff --check`; then stage only the task's files and make the stated commit. Resolver tests receive explicit directories/environment maps and never mutate process cwd/env concurrently. + +**Required exact test names:** `capability_manifest_rejects_unknown_duplicate_and_overlap`, `capability_manifest_rejects_reserved_key_at_every_depth_and_case`, `outbound_host_grammar_table`, `outbound_host_default_is_https_only`, `baked_manifest_states_are_distinct`, `baked_manifest_rejects_reserved_key_at_every_depth_and_case`, `app_macro_manifest_caches_are_per_implementation`, `adapter_capability_default_is_unsupported`, `explicit_manifest_is_authoritative`, `runtime_resolver_rejects_cross_app_pair`, `runtime_resolver_rejects_equal_distance_ambiguity`, `spin_selected_component_hosts_match_as_atomic_sets`, `execute_runtime_gates_each_runtime_action_once`, and `operational_actions_keep_existing_dispatch_path`. + +**Expected red:** schema types are initially unresolved; macro fixtures initially compile or share state when they must not; the registry lacks `capability`; resolver tests select/fall back instead of rejecting; Spin drift passes incorrectly; dispatch counters show zero or duplicate gate calls. + +### Task 1: Add strict capability and host schema + +**Files:** +- Modify: `crates/edgezero-core/src/manifest.rs` +- Modify: `crates/edgezero-core/src/lib.rs` + +**Public surface:** `Capability`, `CapabilitySupport`, `ManifestCapabilities`, `ManifestOutboundCapability`, `AtomicHost`, `HostParseError`, `HostPat`, `Port`, `Scheme`, and `canonicalize_outbound_host` exactly as specified in §3.5.1. + +- [ ] Add failing manifest tests for all eight kebab-case outbound capabilities, including `outbound-complete-resource-accounting`, unknown keys, duplicate required/optional entries, required/optional overlap, and serialization round trips. Add a table that rejects any nested key equal to `capabilities` ignoring ASCII case, rejects non-lowercase top-level spellings, and accepts only exact lowercase top-level `capabilities`; recurse through tables and arrays. +- [ ] Add table tests for every accepted/rejected host grammar row from §3.5.1, including IPv4/IPv6, wildcard expansion, ports, LDH limits, punycode, raw Unicode, userinfo, paths, queries, fragments, whitespace, and malformed brackets. +- [ ] Map every rejected host row to the exact non-exhaustive `HostParseError` variant and stable nonempty Display text specified in §3.5.1; messages must not echo caller input. Use `Port::Any`, never a nonexistent wildcard variant. +- [ ] Run `cargo test --offline --locked -p edgezero-core --lib manifest::tests::`; expect failure. +- [ ] Implement strict `deny_unknown_fields` schema plus the runtime TOML reserved-key walker. Keep the shared `is_reserved_capabilities_key` policy and host parsing inside `manifest.rs`, because that file is textually included by the macro crate. +- [ ] Make absent hosts canonicalize to `https://*:*`; explicit `"*"` expands to both HTTP and HTTPS atomic entries. Validation, rendering, and drift checks must call the same parser. +- [ ] Rerun focused/core tests; expect success. +- [ ] Commit: `feat(core): add outbound capability manifest schema`. + +### Task 2: Add baked-manifest state and macro isolation + +**Files:** +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-core/src/manifest.rs` +- Modify: `crates/edgezero-macros/src/app.rs` +- Modify: `crates/edgezero-macros/tests/app_macro.rs` +- Create: `crates/edgezero-macros/tests/ui/app_capabilities_nested.rs` and `.stderr` +- Create: `crates/edgezero-macros/tests/ui/trigger_capabilities_nested.rs` and `.stderr` +- Create: `crates/edgezero-macros/tests/ui/environment_capabilities_nested.rs` and `.stderr` +- Create: `crates/edgezero-macros/tests/ui/adapter_build_capabilities_nested.rs` and `.stderr` +- Create: `crates/edgezero-macros/tests/ui/array_capabilities_nested.rs` and `.stderr` +- Create: `crates/edgezero-macros/tests/ui/app_capabilities_mixed_case.rs` and `.stderr` +- Create matching TOML inputs under `crates/edgezero-macros/tests/fixtures/` with the same stems + +**Contract:** `BakedManifest::{Absent, Malformed, Present}`, `ManifestContract::{Malformed, None, Present}`, and `Hooks::{manifest_json, manifest}`. + +- [ ] Add failing tests for absent, malformed, and present baked manifests; runtime/baked validation parity; reserved keys at every depth/case in both raw TOML and baked JSON; and two app implementations whose caches cannot leak into each other. +- [ ] Add trybuild failures for `capabilities` misplaced under nested tables and arrays at every depth listed by §3.5.3, plus a mixed-case nested spelling. +- [ ] Run `cargo test --offline --locked -p edgezero-macros`; expect failure. +- [ ] In `expand_app`, parse the source manifest to `toml::Value`, run the recursive reserved-key scan before typed deserialization can discard misplaced tables, then deserialize, validate, finalize, and serialize. Generate one `OnceLock` per app implementation, never in a shared default trait method. At runtime, parse baked JSON to `serde_json::Value`, run the equivalent JSON walker using the same case-insensitive key predicate and array recursion, then deserialize, validate, and finalize. Any scan/parse/validation failure is `BakedManifest::Malformed`, never `Absent` or an empty `Present` contract. +- [ ] Rerun macro and core tests; expect success. +- [ ] Commit: `feat(macros): bake outbound capability contracts`. + +### Task 3: Add fail-closed adapter capability metadata + +**Files:** +- Modify: `crates/edgezero-adapter/Cargo.toml` +- Modify: `crates/edgezero-adapter/src/registry.rs` +- Modify: `crates/edgezero-adapter/src/cli_support.rs` +- Modify: `Cargo.lock`, `examples/app-demo/Cargo.lock` + +- [ ] Add failing registry tests for the default `Unsupported` result and a fixture adapter that returns each support level. Do not publish in-tree matrix cells before the owning adapter behavior and evidence land. +- [ ] Run `cargo test --offline --locked -p edgezero-adapter --features cli adapter_capability_`; expect a nonzero failure. +- [ ] Add the direct `edgezero-core` dependency and defaulted `Adapter::capability`. Until Phases 4-6, in-tree adapters inherit `Unsupported`; each owning phase adds its exhaustive override atomically with passing contracts and any required host proof. +- [ ] Refresh both independent lock graphs with `cargo check --offline -p edgezero-adapter --features cli` and `cargo check --offline --manifest-path examples/app-demo/Cargo.toml -p app-demo-cli --tests`; these dependency-refresh commands are intentionally unlocked. The demo command must select a package in the demo workspace; Cargo rejects feature selection for the root workspace's `edgezero-adapter` package through the demo manifest. +- [ ] Assert the new edge with `cargo tree --offline --locked -p edgezero-adapter -e normal | rg 'edgezero-core'` and `cargo tree --offline --locked --manifest-path examples/app-demo/Cargo.toml -p edgezero-adapter -e normal | rg 'edgezero-core'`. +- [ ] Rerun `cargo test --offline --locked -p edgezero-adapter --features cli adapter_capability_`; expect a nonzero test count and success. +- [ ] Commit: `feat(adapter): add fail-closed capability metadata`. + +### Task 4: Build the paired runtime resolver + +**Files:** +- Modify: `crates/edgezero-adapter/src/lib.rs` +- Modify: `crates/edgezero-adapter/src/registry.rs` +- Create: `crates/edgezero-cli/src/manifest_source.rs` +- Modify: `crates/edgezero-cli/src/lib.rs` +- Modify: `crates/edgezero-cli/src/adapter.rs` +- Modify: `crates/edgezero-adapter/src/cli_support.rs` +- Modify: `crates/edgezero-adapter-axum/src/cli.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/cli.rs` +- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` +- Modify: `crates/edgezero-adapter-spin/src/cli.rs` + +**Contract:** declare the exact spec §3.5.3 types in lint order. In +`edgezero-adapter::registry`, add private-field `AdapterExecutionTarget { app_root, +component, platform_manifest }` with public `new` plus read-only accessors, and add +`Adapter::execute_target(action, &target, args)`. Its default returns an explicit pinned-target +unsupported error; it must not call the cwd-discovering `execute`. In `manifest_source`, add +`ResolvedAdapterTarget::{Registered(AdapterExecutionTarget), Shell}`, `ResolvedManifest { +loader, path }`, owned `ResolvedRuntime { action, adapter, contract, target }`, and +`ResolvedShellTarget { bind_host, bind_port, command, environment, root }`. Keep the latter +fields private to `manifest_source`; `ResolvedRuntime` exposes only crate-private read-only +`action()`, `adapter_name()`, `manifest()`, and `target()` accessors, and the contained types +expose read-only accessors for their listed fields. No `ResolvedRuntime` constructor or +mutator is exposed outside the resolver, and module items/struct fields/enum variants/impl +methods must satisfy `arbitrary_source_item_ordering`. + +- [ ] Add pure failing tests for explicit `EDGEZERO_MANIFEST`, captured invocation-directory resolution, ancestor precedence, bounded workspace descendants, equal-distance ambiguity, target/contract mismatch, symlink containment, malformed contracts, and the narrow valid `contract: None` case. Assert app/shell roots are absolute canonical directories, contract/platform manifests are absolute canonical regular files contained by their corresponding root, and file/directory type swaps fail closed. +- [ ] Add tests proving shell-command and registry-backed runtime-producing actions receive + the same pinned target and cannot rediscover a different manifest. Prove the canonical + adapter identity and requested action are stored in each resolved runtime. For every + in-tree adapter, run from an unrelated cwd with conflicting discoverable manifests and + assert `execute_target` uses only `app_root`, `platform_manifest`, and `component`; assert + the default fixture adapter fails instead of entering its cwd-discovering `execute`. + Assert the resolver rejects operational actions so auth/version/healthcheck/rollback + cannot accidentally enter this outbound-only path. +- [ ] Run `cargo test --offline --locked -p edgezero-cli manifest_source`; expect failure. +- [ ] Extract target-argument parsing and discovery into pure helpers. Explicit env selection is authoritative and never falls back. Canonical platform files must remain under the canonical app root. Resolve and pin exactly one adapter identity, manifest contract, target, and runtime-producing action; no later dispatcher step may read environment variables or scan ancestors/workspace descendants. +- [ ] Preserve direct adapter API discovery for callers outside the CLI. Refactor each + in-tree adapter's build/serve/deploy implementation into target-aware internal helpers; + direct `execute` discovers once and delegates, while the paired CLI path calls + `execute_target` with the cross-crate typed target and performs no discovery. Do not use + process-wide cwd changes or synthetic path/component entries in `adapter_args`. +- [ ] Rerun focused CLI tests; expect success. +- [ ] Commit: `refactor(cli): resolve paired runtime contracts`. + +### Task 5: Validate selected Spin component host drift + +**Files:** +- Modify: `crates/edgezero-adapter-spin/src/cli.rs` +- Modify: `crates/edgezero-adapter/src/cli_support.rs` +- Modify: `crates/edgezero-cli/src/manifest_source.rs` + +- [ ] Add failing tests for explicit component, single inferred component, missing/ambiguous components, absent hosts, wildcard expansion, scheme case normalization, malformed platform entries, set-order independence, and drift diagnostics containing manifest path/component/expected list. +- [ ] Run `cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features cli spin_selected_component_`; expect a nonzero failure. +- [ ] Implement one pure component selector shared by adapter validation and CLI enforcement. Compare only selected-component canonical atomic sets. +- [ ] Rerun `cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features cli spin_selected_component_`; expect success. +- [ ] Run `cargo test --offline --locked -p edgezero-cli --no-default-features --features cli spin_selected_component_`; expect success with the Spin registry adapter genuinely unlinked. +- [ ] Separately run `cargo test --offline --locked -p edgezero-cli spin_selected_component_`; expect success with the default linked-adapter set. +- [ ] Commit: `feat(cli): reject Spin outbound host drift`. + +### Task 6: Gate actions exactly once + +**Files:** +- Modify: `crates/edgezero-cli/src/adapter.rs` +- Modify: `crates/edgezero-cli/src/lib.rs` +- Modify: `crates/edgezero-cli/src/demo_server.rs` +- Modify: `crates/edgezero-cli/Cargo.toml` + +**Dispatch signatures:** add two outbound-scoped dispatch functions that accept only the owned `ResolvedRuntime` and adapter arguments. They call private `ensure_action_capabilities(&runtime)` exactly once before branching. That helper derives the canonical adapter name, runtime-producing action, and manifest through read-only accessors and delegates to `ensure_capabilities(runtime.adapter_name(), ManifestContract::from_opt(runtime.manifest()))`. The private post-gate dispatcher also accepts the complete runtime and never gates again. There is no duplicate adapter/action argument that can diverge between admission and the eventual side effect. Keep the existing `execute(..)` / `execute_capture(..)` APIs for exempt operational actions unchanged. + +```rust +pub fn execute_runtime( + runtime: ResolvedRuntime, + adapter_args: &[String], +) -> Result<(), String>; + +pub fn execute_capture_runtime( + runtime: ResolvedRuntime, + adapter_args: &[String], +) -> Result, String>; +``` + +- [ ] Add failing tests with fixture adapters for required versus optional capabilities at all four support levels, missing registry behavior, and malformed/absent contracts. +- [ ] Add a counter seam proving `Build`, `Serve`, `Deploy`, and `DeployStaging` gate once in both `execute_runtime` and `execute_capture_runtime` before any shell/registry side effect. Use two fixture adapters and distinct actions to prove capability lookup, shell diagnostics/registry lookup, and final `AdapterAction` conversion all observe the adapter/action stored by the resolver; the outbound-scoped dispatch API must offer no override arguments. +- [ ] Add `operational_actions_keep_existing_dispatch_path`: all three auth actions, emit-version, healthcheck, and rollback continue to call the existing dispatcher, remain exempt from capability gating, and preserve registered no-project behavior. The test must fail if any of them constructs `ResolvedRuntime`, invokes paired manifest discovery, or acquires a manifest requirement. Provision and config are outside this outbound specification and receive no requirements here. Add a demo test proving Axum checks the baked manifest before startup. +- [ ] Run `cargo test --offline --locked -p edgezero-cli`; expect failure. +- [ ] Implement one private post-gate dispatcher so `execute_capture_runtime` cannot re-enter public `execute_runtime`. Required accepts only Native/BoundedCooperative; optional warns for BestEffort/Unsupported. Derive adapter/action exclusively from `ResolvedRuntime` in every outbound-gated branch. Leave `auth.rs` and the existing emit-version/healthcheck/rollback dispatch path unchanged; after a captured Deploy lacks a version, retain today's registered `EmitVersion` call rather than broadening this resolver. +- [ ] Rerun CLI tests; expect success. Adapter-specific capability values remain Unsupported until their implementation phase. +- [ ] Commit: `feat(cli): enforce outbound capabilities`. + +## Phase Verification + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- [ ] `cargo test -p edgezero-adapter --all-targets` +- [ ] `(cd examples/app-demo && cargo test --locked --workspace --all-targets)` +- [ ] `git diff --check` + +Expected result: every execution path evaluates one manifest/target pair once, before side effects, while platform support claims remain explicit and conservative. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase4-axum-cloudflare.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase4-axum-cloudflare.md new file mode 100644 index 000000000..d849402b3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase4-axum-cloudflare.md @@ -0,0 +1,307 @@ +# Outbound HTTP Phase 4: Axum and Cloudflare Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Status:** Deterministic implementation is complete on PR 275. Protected deployed-provider evidence remains an external promotion gate, so the affected capability cells remain `BestEffort`. The unchecked steps below are retained as the original implementation record. +> **Superseded batch/lifecycle API:** The 2026-09-15 consumer-alignment plan replaces this +> plan's `send_all` API and test sentinels. Retain the text only as implementation history. +> **Shared implementation follow-up (R5/R6):** Axum, Cloudflare, and Spin now use +> `OutboundBatch::from_futures`. All four adapters use `ResponseBodyPolicy` / +> `apply_response_body_policy` for the payload-limit/decoding/rechunking composition. +> Transport reads, clocks, timers, cancellation, bodyless handling, and final collection remain +> adapter-owned. These extractions do not change the normative outbound contract. + +**Goal:** Implement the full outbound contract for Axum and Cloudflare, including batch behavior, typed cleanup, adapter scheduling, and executable native/WASM/host contracts. + +**Architecture:** Both clients consume the same core request and response pipeline. Axum uses Reqwest and bounded conversion at one Tokio blocking boundary. Cloudflare uses a target-neutral orchestration driver plus a small Worker WASM bridge that owns abort, timer, native body, and host-event yield resources through terminal completion. + +**Tech Stack:** Reqwest 0.13.4, Axum/Tokio, Worker 0.8.5, Web APIs, `web-time`, `futures`, wasm-bindgen-test, workerd/deployed probes. + +--- + +## Preconditions and Owned Files + +- [ ] Phases 1b-3 pass all gates. +- [ ] The implementation index's inert workflow-bootstrap PR is merged to the default + branch, so `.github/workflows/outbound-cloudflare-deployed.yml` can receive + `workflow_dispatch` for an exact reviewed implementation SHA. Verify the default-branch + workflow has only the protected-environment manual trigger; do not attempt to dispatch a + workflow definition that exists only on this implementation branch. +- [ ] Phase 0 has also provisioned the protected `outbound-cloudflare-probe` environment, + required reviewers, exact disposable secrets/account, fixed reviewed probe ref, and the + authenticated observable-origin arm/case/observe/delete protocol. Task 4 cannot begin on + workflow presence alone. +- [ ] Re-read spec §§3.1-3.5, 4.1, 4.2, 5.2-5.5, and the Axum/Cloudflare rows in §7. +- [ ] Add `web-time` and independent `test-utils = []` to both adapter manifests. `test-utils` must not enable a runtime feature. +- [ ] Modify only Axum/Cloudflare adapters, root `Cargo.toml`/`Cargo.lock`, `examples/app-demo/Cargo.toml`/`Cargo.lock`, their host fixtures, the shared nonzero-test gate, and `.github/workflows/test.yml`. The protected Cloudflare dispatcher remains the inert default-branch bootstrap workflow; no core semantics or Phase 7 templates/docs. + +## Task Protocol + +For each adapter task: add only the named contract cases; run its exact Task 1 command and require nonzero failing tests; implement through the production driver; rerun to zero failures; run that adapter's target check and `git diff --check`; then stage only listed files and make the stated commit. Mock transport proves orchestration only. Any cancellation, raw-wire, or host-yield claim marked host-observed requires the workerd/deployed fixture before its capability cell is published. Task 4 is the exception to the one-commit task protocol: its protected red/green proof requires two immutable commits with the exact messages named below. Keep both commits reachable and do not amend, rebase, or squash either one after recording its workflow evidence. + +**Required exact test names:** `send_all_preflight_precedence_and_indices`, `send_all_reports_per_slot_elapsed`, `one_slot_send_all_matches_send`, `send_all_starts_every_eligible_exchange`, `request_preparation_consumes_entry_budget`, `adapter_final_dispatch_reapplies_request_normalization`, `canonical_uri_wire_serialization_table`, `redirect_response_is_not_followed`, `streamed_tasks_consume_fast_body_before_slow_headers`, `request_deadline_checks_before_and_after_source_ready`, `response_pipeline_preserves_typed_limits`, `response_content_length_rejects_before_body_poll`, `response_content_length_explicit_identity_rejects_before_body_poll`, `repeated_set_cookie_survives_response_conversion`, `decoder_stalls_timeout_at_all_completion_boundaries`, `axum_205_reads_at_most_once`, `axum_response_conversion_keeps_reactor_live`, `cloudflare_fetch_options_are_raw_manual_abortable_and_no_redirect`, `cloudflare_raw_fetch_rejects_failed_option_set_without_dispatch`, `cloudflare_origin_observes_canonical_host`, `cloudflare_stream_delivers_first_chunk_before_source_eof`, `cloudflare_frozen_clock_forces_host_yield`, `cloudflare_205_reads_at_most_once`, `cloudflare_invalid_utf8_request_header_returns_error_not_panic`, `cloudflare_valid_non_ascii_request_header_survives`, `cloudflare_abort_guard_fires_exactly_once`, and `adapter_capability_matrix_matches_outbound_spec`. + +**Expected red:** contract smoke tests first fail their sentinel assertion; behavior tests then expose serial starts, followed redirects, timeout/cap misclassification, reactor starvation, transformed fetch bytes, absent abort, extra 205 reads, or panic. Host-yield proof fails by timer starvation, not by a fabricated clock advance. + +### Task 1: Establish executable contract seams + +**Files:** +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Modify: `examples/app-demo/Cargo.toml` +- Modify: `examples/app-demo/Cargo.lock` +- Modify: `crates/edgezero-adapter-{axum,cloudflare}/Cargo.toml` +- Modify: `crates/edgezero-adapter-cloudflare/.cargo/config.toml` +- Create: `crates/edgezero-adapter-axum/tests/contract.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` +- Modify/Create: both adapters' `src/test_utils.rs` +- Modify: `.github/workflows/test.yml` + +- [ ] Pin Reqwest and Worker before compiling either bridge. Set root `reqwest = "=0.13.4"`. Set root and app-demo Worker requirements to exact `=0.8.5`, both with `default-features = false` and `features = ["http"]`; make the Cloudflare adapter inherit that workspace dependency while retaining `optional = true`. +- [ ] Refresh both independent lock graphs with `cargo update --offline -p reqwest --precise 0.13.4`, `cargo update --offline -p worker --precise 0.8.5`, `cargo update --offline --manifest-path examples/app-demo/Cargo.toml -p reqwest --precise 0.13.4`, and `cargo update --offline --manifest-path examples/app-demo/Cargo.toml -p worker --precise 0.8.5`. +- [ ] Assert both graphs before adding bridge code: + - `cargo tree --offline --locked -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown | rg 'worker v0\.8\.5'` + - `cargo tree --offline --locked --manifest-path examples/app-demo/Cargo.toml -p app-demo-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown | rg 'worker v0\.8\.5'` +- [ ] Assert both Reqwest graphs resolve exactly 0.13.4: + - `cargo tree --offline --locked -p edgezero-adapter-axum --features axum -e normal | rg 'reqwest v0\.13\.4'` + - `cargo tree --offline --locked --manifest-path examples/app-demo/Cargo.toml -p app-demo-adapter-axum -e normal | rg 'reqwest v0\.13\.4'` +- [ ] Add one deliberately failing smoke test to each native contract module. +- [ ] Remove Cloudflare's whole-file `cfg(all(feature = "cloudflare", target_arch = "wasm32"))`; place it only on the browser SDK module and add a native `test-utils` module, so the native command cannot succeed with zero tests. +- [ ] Replace the Cloudflare crate's stale Preview-1 Cargo config with `[build] target = "wasm32-unknown-unknown"` and an exact `wasm-bindgen-test-runner` for that target. CI installs the runner and executes one crate-local contract command without `--target` or `CARGO_TARGET_*_RUNNER`; a nonzero passing sentinel proves local target resolution consumes the committed config. +- [ ] Run: + - `cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test contract` + - `cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils --test contract` +- [ ] Confirm each command executes a nonzero test count and fails at the assertion, not at linking/imports. +- [ ] Replace smoke failures with feature-gated seams for clocks, timers, transport/body handles, abort events, host yields, and stage delays. Production and fake transports must call the same driver. +- [ ] Commit: `test(adapters): add outbound contract seams`. + +### Task 2: Implement Axum dispatch and batch behavior + +**Files:** +- Create: `crates/edgezero-adapter-axum/src/outbound.rs` beside the temporary legacy `src/proxy.rs` +- Modify: `crates/edgezero-adapter-axum/src/lib.rs` +- Modify: `crates/edgezero-adapter-axum/tests/contract.rs` + +- [ ] Add failing tests for complete batch preflight, empty batch, index alignment, partial failure, one shared `batch_started_at`, distinct per-slot elapsed values, all valid slots started before completion, duplicate request headers, methods, canonical target use, and a 302 response returned without following its `Location`. Advance the clock during normalization/preflight/builder preparation and prove `send`/`send_all` budgets and elapsed values use the method-entry snapshot rather than a post-preflight re-anchor. Preflight failures receive measured elapsed time; an early completed slot retains its own terminal elapsed when a sibling finishes later; backwards injected time yields zero plus Internal, while a legitimate same-tick result may also be zero with its ordinary outcome. A valid one-slot buffered `send_all` must match `send` outcome semantics while its elapsed field is asserted separately. Include GET/HEAD body invalidity beating batch-only stream-mode errors and prove a rejected body source is never polled. +- [ ] In the production Reqwest request captured by the seam, assert exact core serialization for dot segments, percent-encoded delimiters, numeric IPv4 aliases, IDNA input, empty paths, and query preservation. The adapter must never rebuild these values from URL components. +- [ ] Add upload tests for exact/over request cap, source error, stalled pull, timeout before/after readiness, and no further polling after failure. +- [ ] Add `adapter_final_dispatch_reapplies_request_normalization`: mutate `Connection` nominations, `Host`, `Content-Length`, and `Transfer-Encoding` through `headers_mut` after construction, then inspect the production Reqwest request and prove the adapter called `normalize_for_dispatch` immediately before SDK construction. Only adapter-owned framing and the canonical host may remain. +- [ ] Run the Axum contract command; expect failures. +- [ ] Implement `AxumOutboundClient::{send, send_all}`. Capture the monotonic snapshot as the first operation in each public method, before normalization/preflight, and pass it through without re-anchoring. Configure Reqwest with `redirect(Policy::none())`, `no_gzip()`, `no_brotli()`, `no_deflate()`, and `no_zstd()`, and remove the client-wide 30-second timeout. Assert compressed loopback bytes reach only the shared EdgeZero decoder. Insert `Accept-Encoding: identity` only when the normalized request has no caller-supplied `Accept-Encoding`; preserve any caller value for the shared response decoder. +- [ ] Run `validate_for_dispatch` exactly once per request immediately after the method-entry snapshot and before batch-only mode checks, budget selection, body polling, normalization, or Reqwest construction. Batch survivors enter a private already-validated helper so they are not validated twice. +- [ ] Apply `RequestBuilder::timeout(remaining)` immediately before `send`. Race every streamed upload pull against the same absolute budget and buffer only within `max_request_body_bytes`. +- [ ] Use `join_all` after preflight, preserving slot order and independent results. Construct each `OutboundSlotResult` inside that slot's future at its terminal point; never measure after `join_all` returns or copy batch elapsed into all slots. +- [ ] Compile/export the new module for contract tests, but leave production request injection on the legacy client until Task 3 is green. +- [ ] Rerun the contract command; expect success. +- [ ] Commit: `feat(axum): implement outbound dispatch`. + +### Task 3: Implement Axum response pipeline and conversion schedule + +**Files:** +- Modify: `crates/edgezero-adapter-axum/src/outbound.rs` +- Delete: `crates/edgezero-adapter-axum/src/proxy.rs` +- Modify: `crates/edgezero-adapter-axum/src/lib.rs` +- Modify: `crates/edgezero-adapter-axum/src/request.rs` +- Modify: `crates/edgezero-adapter-axum/src/response.rs` +- Modify: `crates/edgezero-adapter-axum/src/service.rs` +- Modify: `crates/edgezero-adapter-axum/tests/contract.rs` + +- [ ] Add loopback-origin tests for repeated headers including multiple `Set-Cookie` values, raw malformed nomination/encoding lines, all content-encoding policy rows, cumulative adapter-visible upstream header count/bytes before normalization plus the synthetic proxy marker, encoded bytes, decoded output, Brotli window/decoder-state charge, and independent final Buffered limits, gzip members/native EOF, bodyless/205 cases, timeout races, future/source drop on cancellation/error/cap, and non-2xx success. Use unequal decoded/final caps in both directions and prove raw passthrough bypasses only the decoded cap. Exercise HTTP/1 and HTTP/2 separately: HTTP/1 may close its connection, while HTTP/2 resets only the request stream and may retain the pooled connection. Do not assert a generic connection drop, source allocation bound from `max_chunk_bytes`, or bounded origin observation. +- [ ] Add identity/compressed/passthrough `Content-Length` cases proving the shared pre-poll helper runs after normalization: encoded caps apply to every coding; decoded and final Buffered caps reject early for effective identity; raw passthrough compares encoded plus final Buffered but never decoded; compressed-to-decode compares only encoded. Every rejection leaves the loopback body unread. Include an explicit `Content-Encoding: identity` overage. +- [ ] Classify loopback DNS/connect/TLS establishment failures before a response head as `BadGatewayReason::Unreachable`, failures after connection progress as `Transport`, framing/completion as `Protocol`, and EdgeZero-owned JSON/gzip/Brotli failures with their exact `BadGatewayDecodeReason`. Never infer a reason by parsing diagnostic text; unavailable phase evidence falls back to `Unspecified`. +- [ ] For Axum 205, prove positive visible length aborts without reading and absent/zero length performs at most one native read: EOF succeeds; an empty or nonempty item aborts without a second read. +- [ ] In both Buffered and Streamed modes, stall gzip and Brotli before decoded output, midstream, and after codec EOF but before native EOF. Every case must return the attributed 504, retain typed late source/completion errors, and keep cleanup armed until native EOF. +- [ ] Add the streamed fan-out regression: join tasks that each perform `send` and immediately consume the body; the fast body must finish before a sibling's delayed headers. A control that joins header-only sends then delays consumption must fail. +- [ ] Add exact and one-byte-over `AXUM_RESPONSE_STREAM_BUFFER_BYTES = 16 MiB` conversion cases, preserving the original `EdgeError` status/kind. +- [ ] Add a reactor-progress regression proving no nested runtime/blocking deadlock. +- [ ] Add optional live HTTP/2 characterization through the deployed proxy path and HTTP/3 only when that production client feature is enabled. Record observed stream reset/connection reuse behavior without making it a blocking portable guarantee; the deterministic contract is guest-visible timeout plus request/body/source future drop. +- [ ] Run the Axum contract command; expect failures. +- [ ] Feed Reqwest `Response::chunk()` lazily through the core pipeline; retain method/mode/all policy fields before consuming request parts. +- [ ] Make response conversion async. Await routing and conversion inside exactly one `block_in_place(|| Handle::block_on(async { ... }))` boundary. +- [ ] On conversion failure, emit `EdgeError::into_response()`; never replace typed 502/504 errors with a generic 500. +- [ ] Switch request extensions to `HttpClient`, remove the adapter's legacy module, and do not leave a module/type alias. Do not change inbound buffering except compile-required typed stream conversion. +- [ ] Rerun Axum contract and crate tests; expect success. +- [ ] Commit: `feat(axum): enforce outbound response contracts`. + +### Task 4: Prove a Cloudflare host-event yield primitive + +**Files:** +- Create/Modify: `crates/edgezero-adapter-cloudflare/tests/host/outbound.mjs` +- Create: `crates/edgezero-adapter-cloudflare/tests/host/deployed.mjs` +- Create: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml` +- Create: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.lock` +- Create: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/wrangler.toml` +- Create: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/src/lib.rs` +- Create: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/README.md` +- Modify: `crates/edgezero-adapter-cloudflare/package.json` +- Create: `crates/edgezero-adapter-cloudflare/package-lock.json` + +- [ ] Put an empty `[workspace]` table in the fixture `Cargo.toml` so Cargo treats it as a standalone nested workspace rather than an undeclared member of the repository workspace. +- [ ] Pin the fixture's Worker dependency to exact `=0.8.5`. Generate and commit its independent Rust lock with `cargo check --offline --manifest-path crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml`; then require `cargo metadata --offline --locked --format-version 1 --manifest-path crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml` and `cargo tree --offline --locked --manifest-path crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml | rg 'worker v0\.8\.5'` before `worker-build`. +- [ ] Pin fixture `compatibility_date = "2023-05-01"` and no additional compatibility flags, exactly matching the generated production `wrangler.toml.hbs`. Add a fixture assertion that parses both files and fails if their date/flags diverge. +- [ ] Pin `worker-build = 0.8.3` in the fixture instructions/CI setup and add package script + `build:outbound-fixture` running + `worker-build --release tests/fixtures/outbound-worker`. The positional path selects the + nested fixture crate; do not pass `--manifest-path`, which is only a forwarded Cargo option + after `worker-build` has already selected a crate. Record `worker-build --version` and + `wrangler --version` in the probe output. +- [ ] Add exact package scripts: `test:workerd` invokes `node tests/host/outbound.mjs --mode workerd`; `test:deployed-timing` invokes `node tests/host/deployed.mjs`. Neither script may turn absent credentials, zero probes, or unsupported host behavior into success. +- [ ] Generate and commit the adapter's first npm lockfile with `npm --prefix crates/edgezero-adapter-cloudflare install --package-lock-only --ignore-scripts`; subsequent host jobs use `npm ci` and may not float Wrangler independently of that lock. +- [ ] Build a deployed timing probe with a frozen guest clock and a continuously-ready stream. Compare candidate host-event yields; microtask/self-wake/immediately-ready futures are invalid. +- [ ] Establish an executable red in commit `test(cloudflare): add failing host-yield candidate`: + use a microtask/self-wake/immediately-ready yield, set `RED_SHA=$(git rev-parse HEAD)`, and + push that exact tree with + `git push origin HEAD:refs/heads/outbound-probe-reviewed`; the protected ref must accept it + only as a fast-forward. Dispatch the + default-branch workflow with + `RED_RUN_URL="$(gh workflow run outbound-cloudflare-deployed.yml --ref main -f commit_sha="$RED_SHA")"`, + derive `RED_RUN_ID="${RED_RUN_URL##*/}"`, reject an empty/non-URL result, and run + `gh run watch "$RED_RUN_ID" --exit-status`. Require that command to return nonzero because + the named frozen-clock timer probe starves while every + setup and positive-control probe executes. Missing credentials, zero probes, build failure, + or an unrelated assertion is not the expected red. Record the workflow URL and `RED_SHA` + before changing the reviewed ref. +- [ ] Select one candidate host-event primitive and freeze one quota in `1..=64` in commit + `test(cloudflare): characterize outbound host yielding`. Set + `GREEN_SHA=$(git rev-parse HEAD)`, push that exact tree to the same reviewed ref with + `git push origin HEAD:refs/heads/outbound-probe-reviewed`, require the update to be a + fast-forward from the red commit, and dispatch with + `GREEN_RUN_URL="$(gh workflow run outbound-cloudflare-deployed.yml --ref main -f commit_sha="$GREEN_SHA")"`, + derive `GREEN_RUN_ID="${GREEN_RUN_URL##*/}"`, reject an empty/non-URL result, and require + `gh run watch "$GREEN_RUN_ID" --exit-status` to succeed. Require every named probe plus a + positive count to pass, including timer and abort delivery under the frozen guest clock. + Record the passing workflow URL, `GREEN_SHA`, primitive, quota, runtime, and observed bound + before starting Task 5. The green commit is Task 4's final commit; do not create a third + summary commit or rewrite either evidence-bearing SHA. +- [ ] If no candidate passes on the pinned Worker runtime, STOP Phase 4 and downgrade/re-review the Cloudflare deadline capability before implementation. + +### Task 5: Implement the Cloudflare target-neutral driver + +**Files:** +- Create: `crates/edgezero-adapter-cloudflare/src/outbound.rs` beside the temporary legacy `src/proxy.rs` +- Create: `crates/edgezero-adapter-cloudflare/src/outbound/worker.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/lib.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` + +- [ ] Add failing native tests for full batch preflight/order/isolation, one-slot `send_all`/`send` outcome equivalence, one method-entry `batch_started_at`, distinct per-slot elapsed values including preflight failures, upload caps/errors/timeouts, send/body timeout races, encoded/decoded/final-cap fairness, frozen-clock terminal decisions, abort ownership, every early-return/drop path, and 3xx visibility without redirect dispatch. Advance the injected clock during request preparation and prove that elapsed time consumes the original `send`/`send_all` budget. Complete one slot early and another late, then prove the first elapsed value was captured at its own terminal point rather than batch return. Include backwards-clock zero-plus-Internal and legitimate same-tick zero cases, GET/HEAD body errors beating batch-only errors, and no rejected-source polls. +- [ ] Through the target-neutral `NativeBody`/header seam, cover every content-encoding row, effective-identity and raw-passthrough pre-poll rejection including explicit `identity`, unequal encoded/decoded/final caps, Brotli decoder-state charge, cumulative adapter-visible upstream header sections before normalization plus the synthetic proxy marker, repeated `Set-Cookie`, 205 settlement, first-chunk-before-EOF, typed completion, and decoder/cap/deadline ownership. These tests own orchestration; Task 7 repeats Workers-only SDK response boundaries under workerd. +- [ ] Prove typed 502 classification at each seam: provider-confirmed DNS/connect/TLS establishment before a response head is `Unreachable`, later body/completion transport is `Transport`, framing is `Protocol`, and shared JSON/gzip/Brotli decoding preserves exact coding identity. A generic Promise rejection without phase evidence is `Unspecified`, not guessed from its message. +- [ ] Capture the exact canonical string supplied to `worker::Request` and assert core + serialization for dot segments, percent-encoded delimiters, numeric IPv4 aliases, IDNA + input, empty paths, and queries; no bridge-side URL reconstruction is allowed. Do not call + this the final wire URL: the Web API may perform a second WHATWG parse/serialization. + Task 7 separately asserts the effective origin-observed target and Host against the + corresponding canonical semantics without requiring byte equality to the input string. +- [ ] Run the Cloudflare native contract command; expect failure. +- [ ] Implement one driver parameterized by `Clock`, `Timer`, `HostYield`, `RawFetch`, `NativeBody`, and `AbortHandle`. Capture the clock as the first operation in each public send method, before normalization/preflight, and never re-anchor. Count ready items including empty chunks; preserve quota state across polls and reset only after the selected host event completes. +- [ ] Run `validate_for_dispatch` exactly once per request immediately after the method-entry snapshot and before batch-only checks, budget selection, body polling, normalization, or Worker request construction. Batch survivors enter a private already-validated helper. +- [ ] Keep the owning abort guard armed through decoder completion and native EOF. Disarm only on full success. +- [ ] Implement concurrent eligible `send_all` exchanges with complete preflight and stable indices. Each slot constructs its timed result at preflight failure or inside its exchange future immediately when terminal; vector assembly performs no timing measurement. +- [ ] Compile/export the new module for contracts, but leave production request injection on the legacy client until Task 6 is green. +- [ ] Rerun native contract tests; expect success. +- [ ] Commit: `feat(cloudflare): add outbound exchange driver`. + +### Task 6: Add Worker raw fetch and response bridges + +**Files:** +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound/worker.rs` +- Delete: `crates/edgezero-adapter-cloudflare/src/proxy.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/lib.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/request.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/response.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` + +- [ ] Add browser WASM tests that inspect `RequestRedirect::Manual` on the constructed `request.inner()` separately from `signal` plus `encodeResponseBody: "manual"` on the final fetch initializer; the latter must not reset redirect. Preserve request header list semantics and compile/exercise only portable Web API bridge construction. +- [ ] Do not call `worker::Headers::get_all` or claim real Worker SDK response conversion in the browser runner: Worker 0.8.5 binds the Workers-only `Headers.getAll()` API without a catch boundary. Compile the production response bridge here, but execute its real header/body conversion, repeated `Set-Cookie`, and checked completion assertions in Task 7's workerd fixture. +- [ ] Keep payload-length/decode/cap/body-disposition behavior in the target-neutral native suite. The workerd suite repeats the SDK boundary cases proving malformed/conflicting lengths and encoded/effective-identity overages reject before a Worker body stream is polled. +- [ ] Ensure the production bridge composes the same native-tested pipeline in both Buffered and Streamed modes. Task 7 runs real Worker cases stalled before decoded output, midstream, and after codec EOF but before native EOF; those cases must retain attributed 504, typed late source/completion errors, and exactly-once abort ownership. +- [ ] Add the same joined send-plus-immediate-body-consumption regression as Axum to the native driver suite. The first response chunk before source EOF is repeated through the real Worker response bridge in Task 7; collecting the whole body is not sufficient evidence. +- [ ] Add `adapter_final_dispatch_reapplies_request_normalization` at the raw-fetch boundary with post-construction `Connection` nominations, stale `Host`, `Content-Length`, and `Transfer-Encoding`. Assert stale Host is removed, no replacement Host header is inserted, and the exact canonical URL string is supplied when constructing the Web request. For headers, exercise request list semantics, preserve valid non-ASCII UTF-8 strings such as `café`, reject a raw invalid-UTF-8 value with an error rather than a panic, and assert only Cloudflare's visible normalized response-string baseline without claiming unavailable raw octets or final wire-URL byte equality. +- [ ] Inject raw-JS option-set failures and prove both a thrown `Reflect::set` and `Ok(false)` return typed Internal without invoking fetch. Also test checked JS-response conversion failure. +- [ ] The browser command must list and execute a nonzero portable bridge sentinel, but it is compile evidence only for the Workers-specific response path. Task 7's workerd suite is the required execution gate for that path. +- [ ] Run the WASM contract command; expect failure before implementation and nonzero success afterward. +- [ ] Implement lazy `Body::Stream` output and the raw JS/Web fetch bridge. Do not add direct Web dependencies unless the pinned Worker re-exports prove insufficient. +- [ ] Switch request extensions to `HttpClient`, delete the legacy module, and leave no module/type alias. +- [ ] Remove production `brotli`/`flate2` only after no production callsite remains. +- [ ] Commit: `feat(cloudflare): bridge raw outbound fetches`. + +### Task 7: Land host evidence and CI gates + +**Files:** +- Modify: `.github/workflows/test.yml` +- Modify: `crates/edgezero-adapter-axum/src/cli.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/cli.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/host/outbound.mjs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/host/deployed.mjs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/src/lib.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/wrangler.toml` +- Modify: `crates/edgezero-adapter-cloudflare/package.json` +- Modify: `crates/edgezero-adapter-cloudflare/package-lock.json` +- Modify: `crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/README.md` +- Create: `scripts/run_test_nonzero.sh` + +- [ ] Run workerd and deployed-origin tests for real Worker SDK response conversion, Workers-only `Headers.getAll("set-cookie")`, repeated `Set-Cookie`, origin-observed effective URL/Host agreement after the Web API's WHATWG normalization, send-future/body cancellation, early consumer drop, cap/decode failure, first chunk before source EOF, gzip/br decoding exactly once in Buffered and Streamed modes, effective-identity `Content-Length` rejection before body polling, the complete passthrough encoding matrix, raw upstream receipt under manual fetch encoding, downstream `EncodeBody::Manual`, 205 settlement, and selected host-yield/frozen-clock timing. Include dot-segment, numeric-IPv4, IDNA, percent-encoding, empty-path, and query rows. No browser-only result substitutes for any of these cases. +- [ ] Make `tests/host/outbound.mjs` require every exact probe identifier, print `PASS ` for each, print a positive final count, and fail on missing/duplicate/zero probes. Include `repeated_set_cookie_survives_response_conversion`, `cloudflare_origin_observes_canonical_host`, `cloudflare_stream_delivers_first_chunk_before_source_eof`, `response_content_length_explicit_identity_rejects_before_body_poll`, `cloudflare_205_reads_at_most_once`, and `cloudflare_frozen_clock_forces_host_yield`. +- [ ] Record runtime/tool versions and observed bounds in fixture output or adjacent README comments. Mocks do not satisfy this gate. +- [ ] Before `worker-build`, CI runs the fixture's locked metadata and Worker tree assertions from Task 4. Install the fixture builder with `cargo install worker-build --version 0.8.3 --locked`; verify `worker-build --version` before invoking `build:outbound-fixture`. Assert the fixture's compatibility date/flags match the production template. The README local command and CI use those same exact versions/settings. +- [ ] Keep deterministic workerd coverage in the normal untrusted `pull_request` workflow. + Use the already-merged default-branch + `.github/workflows/outbound-cloudflare-deployed.yml` bootstrap dispatcher with **only** + `workflow_dispatch`, `contents: read`, and the protected `outbound-cloudflare-probe` + environment; never use `pull_request_target`. It requires `commit_sha`, checks out that + exact full SHA, and fails unless `git rev-parse HEAD` is byte-for-byte equal before any + probe command receives secrets. Do not modify the workflow on this implementation branch + and mistake that branch-only file for executable dispatch infrastructure. +- [ ] The protected job uses only a disposable Workers account and independently observable disposable origin. Define exact secrets `CLOUDFLARE_API_TOKEN` (Workers Scripts edit limited to that account), `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_WORKERS_SUBDOMAIN`, `OUTBOUND_PROBE_ORIGIN_URL`, and `OUTBOUND_PROBE_ORIGIN_TOKEN`; production account tokens, zones, routes, and origins are forbidden. Install Node/Rust from `.tool-versions`, run `npm ci`, assert both lock graphs and Worker 0.8.5, install `worker-build 0.8.3 --locked`, assert tool versions, build the fixture, then run `npm --prefix crates/edgezero-adapter-cloudflare run test:deployed-timing`. +- [ ] Implement the Phase 0-frozen origin protocol in the fixture README and driver: + authenticated `POST /v1/probes/{run_id}/arm` creates isolated state; Worker requests use + `/v1/probes/{run_id}/{case_id}` for raw bytes, delayed headers/chunks, stalled upload reads, + and cancellation; authenticated `GET /v1/probes/{run_id}/observations` returns timestamped + bytes/EOF/disconnect facts; authenticated `DELETE /v1/probes/{run_id}` removes them. Every + request carries an unguessable run ID and token. The driver creates a unique Worker name + from `GITHUB_RUN_ID` + `GITHUB_RUN_ATTEMPT`, polls its `workers.dev` URL, requires every + exact probe ID plus a positive final count, and verifies cancellation/timing from origin + observations rather than client timing alone. Changing that protocol requires updating and + re-reviewing Phase 0 before dispatch. +- [ ] Put Worker deletion and origin-state deletion in `deployed.mjs` `finally` blocks. A failed assertion must still attempt both cleanups; a cleanup failure is reported and fails the job. The script must reject missing variables before deployment and must never reuse a fixed Worker name or observation namespace. +- [ ] Create executable POSIX `scripts/run_test_nonzero.sh `. It first invokes the command with `-- --list`, requires the sentinel as the exact terminal test-name component (an optional Rust module prefix ending in `::` is allowed) and at least one listed test, then invokes the command normally; list or execution failure propagates. Match a listed test identifier, not arbitrary diagnostic text. Use it for every native, browser-WASM, and capability-table Cargo test added to CI so cfg/feature drift cannot turn a gate into zero-test success. +- [ ] Add native Axum/Cloudflare commands and browser-WASM `test-utils` activation to CI through that helper. Add and execute explicit capability-table unit commands so feature-gated `cli.rs` tests run rather than merely compile under clippy: + - `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` + - `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] Add the generated Cloudflare adapter target check to CI: + `(cd examples/app-demo && cargo check --offline --locked -p app-demo-adapter-cloudflare --no-default-features --features cloudflare --target wasm32-unknown-unknown)`. +- [ ] Keep the workerd host suite and the trusted-environment deployed cancellation/timing probe as named CI jobs. Capability publication is blocked unless the recorded deployed job passes; native mocks or a credential-skipped zero-test result cannot substitute. +- [ ] For fork PRs, run only non-secret native/browser/workerd jobs automatically. A maintainer reviews the exact commit, reproduces and pushes that tree to `refs/heads/outbound-probe-reviewed`, dispatches the protected workflow for its SHA, and records the successful workflow URL and SHA on the PR. A skipped or mismatched-SHA run cannot promote Cloudflare's BestEffort timing/upload cells or its Native lazy-stream cell. +- [ ] Add table-driven capability tests and publish the overrides only now. Axum: Native for HTTP, header fidelity, deadlines, flexible phase budget, slot isolation, upload deadlines, and lazy response passthrough; Unsupported for complete resource accounting. Cloudflare: Native for HTTP, flexible phase budget, slot isolation, and lazy response passthrough; BestEffort for deadlines, header fidelity, and upload deadlines; Unsupported for complete resource accounting. Both wildcard future capabilities to Unsupported. +- [ ] Commit: `ci: enforce axum and cloudflare outbound contracts`. + +## Phase Verification + +- [ ] `scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test contract` +- [ ] `scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils --test contract` +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `env CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner scripts/run_test_nonzero.sh cloudflare_fetch_options_are_raw_manual_abortable_and_no_redirect cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --test contract` +- [ ] `(cd crates/edgezero-adapter-cloudflare && ../../scripts/run_test_nonzero.sh cloudflare_fetch_options_are_raw_manual_abortable_and_no_redirect cargo test --offline --locked --no-default-features --features cloudflare,test-utils --test contract)`; no target or runner override is permitted. +- [ ] `cargo check --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare --target wasm32-unknown-unknown` +- [ ] `(cd examples/app-demo && cargo check --offline --locked -p app-demo-adapter-cloudflare --no-default-features --features cloudflare --target wasm32-unknown-unknown)` +- [ ] `cargo metadata --offline --locked --format-version 1 --manifest-path crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml` +- [ ] `cargo tree --offline --locked --manifest-path crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml | rg 'worker v0\.8\.5'` +- [ ] `npm ci --prefix crates/edgezero-adapter-cloudflare` +- [ ] `npm --prefix crates/edgezero-adapter-cloudflare run build:outbound-fixture` +- [ ] `npm --prefix crates/edgezero-adapter-cloudflare run test:workerd` +- [ ] Push the reviewed phase HEAD to `refs/heads/outbound-probe-reviewed`, dispatch the + default-branch `.github/workflows/outbound-cloudflare-deployed.yml` with + `commit_sha=$(git rev-parse HEAD)`, and require a successful workflow URL whose checked-out + SHA matches exactly. Directly running the credentialed npm script is not a substitute for + protected-environment, exact-SHA, and cleanup-path evidence. +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- [ ] `(cd examples/app-demo && cargo test --locked --workspace --all-targets)` +- [ ] `git diff --check` + +Expected result: Axum and Cloudflare no longer expose adapter `proxy` modules and publish their reviewed capability rows atomically with passing contracts. Cloudflare timing and upload remain `BestEffort` until matching-SHA host-observed evidence justifies promotion. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase5-spin.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase5-spin.md new file mode 100644 index 000000000..6bef558c7 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase5-spin.md @@ -0,0 +1,201 @@ +# Outbound HTTP Phase 5: Spin WASI HTTP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Status:** Implemented on PR 275 with the repository-pinned WASI SDK-resource runner. Provider teardown remains `BestEffort`; the unchecked steps below are retained as the original implementation record. +> **Superseded batch/lifecycle API:** The 2026-09-15 consumer-alignment plan replaces this +> plan's `send_all` API and test sentinels. Retain the text only as implementation history. + +**Goal:** Implement Spin outbound HTTP with owned WASI resources, exact request/response completion, typed error classification, cooperative fairness, and a real executable SDK-resource test gate. + +**Architecture:** A target-neutral state-machine driver is exercised natively; a thin Spin SDK layer supplies real WASI HTTP resources. Upload, send, request completion, response body, trailers, and caller-result handles remain owned until their specified terminal branch. One outer monotonic race covers the complete buffered exchange; streamed bodies retain the original deadline. + +**Tech Stack:** exact manifest-pinned Spin SDK 7.0.0, WASI HTTP 0.3, `web-time`, `futures`, wasm32-wasip2, validated Wasmtime runner. + +> **Readiness record:** Task 0's nonzero SDK-resource gate passed and the exact runner is frozen in crate-local Cargo configuration and CI. This note records the original dependency of Tasks 1-6; it is no longer a blocker. + +--- + +## Preconditions + +- [ ] Phases 1b-4 pass all gates. +- [ ] Re-read spec §4.4, Spin rows in §§5.2-5.5, and Spin file summary in §7. +- [ ] Keep capability support BestEffort for deadlines, flexible phase budgets, streamed-upload deadlines, and lazy response passthrough. No fake-resource test may promote these cells. + +## Task Protocol + +For each task: add only the named native or SDK-resource tests; run the exact native or pinned WASM command and require a nonzero failure from missing behavior; implement through the same production state machine; rerun focused and package tests; run `git diff --check`; then stage only listed files and make the stated commit. Resource ownership assertions use counters/defaulted completion writers and simultaneous-readiness scripts, never prose-only reasoning. + +After Task 0, "run native contracts" means: +`scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features test-utils --test contract`. +"Run SDK resources" and "run WASM contracts" mean invoking Cargo from +`crates/edgezero-adapter-spin` so its validated `.cargo/config.toml` is loaded: +`../../scripts/run_test_nonzero.sh spin_error_code_table_is_exhaustive cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources` +and +`../../scripts/run_test_nonzero.sh response_caller_result_succeeds_only_after_native_eof cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test contract`. +Every command must execute a nonzero test count; a target/linker error or zero tests is failure. + +**Required exact test names:** `sdk_fields_preserve_duplicate_values`, `sdk_request_options_execute_all_setters`, `spin_error_code_table_is_exhaustive`, `spin_timeout_provenance_before_and_after_deadline`, `send_all_preflight_precedence_and_indices`, `send_all_reports_per_slot_elapsed`, `one_slot_send_all_matches_send`, `request_preparation_consumes_entry_budget`, `adapter_final_dispatch_reapplies_request_normalization`, `canonical_uri_wire_serialization_table`, `upload_failure_wins_simultaneous_send`, `request_done_error_wins_retained_send`, `reader_gone_never_polls_request_done`, `request_trailers_succeed_only_on_clean_eof_or_reader_gone`, `response_caller_result_succeeds_only_after_native_eof`, `response_content_length_rejects_before_body_poll`, `response_content_length_explicit_identity_rejects_before_body_poll`, `repeated_set_cookie_survives_response_conversion`, `decoder_stalls_timeout_at_all_completion_boundaries`, `raw_and_decoded_quotas_yield_before_item_65`, `streamed_tasks_consume_fast_body_before_slow_headers`, `streamed_body_races_each_pull_against_remaining_budget`, `downstream_fallback_preserves_typed_error_envelope`, and `adapter_capability_matrix_matches_outbound_spec`. + +**Expected red:** Task 0 fails at WASI import/resource execution until the runner is correct; later tests expose wrong poll priority, dropped/early-success completion handles, item 65 without Pending, unbounded streamed pull, or generic response errors. A trap caused by missing imports is a Task 0 failure, never a skipped/pass result. + +**Exchange skeleton:** implement these states directly; do not replace them with an unbiased `select`. + +```rust +enum ExchangeState { + AwaitingRequestDone { request_done: RequestDone, send: StoredSend }, + ReaderGone { request_done: RequestDone, send: SendFuture }, + Uploading { pump: UploadPump, request_done: RequestDone, send: SendFuture }, +} +``` + +`Uploading` polls one pump step first and polls send only after that step returns Pending. `AwaitingRequestDone` polls completion first while retaining ready send. `ReaderGone` never polls completion, waits for send, then drops completion before response conversion. + +### Task 0: Prove the SDK-resource runner or STOP + +**Files:** +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Modify: `examples/app-demo/Cargo.toml` +- Modify: `examples/app-demo/Cargo.lock` +- Create: `crates/edgezero-adapter-spin/tests/sdk_resources.rs` +- Modify: `crates/edgezero-adapter-spin/Cargo.toml` +- Modify after proof: `crates/edgezero-adapter-spin/.cargo/config.toml`, `.tool-versions`, `.github/workflows/test.yml` + +- [x] Pin root and app-demo `spin-sdk` requirements to exact `=7.0.0`; the adapter's exhaustive WASI HTTP classifier makes SDK upgrades explicit review events. +- [x] Refresh both independent locks with the exact manifest requirements using `cargo check --offline -p edgezero-adapter-spin --features spin --target wasm32-wasip2` and `cargo check --offline --manifest-path examples/app-demo/Cargo.toml -p app-demo-adapter-spin --features spin --target wasm32-wasip2`; these dependency-refresh commands are intentionally unlocked. Assert both locked trees report exactly `spin-sdk v7.0.0`. +- [ ] Add the independent `test-utils = []` feature without enabling `spin`; Task 1 adds the driver dependencies. +- [ ] Add nonzero WASM tests that construct real `Fields`, append duplicate fields, construct `RequestOptions`, call all three timeout setters, construct `Request`, and exercise request/response completion resources without invoking an external origin. +- [x] Use the repository-pinned Wasmtime 48.0.2 runner: + +```sh +CARGO_TARGET_WASM32_WASIP2_RUNNER='wasmtime run -W component-model-async=y -S p3=y -S http=y' \ + scripts/run_test_nonzero.sh sdk_fields_preserve_duplicate_values \ + cargo test --offline --locked -p edgezero-adapter-spin --no-default-features \ + --features spin,test-utils --target wasm32-wasip2 --test sdk_resources +``` + +- [ ] Record the exact runtime version, flags, Rust target/component setup, test count, and passing output. Put the verified runner in the crate-local `.cargo/config.toml`; CI must use the same value. `-S http=y -S p3=y` is necessary evidence, not assumed sufficient evidence. +- [ ] From `crates/edgezero-adapter-spin`, rerun without a runner environment override: `../../scripts/run_test_nonzero.sh sdk_fields_preserve_duplicate_values cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources`; require the same nonzero pass. This proves the committed crate-local Cargo configuration is the configuration later tasks and CI consume. +- [ ] If the pinned runtime cannot execute these resources, STOP. Select and review a compatible exact runtime pin before changing `.tool-versions` and CI together. Compilation, native fakes, and zero-test success do not pass this gate. +- [ ] Commit after proof: `test(spin): execute WASI HTTP SDK resources`. + +### Task 1: Establish the native driver seam and error classifier + +**Files:** +- Modify: `crates/edgezero-adapter-spin/Cargo.toml` +- Create: `crates/edgezero-adapter-spin/src/outbound.rs` beside temporary `src/proxy.rs` +- Modify: `crates/edgezero-adapter-spin/src/lib.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` +- Modify: `crates/edgezero-adapter-spin/tests/sdk_resources.rs` + +- [ ] Add direct `web-time`; keep `test-utils` and `spin` independent. +- [ ] Remove the whole-file WASM gate from `tests/contract.rs`; create native and SDK modules. Confirm the native command executes a nonzero deliberately failing test. +- [ ] In the native contract, test only the target-neutral classifier policy and driver seam; do not claim that a local mirror proves the SDK enum boundary. +- [ ] In `sdk_resources.rs`, add the exhaustive table over all 39 real pinned `spin_sdk::wasip3::http::types::ErrorCode` variants with no wildcard. At or after the absolute deadline, every simultaneous SDK error maps to an attributed 504. Before expiry, all five provider timeout variants map to 504 with `BudgetSource::Unspecified`; caller request policy/size maps to 400; local invariants to 500; pre-response DNS/connect/TLS establishment to `Unreachable`; later connection/read/write failure to `Transport`; response framing/protocol to `Protocol`; and host internal to unspecified 502. Shared decoder tests preserve exact JSON/gzip/Brotli identity. Exercise the entire error table before and after deadline expiry. Invoke all three real request-option setters and record/assert the actual pinned Wasmtime outcome; this suite must not claim it can force host-selected `NotSupported` results. +- [ ] Test the real SDK conversion through `client::send`, `request_done`, and response completion paths under `spin,test-utils`. Non-2xx HTTP statuses remain successful responses. +- [ ] Run native contracts and require the deliberate assertion failure. From `crates/edgezero-adapter-spin`, run `cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources spin_error_code_table_is_exhaustive`; expect a nonzero compile/assertion failure from the missing classifier. +- [ ] Implement target-neutral traits/resources and `map_spin_send_err(err, deadline, cause)`. It checks absolute expiry before classifying the SDK variant and has no wildcard for the pinned exhaustive enum; any dependency update must revise the mapping and tests in the same change. +- [ ] Run native contracts; expect success, then run SDK resources filtered to `spin_error_code_table_is_exhaustive`; expect a nonzero pass against the real SDK type. +- [ ] Commit: `feat(spin): add outbound driver and error classifier`. + +### Task 2: Implement preflight, request conversion, and batch execution + +**Files:** +- Modify: `crates/edgezero-adapter-spin/src/outbound.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` + +- [ ] Add failing tests for empty batch, complete preflight, streamed request/response slot rejection, stable indices, partial errors, one-slot `send_all`/`send` outcome equivalence, one method-entry `batch_started_at`, distinct per-slot elapsed values including measured preflight time, concurrent eligible exchange progress, and sibling isolation. Advance the clock during normalization/preflight/builder preparation and prove that time consumes the original `send`/`send_all` budget. Complete one slot early and another late and prove the early elapsed value is retained; backwards injected time yields zero plus Internal, while legitimate same-tick completion may also be zero with its ordinary outcome. Include GET/HEAD body errors beating batch-only errors and no polls of rejected source streams. +- [ ] In the target-neutral injected conversion suite, add tests for methods, repeated fields via append, request options order, nonzero nanosecond rounding, and every synthetic setter outcome (`NotSupported`, `Immutable`, `Other`). The real SDK-resource suite only executes the setters and asserts the pinned host's actual result; a deployed-host probe is required before claiming a different host accepts or rejects any setter. Capture the final WASI scheme/authority/path-with-query and assert exact core serialization for dot segments, percent-encoded delimiters, numeric IPv4 aliases, IDNA input, empty paths, and queries. +- [ ] Add `adapter_final_dispatch_reapplies_request_normalization`: after `headers_mut` introduces `Connection` nominations plus stale `Host`, `Content-Length`, and `Transfer-Encoding`, inspect the final WASI `Fields` and prove normalization ran immediately before SDK request construction. Only canonical host and adapter-owned framing may remain. +- [ ] Run native contracts; expect failure. +- [ ] Implement `SpinOutboundClient::{send, send_all}` for contract tests. Capture the monotonic snapshot as the first operation in each public method, then run `validate_for_dispatch` exactly once per request before batch-only mode checks, budget selection, body polling, normalization, or SDK construction; never re-anchor. Retain method, mode, and all policy fields before consuming request parts; production injection switches only after Task 5 is green. +- [ ] Treat `RequestOptionsError::NotSupported` as logged BestEffort degradation while retaining the outer deadline race; `Immutable`/`Other` are internal setup failures. +- [ ] Use `join_all` for eligible complete batch slots after full preflight. Construct each `OutboundSlotResult` when its own preflight/exchange becomes terminal; vector assembly must not sample time. +- [ ] Rerun native and SDK-resource tests; expect success. +- [ ] Commit: `feat(spin): dispatch outbound requests`. + +### Task 3: Implement the biased upload/send state machine + +**Files:** +- Modify: `crates/edgezero-adapter-spin/src/outbound.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` + +- [ ] Add failing transition tests for `Uploading`, `AwaitingRequestDone`, and `ReaderGone`; source/cap/deadline failures, partial writes, cancellation during `write_all`, clean EOF, reader-gone, early response, writer/reader drop, trailer-completion failure, and future/resource drop counts. A successful request-trailers `FutureWriter::write(Ok(None))` is the only path from clean EOF to `AwaitingRequestDone`; `FutureWriteError` means the host dropped its reader, transitions to `ReaderGone`, and never polls `request_done`. WASI HTTP 0.3's component-stream writer has no flush operation, so no test or implementation may invent one. +- [ ] Add simultaneous-readiness tests: upload failure beats send; request completion beats retained send; send is observed only after an upload step returns Pending. +- [ ] Prove request trailers attempt `Ok(None)` only for clean EOF or reader-gone and retain the default failure otherwise. Reader-gone ignores a rejected redundant completion write; clean EOF must inspect the write result and follow the transition above. +- [ ] Implement the state machine exactly as §4.4 specifies. Never poll `request_done` in `ReaderGone`; never lose an already-ready send result while awaiting request completion. +- [ ] Yield once after every accepted upload chunk and recheck the absolute deadline before accepting every terminal or data result. +- [ ] Rerun native contracts; expect success. +- [ ] Commit: `feat(spin): own outbound upload completion`. + +### Task 4: Implement response completion, decoding, and fairness + +**Files:** +- Modify: `crates/edgezero-adapter-spin/src/outbound.rs` +- Delete: `crates/edgezero-adapter-spin/src/decompress.rs` +- Modify: `crates/edgezero-adapter-spin/src/lib.rs` +- Modify: `crates/edgezero-adapter-spin/Cargo.toml` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` + +- [ ] Add failing cases for cumulative adapter-visible upstream header limits before normalization plus the synthetic proxy marker, repeated request fields, repeated response `Set-Cookie`, raw malformed response nomination/encoding, 3xx returned without a follow-up request, HEAD/1xx/204/304, every 205 branch, encoded bytes, decoded output, Brotli window/decoder-state charge, independent final Buffered limits, gzip members, Brotli trailing data, typed source/completion errors, and timeout precedence. Unequal decoded/final caps prove raw passthrough bypasses only decoded accounting; `max_chunk_bytes` is tested only as emitted-item shaping. +- [ ] Add identity/compressed/passthrough `Content-Length` cases proving malformed/conflicting values and encoded/effective-identity decoded overages reject before reading the WASI response body; include exactly one bare `Content-Encoding: identity`. Effective identity compares encoded, decoded, and final Buffered caps; raw passthrough compares encoded plus final Buffered but never decoded; compressed-to-decode compares only encoded. +- [ ] In both Buffered and Streamed modes, stall gzip and Brotli before decoded output, midstream, and after codec EOF but before native EOF. Assert attributed 504, no early caller-result success, and preservation of a late typed source/completion error when it wins before expiry. +- [ ] Add the joined send-plus-immediate-body-consumption regression used by Axum/Cloudflare; a fast body must complete while sibling headers remain pending. +- [ ] Add caller-result tests proving the default is failure and `Ok(())` is written only after native EOF, trailers, decoder completion, caps, and deadlines succeed. Separate the actual shapes: the body is a component `stream`; only the response trailers/completion future carries an `ErrorCode` into `map_spin_send_err`; caller-result `write(Ok(()))` returns `FutureWriteError` when the host dropped its reader. Assert protocol 502 for that failed completion handshake while a consumer waits and cleanup-only behavior during wrapper drop. +- [ ] Add continuously-ready raw and decoded streams. Freeze separate quotas at 64; empty items count; state survives polls/`next()`; item 65 is preceded by a cooperative Pending. +- [ ] Replace adapter-local buffered decompression with core's shared streaming pipeline. Remove production `brotli`/`flate2` only after no production callsite remains. +- [ ] For streamed mode, retain body/trailer/caller-result ownership in `Body::Stream`; late errors must surface as error items, never false EOF. Add pre-pull, timer-ready, item-ready, post-ready, and terminal EOF/error tests for the streamed-only monotonic race. +- [ ] Rerun native and SDK tests; expect success. +- [ ] Commit: `feat(spin): complete outbound response streams`. + +### Task 5: Add the outer deadline race and downstream fallback + +**Files:** +- Modify: `crates/edgezero-adapter-spin/src/outbound.rs` +- Delete: `crates/edgezero-adapter-spin/src/proxy.rs` +- Modify: `crates/edgezero-adapter-spin/src/lib.rs` +- Modify: `crates/edgezero-adapter-spin/src/request.rs` +- Modify: `crates/edgezero-adapter-spin/src/response.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` + +- [ ] Add failing tests for send/upload/buffered-drain timeout, simultaneous exchange/timer readiness, post-ready absolute checks, preservation of all four `BudgetSource` values, and the streamed continuation using only `budget.deadline.remaining()` after headers. +- [ ] Race the whole buffered exchange against `spin_sdk::time::sleep(remaining)`. Poll exchange first but check absolute expiry in both branches so expiry wins simultaneous results. +- [ ] For Streamed mode only, read remaining time after header exchange. If expired, drop native response resources and return attributed 504; otherwise install a fresh per-chunk sleep race bounded by that remaining duration while retaining the original absolute deadline for post-ready checks. Do not add a second race to Buffered mode. +- [ ] Rename/freeze `SPIN_RESPONSE_STREAM_BUFFER_BYTES = 16 MiB`. Test exact/over limit and source failure; convert the original typed error envelope before platform headers are committed. +- [ ] Switch request extensions to `HttpClient`, delete the legacy module, and leave no module/type alias. +- [ ] Rerun native contracts and WASM checks; expect success. +- [ ] Commit: `feat(spin): enforce outbound exchange budgets`. + +### Task 6: Land CI execution gates + +**Files:** +- Modify: `.github/workflows/test.yml` +- Modify: `crates/edgezero-adapter-spin/src/cli.rs` +- Modify if validated in Task 0: `.tool-versions` + +- [ ] Add the native contract command. +- [ ] Add the exact validated SDK-resource and WASM contract runner commands, with `spin,test-utils`, through `scripts/run_test_nonzero.sh` using exact sentinels; a successful zero-test command is failure. +- [ ] Keep ordinary Spin WASM compile checks. +- [ ] Add the generated Spin adapter target check: + `(cd examples/app-demo && cargo check --offline --locked -p app-demo-adapter-spin --no-default-features --features spin --target wasm32-wasip2)`. +- [ ] Add and execute `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec`; clippy compilation alone does not execute the feature-gated `cli.rs` table. +- [ ] Add table-driven capability tests and publish Spin's override only now: Native for outbound HTTP, header fidelity, and slot isolation; BestEffort for deadlines, flexible phase budget, streamed-upload deadlines, and lazy response passthrough; Unsupported for complete resource accounting; future capabilities -> Unsupported. +- [ ] Commit: `ci: execute Spin outbound contracts`. + +## Phase Verification + +- [ ] `scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features test-utils --test contract` +- [ ] `(cd crates/edgezero-adapter-spin && ../../scripts/run_test_nonzero.sh response_caller_result_succeeds_only_after_native_eof cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test contract)` using the validated crate-local runner. +- [ ] `(cd crates/edgezero-adapter-spin && ../../scripts/run_test_nonzero.sh spin_error_code_table_is_exhaustive cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources)` using the validated crate-local runner. +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `cargo check --offline --locked -p edgezero-adapter-spin --no-default-features --features spin --target wasm32-wasip2` +- [ ] `(cd examples/app-demo && cargo check --offline --locked -p app-demo-adapter-spin --no-default-features --features spin --target wasm32-wasip2)` +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `(cd examples/app-demo && cargo test --locked --workspace --all-targets)` +- [ ] `git diff --check` + +Expected result: Spin's deterministic contract and real SDK bindings execute in CI, and its conservative capability row lands with them. Host cancellation remains documented BestEffort until a separate live characterization proves a finite bound. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase6-fastly.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase6-fastly.md new file mode 100644 index 000000000..cf94497c4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase6-fastly.md @@ -0,0 +1,251 @@ +# Outbound HTTP Phase 6: Fastly Dispatch and Harvest Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Status:** Deterministic implementation is complete on PR 275. Protected deployed-provider characterization remains an external promotion gate, so Fastly limitations remain `BestEffort`. The unchecked steps below are retained as the original implementation record. +> **Superseded batch/lifecycle API:** The 2026-09-15 consumer-alignment plan replaces this +> plan's ordered-harvest API and test sentinels. Retain the text only as implementation history. +> **Backend-name follow-up:** Current names include a per-client namespace in addition to the +> deterministic identity digest, so separately constructed clients cannot collide in one session. + +**Goal:** Implement Fastly outbound HTTP with dispatch-all-before-wait fan-out, deterministic dynamic backends, host timer derivation, typed errors, streamed upload ownership, and bounded downstream conversion. + +**Architecture:** A target-neutral synchronous engine owns slots, backend identity/cache behavior, timing arithmetic, and cleanup. The SDK layer supplies `PendingRequest`, backend registration, and body handles. Batch dispatch is a distinct first phase; ordered harvest may opportunistically poll later slots but never loses a result. + +**Tech Stack:** Fastly SDK 0.13.1, `web-time`, `sha2`, core typed streams/decoders, native `test-utils`, wasm32-wasip1/Viceroy. + +--- + +## Preconditions + +- [ ] Phases 1b-5 pass all gates. +- [ ] The implementation index's inert workflow-bootstrap PR is merged to the default + branch, so `.github/workflows/outbound-fastly-characterization.yml` can receive + `workflow_dispatch` for an exact reviewed implementation SHA. Do not rely on a workflow + definition introduced only on this implementation branch. +- [ ] Phase 0 has also provisioned the protected `outbound-fastly-probe` environment, + required reviewers, exact disposable secrets, enabled/disabled services, fixed reviewed + probe ref, and authenticated observable-origin protocol. Workflow presence alone is not a + runnable host gate. +- [ ] Re-read spec §4.3, Fastly rows in §§5.2-5.5, and the Fastly file summary in §7. +- [ ] Keep outbound HTTP, deadlines, flexible phase budgets, slot isolation, streamed-upload deadlines, and lazy response passthrough at the exact reviewed BestEffort levels. Dynamic-backend service enablement is not statically provable, so `outbound-http` cannot be published as Native. +- [ ] Confirm `OutboundRequest::host_name()` exists from Phase 1b; never reparse the URI to construct backend identity. +- [ ] Require `viceroy --version` to print exactly the repository-pinned `0.21.0`. Run WASM tests from `crates/edgezero-adapter-fastly` so its committed `.cargo/config.toml` supplies both `wasm32-wasip1` and `viceroy run -C ../../examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml --`; no CI environment override may replace that service configuration. + +## Task Protocol + +For each task: add only the named native or SDK-gated cases; run the exact native/WASM command and require a nonzero failure; implement through the shared production engine; rerun focused and package tests; run `git diff --check`; then stage only listed files and make the stated commit. Fake handles establish stage order and cleanup, not real host cancellation or a finite write bound. + +"Run native contracts" means +`scripts/run_test_nonzero.sh send_all_dispatches_every_slot_before_wait cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils --test contract`. +"Run Fastly WASM contracts/library tests" means the two crate-local `wasm32-wasip1` +commands in Phase Verification with `fastly,test-utils`. Every command must execute a nonzero test count; a +target/linker error or a zero-test result is failure. + +**Required exact test names:** `backend_identity_uses_every_canonical_property`, `backend_creation_error_table_is_exhaustive`, `backend_builder_keeps_pooling_for_identical_identity_and_settings`, `dispatch_guard_checks_expiry_before_slack`, `send_all_dispatches_every_slot_before_wait`, `send_all_reports_per_slot_elapsed`, `one_slot_send_all_matches_send`, `request_preparation_consumes_entry_budget`, `adapter_final_dispatch_reapplies_request_normalization`, `canonical_uri_wire_serialization_table`, `send_all_reverse_completion_preserves_order`, `done_error_survives_poll_sweep`, `send_error_cause_table_preserves_timeout_source`, `response_content_length_rejects_before_body_poll`, `response_content_length_explicit_identity_rejects_before_body_poll`, `response_read_checks_deadline_after_eof`, `repeated_set_cookie_survives_response_conversion`, `decoder_stalls_timeout_at_all_completion_boundaries`, `streamed_upload_finishes_exactly_once`, `streamed_upload_failure_never_waits`, `downstream_fallback_preserves_typed_error_envelope`, and `adapter_capability_matrix_matches_outbound_spec`. + +**Expected red:** the old client waits during dispatch, backend names/settings collide, error variants map to generic 500, an EOF read escapes deadline checks, or failed uploads finish/wait. SDK-gated tests must fail assertions, not report zero tests. + +**Engine skeleton:** + +```rust +enum Slot { + Done(OutboundSlotResult), + Pending(PendingSlot), + Taken, +} + +struct BackendIdentity { + budget_ms: u32, + host: String, + port: u16, + scheme: Scheme, + tls_mode: TlsMode, +} +``` + +Phase one converts every valid slot to `Pending` or `Done`; no wait occurs. Phase two waits in input order, polls later pending slots opportunistically into `Done`, and replaces an unresolved `Taken` with internal error rather than panicking. + +### Task 1: Establish the engine and executable seams + +**Files:** +- Modify: `Cargo.toml` +- Modify: `examples/app-demo/Cargo.toml` +- Modify: `crates/edgezero-adapter-fastly/Cargo.toml` +- Create: `crates/edgezero-adapter-fastly/src/outbound.rs` beside temporary `src/proxy.rs` +- Create: `crates/edgezero-adapter-fastly/src/outbound/{backend,engine,sdk,test_utils}.rs` +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` +- Modify: `Cargo.lock`, `examples/app-demo/Cargo.lock` + +- [ ] Pin root and app-demo `fastly` requirements to exact `=0.13.1`; refresh both locks with `cargo update --offline -p fastly --precise 0.13.1` and `cargo update --offline --manifest-path examples/app-demo/Cargo.toml -p fastly --precise 0.13.1`. Assert both `cargo tree --offline --locked` graphs resolve exactly `fastly v0.13.1` before writing exhaustive SDK mappings. +- [ ] Add direct `web-time` and independent `test-utils = []`; do not enable Fastly through test-utils. +- [ ] Move whole-file WASM gating to the SDK test module. Add a native deliberately failing smoke test and prove the native command executes it. +- [ ] Run `cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils --test contract`; expect a nonzero test count and failure at the smoke assertion, not at linking/imports. +- [ ] Define injectable clock, dispatch-stage callback, backend registrar, synchronous pending/body handles, and hash function. Public retained callbacks must remain `Send + Sync`. +- [ ] Production and tests must call the same generic engine; test code may supply effects, not copy algorithms. +- [ ] Replace the sentinel with a passing seam-construction test and rerun the same native command; require a nonzero test count and success before committing. +- [ ] Commit: `test(fastly): add outbound engine seams`. + +### Task 2: Implement backend identity and host timers + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/outbound/backend.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound/engine.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` + +- [ ] Add failing native tests for DNS/IP/IPv6/plain/TLS identities, default/explicit ports, exact ceil-to-ms budgets, SHA-256 first-128-bit names, dedup, forced hash collision, externally occupied `NameInUse`, and concurrent lookup. Add a production-builder assertion that no `.enable_pooling(false)` override is applied: identical names/settings may use the SDK default pool, while distinct budgets/settings produce distinct identities. +- [ ] Add timer tests: for total >= 4 ms, connect = total/4, first byte = remainder, between = total; below 4 ms, connect/first each equal total. +- [ ] In the Fastly-featured SDK test module, add an exhaustive no-wildcard table over the real `fastly::backend::BackendCreationError`: `Disallowed` -> actionable unspecified 502; `NameInUse` -> cache/collision protocol; all three `*TimeoutTooLarge`, `NameTooLong`, and `EncodingError` -> internal 500; `HostError` -> unspecified 502. Native tests may exercise an adapter-owned policy model but cannot claim coverage of the SDK enum. +- [ ] Add guard-order tests before cold batch registration and immediately before dispatch: absolute expiry -> attributed 504; live-deadline setup over `BATCH_DISPATCH_SLACK_MAX` -> internal 500; no deadline uses the default budget without the live-deadline slack failure. Prove an over-slack later cache miss invokes no additional registration. +- [ ] Run native contracts; expect a nonzero failure. From `crates/edgezero-adapter-fastly`, run `../../scripts/run_test_nonzero.sh backend_creation_error_table_is_exhaustive cargo test --offline --locked --no-default-features --features fastly,test-utils --lib`; expect a nonzero compile/assertion failure from the missing real-SDK classifier. Do not override the committed target/runner. +- [ ] Implement `BackendIdentity(scheme, host_name, resolved_port, tls_mode, budget_ms)` and `ez_` names. Use true ceil-to-millisecond conversion, including a 1 ms + 1 ns -> 2 ms boundary case. Cache name -> `(identity, backend)` behind `Mutex`; never hold the lock across `finish()`. Leave SDK connection pooling at its default: Fastly reuses only same-name, exact-same-settings backends, and the identity/settings already separate budgets and TLS modes. Probe multi-host heterogeneous budgets because each distinct tuple may require synchronous registration. +- [ ] A cache miss followed by `NameInUse` fails closed. Do not recover with string matching or `Backend::from_name`. +- [ ] Implement the complete creation-error mapping separately from `SendErrorCause`; DNS/TLS/connect failures belong only to the send-stage table. +- [ ] Configure SNI only from `sni_hostname`; certificate checking from `cert_host`, including HTTPS IP literals. +- [ ] Rerun native contracts and the filtered Fastly WASM library test; each must execute a nonzero test count and succeed. +- [ ] Commit: `feat(fastly): configure deterministic outbound backends`. + +### Task 3: Implement two-phase send_all + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/outbound/engine.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound/sdk.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` + +- [ ] Add failing cases for empty batch, complete preflight and index alignment, every valid dispatch before first wait, one-slot `send_all`/`send` exact error equivalence, reverse completion with stable order, partial dispatch/wait/body errors, preservation of pre-existing `Done` results during poll sweeps, both still-pending and later-ready poll branches, one method-entry `batch_started_at`, distinct per-slot elapsed values, and unresolved-slot internal error. Advance the clock during normalization/preflight/backend preparation and prove the original `send`/`send_all` snapshot owns the budget and elapsed measurement. Preflight/dispatch failures sample immediately; an opportunistically polled early completion keeps its own elapsed value while an earlier index blocks later vector return; backwards injected time yields zero plus Internal, while legitimate same-tick completion may also be zero with its ordinary outcome. Include GET/HEAD body errors beating batch-only errors and no polls of rejected source streams. +- [ ] Capture the exact SDK request target and assert it matches core serialization for dot segments, percent-encoded delimiters, numeric IPv4 aliases, IDNA input, empty paths, and queries; Fastly may derive backend identity from accessors but must not reconstruct the request URI. +- [ ] Add `adapter_final_dispatch_reapplies_request_normalization`: mutate `Connection` nominations plus stale `Host`, `Content-Length`, and `Transfer-Encoding` through `headers_mut`, then inspect the final Fastly request and prove normalization ran immediately before SDK construction. Only canonical host override and adapter-owned framing may remain. +- [ ] Add serial-harvest cases showing a later slot can expire while waiting behind an earlier slot without cancellation or Native slot-isolation claims. +- [ ] Run native contracts; expect failure. +- [ ] Implement `Slot::{Done, Pending, Taken}`. Capture the monotonic snapshot as the first operation in each public send method, then run `validate_for_dispatch` exactly once per request before batch-only checks, budget selection, body polling, normalization, backend construction, or SDK request construction; never re-anchor. Construct `OutboundSlotResult` at each slot's actual terminal point, including preflight/dispatch errors and immediately after wait/poll harvest; final vector assembly performs no clock read. Issue every valid `send_async` sequentially before the first wait; pinned Fastly 0.13.1 returns when transmission begins and continues buffered upload in the background, so a stalled buffered upload may leave its slot unresolved but must not be modelled as synchronously preventing later dispatch calls. Then wait in input order and opportunistically poll later pending slots. +- [ ] Preserve all policy/method/mode fields in `PendingSlot`. Resolve every output index without panic. Do not use SDK `select()`, which cannot preserve stable slot identity. +- [ ] Harvest every successfully dispatched buffered batch request even when siblings fail. +- [ ] Rerun native contracts; expect success. +- [ ] Commit: `feat(fastly): dispatch fanout before ordered harvest`. + +### Task 4: Map SDK errors and process responses + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/outbound/engine.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound/sdk.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` + +- [ ] Add SDK-gated construction tests for every known public `SendErrorCause` listed in + §4.3, with a mandatory future `_` classifier arm. Split timeout policy into + `BudgetedTimeout` (`ConnectionTimeout`, `HttpResponseTimeout`) and `ProviderTimeout` + (`DnsTimeout`). Pass the absolute deadline, selected cause, and injected observation + instant into the pure classifier; test all four `BudgetSource` values before, exactly at, + and after expiry. Configured phase timers stay attributed; pre-deadline DNS timeout is 504 + with `BudgetSource::Unspecified`; at/after expiry the selected cause wins for every result. +- [ ] Add native response cases for cumulative adapter-visible upstream headers before normalization plus the synthetic proxy marker, repeated request fields, repeated response `Set-Cookie`, raw malformed response nomination/encoding, 3xx returned without a follow-up request, bodyless/205, encoded bytes, decoded output, Brotli window/decoder-state charge, independent final Buffered limits, encoding policy, gzip members/native EOF, errors, deadlines before/after each blocking read including EOF, and drop-on-early-termination. Unequal decoded/final caps prove raw passthrough bypasses only decoded accounting; `max_chunk_bytes` remains an emitted-item guarantee. +- [ ] Add identity/compressed/passthrough `Content-Length` cases proving malformed/conflicting values and encoded/effective-identity decoded overages reject before the first Fastly body read; include exactly one bare `Content-Encoding: identity`. Effective identity compares encoded, decoded, and final Buffered caps; raw passthrough compares encoded plus final Buffered but never decoded; compressed-to-decode compares only encoded. +- [ ] In both Buffered and Streamed modes, stall gzip and Brotli before decoded output, midstream, and after codec EOF but before native EOF. Assert attributed 504 after the blocking read returns, no decoder-end success before native EOF, and late typed source/completion preservation when observed in budget. +- [ ] Run native and Fastly WASM library/contract tests; expect failures. +- [ ] Map configured phase timeout -> attributed 504; early unconfigured DNS timeout -> + unattributed 504; any result at/after absolute expiry -> attributed 504; + DNS/destination/connect/TLS establishment before a response head -> typed 502 + `Unreachable`; later connection/I/O -> `Transport`; response framing -> `Protocol`; + shared JSON/gzip/Brotli failures preserve exact coding identity; local invariant/platform internal -> 500; unspecified + known/future -> unspecified 502 as specified. Define one + `DYNAMIC_BACKENDS_DISABLED_MESSAGE` constant with the exact spec diagnostic and use it for + `BackendCreationError::Disallowed` and the deployed disabled-service assertion. +- [ ] Apply the core response pipeline in order. Drop the owned native body on cap/decode/deadline/consumer termination; do not drain after failure and do not claim finite origin cancellation. +- [ ] Rerun native/WASM tests; expect success. +- [ ] Commit: `feat(fastly): classify and harvest outbound responses`. + +### Task 5: Implement streamed uploads + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/outbound/engine.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound/sdk.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` + +- [ ] Add failing tests for source/cap/pre-read/post-read/write/flush failures, future drop, clean EOF, `finish` exactly once, finish failure, post-finish expiry, and no wait after failed finish. +- [ ] Retain writer and pending handles together. Every failure drops both without `finish`/`wait`; clean EOF finishes once and only then permits wait. +- [ ] Document that source pulls and host writes can stall beyond the cooperative check and that partial bytes may already be delivered. +- [ ] Rerun native/WASM tests; expect success. +- [ ] Commit: `feat(fastly): own streamed outbound uploads`. + +### Task 6: Migrate request injection and downstream conversion + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` +- Delete: `crates/edgezero-adapter-fastly/src/proxy.rs` +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` +- Modify: `crates/edgezero-adapter-fastly/src/response.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` + +- [ ] Inject one `HttpClient::with_client(FastlyOutboundClient::new())` per Fastly session so the backend cache lifetime mirrors the SDK namespace. +- [ ] Delete the legacy module during this switch and leave no module/type alias. +- [ ] Add exact/over `FASTLY_RESPONSE_STREAM_BUFFER_BYTES = 16 MiB` tests and typed stream-error envelope tests. +- [ ] Implement bounded fallback before platform headers are committed. Preserve original status/kind on failure. +- [ ] Rerun adapter and workspace tests; expect success. +- [ ] Commit: `feat(fastly): wire outbound client and response fallback`. + +### Task 7: Land CI gates + +**Files:** +- Modify: `.github/workflows/test.yml` +- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` +- Create: `crates/edgezero-adapter-fastly/tests/host/deployed.sh` +- Create: `crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/Cargo.toml` +- Create: `crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/Cargo.lock` +- Create: `crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/fastly.toml` +- Create: `crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/src/main.rs` +- Create: `crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/README.md` + +- [ ] Add the native contract command and enable `test-utils` in Fastly WASM contract/library runs. Execute every test target through `scripts/run_test_nonzero.sh` with one exact sentinel; retain production target checks. +- [ ] Assert `viceroy --version` is exactly `0.21.0`, then execute both WASM suites from the adapter crate without `CARGO_TARGET_*_RUNNER`; this proves the checked-in runner and demo `fastly.toml` are used locally and in CI. +- [ ] Add the generated Fastly adapter target check: + `(cd examples/app-demo && cargo check --offline --locked -p app-demo-adapter-fastly --no-default-features --features fastly --target wasm32-wasip1)`. +- [ ] Add a standalone deployed-probe crate with an empty `[workspace]`, exact + `fastly = "=0.13.1"`, path dependencies on the production adapter/core, and its own + committed lock. The binary directly exercises `FastlyOutboundClient` for every named + probe case; it must not depend on the not-yet-migrated app-demo handler. Pin the package + name and output to + `tests/fixtures/outbound-fastly/pkg/edgezero-outbound-probe.tar.gz`. Generate the fixture + lock once with + `cargo check --offline --manifest-path crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/Cargo.toml --target wasm32-wasip1`; + subsequent metadata, tree, and build commands are locked. Its `fastly.toml` build script + runs Cargo with `--offline --locked --profile release --target wasm32-wasip1`. +- [ ] Before any protected dispatch, run locked metadata/tree checks for the fixture, require + exactly Fastly SDK 0.13.1, and build without secrets using + `fastly compute build --non-interactive --dir crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly`. + Require the fixed package path to exist. `deployed.sh` accepts only that prebuilt package; + it exits before deployment if the artifact is absent and contains no build, dependency + download, or package-selection fallback while secrets are present. +- [ ] Confirm each command lists and executes nonzero tests. +- [ ] Add and execute `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec`; clippy compilation alone does not execute feature-gated capability tests. +- [ ] Add table-driven capability tests and publish Fastly's override only now: Native for header fidelity; BestEffort for outbound HTTP, deadlines, flexible phase budget, slot isolation, streamed-upload deadlines, and lazy response passthrough; Unsupported for complete resource accounting; future capabilities -> Unsupported. +- [ ] Use the already-merged default-branch trusted characterization dispatcher with the protected `outbound-fastly-probe` environment. It accepts an exact commit SHA, checks out and verifies that SHA, installs Fastly CLI `15.1.0`, and uses only disposable `FASTLY_DYNAMIC_SERVICE_ID`, `FASTLY_DYNAMIC_SERVICE_URL`, `FASTLY_DISABLED_SERVICE_ID`, `FASTLY_DISABLED_SERVICE_URL`, `FASTLY_API_TOKEN`, `OUTBOUND_PROBE_ORIGIN_URL`, and `OUTBOUND_PROBE_ORIGIN_TOKEN` secrets. Never use `pull_request_target`, expose secrets to a fork checkout, or modify a branch-only workflow and assume it is dispatchable. +- [ ] `tests/host/deployed.sh` records each service's prior active version, deploys the fixed prebuilt probe package to both disposable services, sends a unique run ID to an authenticated observable origin, and requires exact positive probe IDs for canonical arbitrary destinations, request method/body, timeout phases, upload cancellation characterization, and response streaming. Characterize which `SendErrorCause` each configured connect/response timer and the provider DNS timeout produces. The enabled service must succeed; the disabled service must return the exact `DYNAMIC_BACKENDS_DISABLED_MESSAGE` typed 502 and must not contact the origin. In a trap, reactivate each recorded prior version, delete only versions created by this run, and delete this run's origin observations; never delete a pre-existing version. Any cleanup failure fails the job. Record the workflow URL, commit SHA, tool versions, and observed bounds in the PR. This evidence characterizes the BestEffort row; it does not promote it to Native. +- [ ] For a fork PR, run only non-secret native/Viceroy checks automatically. A maintainer first reviews, reproduces, and pushes the exact tree to `refs/heads/outbound-probe-reviewed`, then dispatches the protected workflow for that SHA. Missing live evidence does not silently skip a probe or report zero tests. +- [ ] Commit: `ci: execute Fastly outbound contracts`. + +## Phase Verification + +- [ ] `scripts/run_test_nonzero.sh send_all_dispatches_every_slot_before_wait cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils --test contract` +- [ ] `test "$(viceroy --version)" = "viceroy 0.21.0"` +- [ ] `(cd crates/edgezero-adapter-fastly && ../../scripts/run_test_nonzero.sh send_all_dispatches_every_slot_before_wait cargo test --offline --locked --no-default-features --features fastly,test-utils --test contract)` +- [ ] `(cd crates/edgezero-adapter-fastly && ../../scripts/run_test_nonzero.sh backend_creation_error_table_is_exhaustive cargo test --offline --locked --no-default-features --features fastly,test-utils --lib)` +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `cargo check --offline --locked -p edgezero-adapter-fastly --no-default-features --features fastly --target wasm32-wasip1` +- [ ] `(cd examples/app-demo && cargo check --offline --locked -p app-demo-adapter-fastly --no-default-features --features fastly --target wasm32-wasip1)` +- [ ] `cargo metadata --offline --locked --format-version 1 --manifest-path crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/Cargo.toml` +- [ ] `cargo tree --offline --locked --manifest-path crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/Cargo.toml | rg 'fastly v0\.13\.1'` +- [ ] `fastly compute build --non-interactive --dir crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly` +- [ ] `test -f crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/pkg/edgezero-outbound-probe.tar.gz` +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- [ ] `(cd examples/app-demo && cargo test --locked --workspace --all-targets)` +- [ ] Push the reviewed phase HEAD to `refs/heads/outbound-probe-reviewed`, dispatch the + default-branch `.github/workflows/outbound-fastly-characterization.yml` with + `commit_sha=$(git rev-parse HEAD)`, and attach a successful workflow URL whose checked-out + SHA matches exactly; this remains characterization of the BestEffort service prerequisite. +- [ ] `git diff --check` + +Expected result: Fastly dispatches all eligible fan-out slots before harvest, preserves typed per-slot outcomes, and documents rather than overclaims its unavoidable host-blocking gaps. diff --git a/docs/superpowers/plans/2026-09-06-outbound-http-phase7-migration-docs.md b/docs/superpowers/plans/2026-09-06-outbound-http-phase7-migration-docs.md new file mode 100644 index 000000000..983a24e17 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-outbound-http-phase7-migration-docs.md @@ -0,0 +1,242 @@ +# Outbound HTTP Phase 7: Public Migration, Documentation, and Final Gates Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Status:** Deterministic migration, hard-cut API removal, generated/demo alignment, and documentation gates are implemented on PR 275. Protected provider evidence remains external and does not promote `BestEffort` cells. The unchecked steps below are retained as the original implementation record. +> **Superseded capability/batch surface:** The 2026-09-15 consumer-alignment plan owns the +> current completion-order, cancellation, cache-bypass, authority-override, and Fastly lifecycle +> hard cut. The matrices and commands below are historical. + +**Goal:** Migrate all remaining consumers, delete the legacy proxy API without aliases, publish capability/outbound documentation, and make every generated, example, adapter, WASM, host, and docs gate executable in CI. + +**Architecture:** Migrate non-workspace consumers before deleting core's temporary legacy module. Then enforce the hard public cutoff with a scoped symbol scan. Deterministic merge gates stay separate from the required protected Fastly characterization and the optional Spin teardown characterization; Cloudflare host evidence is already mandatory in Phase 4. + +**Tech Stack:** Rust workspace and generated workspaces, Handlebars templates, VitePress/npm, GitHub Actions, ripgrep structural gates. + +--- + +## Preconditions + +- [ ] Phases 1b-6 pass all their deterministic and required host gates. +- [ ] All four adapters already use `outbound` modules and `HttpClient`; only core scaffolding, generated/template consumers, app-demo, and prose may retain legacy names. +- [ ] Re-read spec §§5-7 and the final capability matrix. Do not promote BestEffort cells during documentation cleanup. + +## Task Protocol + +For each task: first add or run the named structural/build test and capture the expected stale-symbol, missing-capability, or compile failure; perform only that migration slice; rerun it to success plus `git diff --check`; then stage only listed files and make the stated commit. Do not combine the hard core deletion with unrelated prose cleanup. + +**Required exact test/gate names:** `generated_workspace_compiles`, `generated_manifest_declares_outbound_http_optional`, `generated_spin_hosts_default_to_https_only`, `generated_outbound_http_smoke`, `generated_core_tests_execute`, `generated_core_test_gate_rejects_zero_tests`, `app_demo_outbound_client_preserves_method_and_uri`, `app_demo_manifest_declares_outbound_http_optional`, `typed_body_stream_boundaries_preserve_edge_error`, `capabilities_page_lists_exact_matrix`, `docs_sidebar_links_capabilities`, and `outbound_docs_contract`. The source-wide legacy-symbol check is owned by `scripts/check_outbound_legacy_api.sh`, not by a Rust test that would need to embed the forbidden strings. + +**Expected red:** generated/template tests find missing capabilities or stale behavior; app-demo fails against the new API; the hard-cut scan reports exact remaining source/test/template paths; docs checks find missing matrix/sidebar content. Each red result must disappear in its owning task, not be added to an exclusion. + +### Task 1: Migrate scaffolds and strengthen generated-project tests + +**Files:** +- Modify: `crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs` +- Modify: `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` +- Modify: `crates/edgezero-cli/src/templates/root/README.md.hbs` +- Modify: `crates/edgezero-adapter-spin/src/templates/spin.toml.hbs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `crates/edgezero-cli/tests/generated_project_builds.rs` + +- [ ] Add failing structural tests for `optional = ["outbound-http"]`, outbound API names, Spin absent-host rendering as HTTPS-only, explicit wildcard HTTP+HTTPS expansion, and no implicit cleartext grant. The generated project is portable across all four adapters, so it cannot require a capability Fastly truthfully reports BestEffort. Generated README text states that `optional` explicitly accepts both documented behavioral deviations and runtime failure when an unverified deployment prerequisite is absent; an app whose behavior depends on outbound success must promote the declaration to `required` and select an adapter/deployment whose matrix result passes. Do not add a Rust assertion containing legacy API spellings; Task 3's source-wide negative gate owns their absence after all code/tests/templates migrate. +- [ ] First add only the harness regression + `generated_core_test_gate_rejects_zero_tests`. Run + `cargo test -p edgezero-cli --test generated_project_builds generated_core_test_gate_rejects_zero_tests` + and require a nonzero assertion failure showing the current harness accepts an + otherwise-successful zero-test fixture; dependency/build failure or a filtered zero-test + command is not the expected red. +- [ ] Add a generated core test named `generated_outbound_http_smoke`. Extend the generated-project harness to run `cargo test -p scaffold-probe-core --lib -- --list`, parse listed test identifiers, and require at least one test plus exactly one identifier whose terminal component is `generated_outbound_http_smoke` (the current full name is `handlers::tests::generated_outbound_http_smoke`). Match an optional Rust module prefix ending in `::`, not arbitrary diagnostic text or an impossible bare output line. Then run `cargo test -p scaffold-probe-core --lib`. Rerun the exact `generated_core_test_gate_rejects_zero_tests` command and require a nonzero passing test, then run the `generated_workspace_compiles` sentinel gate below. +- [ ] Run `cargo test -p edgezero-cli generator`; expect failure, then migrate templates/renderers and rerun. +- [ ] Run `scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test -p edgezero-cli --test generated_project_builds`; expect generated compilation and nonzero core tests. +- [ ] Commit: `feat(scaffold): generate outbound HTTP clients`. + +### Task 2: Migrate app-demo and its independent lockfile + +**Files:** +- Modify: `examples/app-demo/edgezero.toml` +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` +- Modify: `examples/app-demo/crates/app-demo-adapter-spin/spin.toml` +- Modify: `examples/app-demo/crates/app-demo-cli/tests/config_flow.rs` +- Modify: `examples/app-demo/Cargo.lock` + +- [ ] Add/update tests for URI merge/query preservation, injected client success, no-client 501, original method/URI propagation, `OutboundResponse::into_response`, and shipped capability parsing. +- [ ] Before migration, run `(cd examples/app-demo && cargo test --offline --locked -p app-demo-core app_demo_outbound_client_preserves_method_and_uri)` and require a nonzero failure from missing/stale outbound behavior, not dependency resolution or a zero-test filter. +- [ ] Update the mock to implement both required trait methods and preserve partial batch results. +- [ ] Declare outbound HTTP optional so the multi-adapter demo remains deployable to Fastly + with the standard BestEffort warning; make selected Spin hosts match the canonical expected + set. The demo handler must render the typed outbound failure, including the exact + dynamic-backend-disabled 502, rather than assuming optional means success. The test must + still prove the capability is present and not silently omitted. +- [ ] Run these exact independent-workspace gates and require success: `(cd examples/app-demo && cargo fmt --all -- --check)`, `(cd examples/app-demo && cargo clippy --workspace --all-targets --all-features -- -D warnings)`, `(cd examples/app-demo && cargo test --locked --workspace --all-targets)`, `(cd examples/app-demo && cargo check --locked -p app-demo-adapter-cloudflare --target wasm32-unknown-unknown --no-default-features --features cloudflare)`, `(cd examples/app-demo && cargo check --locked -p app-demo-adapter-fastly --target wasm32-wasip1 --no-default-features --features fastly)`, and `(cd examples/app-demo && cargo check --locked -p app-demo-adapter-spin --target wasm32-wasip2 --no-default-features --features spin)`. +- [ ] Commit: `refactor(app-demo): migrate to outbound HTTP API`. + +### Task 3: Perform the hard core cutoff + +**Files:** +- Delete: `crates/edgezero-core/src/proxy.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Create: `scripts/check_outbound_legacy_api.sh` +- Modify: each remaining runtime/template path printed by the first red run of that script; adding an exclusion instead is not permitted + +- [ ] Create `scripts/check_outbound_legacy_api.sh` as the deterministic negative gate for code and generated templates in this task. It initially scans `crates` and `examples/app-demo` for only the legacy API symbols below. Task 4 expands its roots to active documentation after that task owns the prose migration. Implement exit handling explicitly: ripgrep status 0 prints matches and exits 1; status 1 exits 0; status greater than 1 propagates as a scan failure. + +```sh +#!/usr/bin/env bash +set -uo pipefail + +if matches="$(rg -n 'Proxy(Client|Handle|Request|Response|Service)|proxy_handle|edgezero_core::proxy|crate::proxy|pub mod proxy' \ + crates examples/app-demo --glob '*.rs' --glob '*.hbs')"; then + printf '%s\n' "$matches" + exit 1 +else + status=$? + if [ "$status" -eq 1 ]; then exit 0; fi + exit "$status" +fi +``` + +- [ ] Run `bash scripts/check_outbound_legacy_api.sh`; expect failure with every remaining source/test/template match. Migrate each match; do not preserve forbidden spellings in Rust string-literal assertions because this raw-source gate intentionally rejects them too. + +```sh +bash scripts/check_outbound_legacy_api.sh +``` + +- [ ] Delete the legacy module and `proxy_handle`; export only outbound names. Add no deprecated aliases, forwarding traits, or feature flags. +- [ ] Preserve the wire/header constant `PROXY_HEADER`, `x-edgezero-proxy`, route names such as `/proxy/{*rest}`, and ordinary reverse-proxy terminology where semantically correct. +- [ ] Rerun `bash scripts/check_outbound_legacy_api.sh`; expect exit 0 and no output. Run workspace and app-demo tests. +- [ ] Separately inventory every typed-stream boundary; this audit is not satisfied by the legacy-symbol scan: + +```sh +rg -n 'Body::from_stream|Body::from_external_stream|Body::Stream|Self::Stream|\.into_stream\(' \ + crates examples/app-demo --glob '*.rs' --glob '*.hbs' +``` + +- [ ] This reruns the Phase 2 audit rather than creating a new adjacent test for comments, definitions, or unchanged inbound introspection. Classify construction/consumption matches only: EdgeZero-owned outbound/decoder/deadline paths must carry `Result`; genuine platform/foreign inputs alone use `from_external_stream`. Existing Phase 2 and adapter error-identity tests must cover every error-producing owned boundary introduced by the migration. +- [ ] Rerun the inventory after migration and review every remaining construction/consumption line. Then run `cargo test --workspace --all-targets`, `cargo check --workspace --all-targets --features "fastly cloudflare spin"`, the Cloudflare WASM contract command, both Fastly WASM contract/library commands, and both Spin WASM contract/SDK-resource commands from Task 6. Compilation plus the named Phase 2/adapter error-identity tests is the `typed_body_stream_boundaries_preserve_edge_error` gate. +- [ ] Commit: `refactor(core): remove legacy proxy API`. + +### Task 4: Publish outbound and capability documentation + +**Files:** +- Modify: `docs/guide/proxying.md` +- Modify: `docs/guide/handlers.md` +- Modify: `docs/guide/architecture.md` +- Modify: `docs/guide/streaming.md` +- Modify: `docs/guide/what-is-edgezero.md` +- Modify: `docs/guide/configuration.md` +- Modify: `docs/guide/adapters/{overview,axum,cloudflare,fastly,spin}.md` +- Create: `docs/guide/capabilities.md` +- Modify: `docs/.vitepress/config.mts` +- Modify: `scripts/check_outbound_legacy_api.sh` +- Create: `scripts/check_outbound_docs_contract.mjs` +- Modify: `CLAUDE.md`, `.claude/agents/code-architect.md`, `Cargo.toml`, `TODO.md` where they describe the active API + +- [ ] Document all eight outbound capability names, support ladder semantics, exact matrix, every BestEffort footnote including Fastly's dynamic-backend service prerequisite, host grammar/defaults, request/response limits, batch memory model, and no-client behavior. The exact rows are: + +| Capability | Axum | Cloudflare | Fastly | Spin | +| --- | --- | --- | --- | --- | +| `outbound-http` | Native | Native | BestEffort | Native | +| `outbound-complete-resource-accounting` | Unsupported | Unsupported | Unsupported | Unsupported | +| `outbound-header-fidelity` | Native | BestEffort | Native | Native | +| `outbound-deadlines` | Native | BestEffort | BestEffort | BestEffort | +| `outbound-flexible-phase-budget` | Native | Native | BestEffort | BestEffort | +| `send-all-slot-isolation` | Native | Native | BestEffort | Native | +| `streamed-upload-deadlines` | Native | BestEffort | BestEffort | BestEffort | +| `lazy-streamed-response-passthrough` | Native | Native | BestEffort | BestEffort | + +- [ ] Explain that Cloudflare manual upstream fetch encoding and downstream encoded passthrough are distinct controls. +- [ ] Explain the bounded fallback used for precommit conversion failures and Native lazy passthrough on Axum and Cloudflare. +- [ ] Explain that the body/header/window/decoder-state controls bound named guest-visible terms, `max_chunk_bytes` shapes emitted items without limiting source allocation, and complete pre-admission process/isolate accounting is Unsupported on every current adapter because provider parser, field-section, native chunk, allocator, and host-copy terms remain opaque. +- [ ] Before editing prose, expand `scripts/check_outbound_legacy_api.sh` to scan `docs/guide`, `README.md`, `CLAUDE.md`, `TODO.md`, root `Cargo.toml`, and `.claude/agents/code-architect.md` in addition to its existing code/template roots, adding `*.md` and `*.toml` globs. Using explicit active-source roots excludes internal history under `docs/superpowers/specs` and `docs/superpowers/plans`. Run it and require the expected stale-document failure. +- [ ] Add exactly one sidebar item whose link is `/guide/capabilities`; verify all internal links/anchors. +- [ ] Create `scripts/check_outbound_docs_contract.mjs`. It reads `docs/guide/capabilities.md` and `docs/.vitepress/config.mts`, locates exactly one Markdown table with the exact header `Capability | Axum | Cloudflare | Fastly | Spin`, and parses every contiguous data row in that table rather than filtering by a name prefix. It strips backticks around the first cell and only a trailing superscript footnote reference (`¹` through `⁹`) or Markdown footnote reference (`[^…]`) from support cells, then deep-compares the resulting ordered entries to the literal eight-row matrix above. It fails on a missing/duplicate matrix table or any duplicate, missing, extra, reordered, misspelled, or differently supported row, including the two valid names that do not begin `outbound-`. It also requires exactly one `/guide/capabilities` sidebar link. These assertions own `capabilities_page_lists_exact_matrix`, `docs_sidebar_links_capabilities`, and `outbound_docs_contract`. +- [ ] Run `node scripts/check_outbound_docs_contract.mjs`; expect failure before the page/sidebar are complete, then exit 0 after the documentation change. +- [ ] Run `npm --prefix docs run lint`, `npm --prefix docs run format`, and `npm --prefix docs run build`; expect success. +- [ ] Run `bash scripts/check_outbound_legacy_api.sh` over the now-updated active docs; expect no stale API references. Legitimate protocol terminology is outside the script's exact symbol pattern and needs no broad exclusion. +- [ ] Commit: `docs: publish outbound HTTP and capabilities guide`. + +### Task 5: Make the completion matrix a CI contract + +**Files:** +- Modify: `.github/workflows/test.yml` +- Modify: `.github/workflows/format.yml` +- Modify: `scripts/run_tests.sh` +- Modify: `scripts/run_test_nonzero.sh` + +- [ ] Add/confirm native contract commands for all four adapters and `test-utils` in each supported WASM contract run. Route every native, browser-WASM, WASI SDK-resource, Fastly SDK, and CLI capability suite through `scripts/run_test_nonzero.sh [--ignored] `. The helper first runs the command with harness `--list` (and `--ignored` when requested), requires the sentinel as the exact terminal test-name component with only an optional Rust module prefix, and requires at least one listed test, then runs the same selected suite; cfg/feature drift to zero tests fails CI. The generated-project integration target uses this helper, while its nested generated-core command keeps Task 1's equivalent in-harness list/sentinel/count assertion because it runs from a generated directory. +- [ ] Add generated-project core tests, app-demo fmt/clippy/workspace-test and three WASM checks, and PR-time docs lint/format/build. Retain the existing explicit `examples/app-demo` format and clippy steps in `.github/workflows/format.yml`; the root workspace excludes that directory, so root gates cannot replace them. +- [ ] Retain Cloudflare workerd/deployed host-observed cancellation and timing jobs from Phase 4. +- [ ] Keep pinned Spin teardown characterization clearly non-blocking for its existing BestEffort claims. Retain Phase 6's required Fastly enabled/disabled entitlement characterization as evidence that the adapter actually issues requests and maps disabled-service failure; that evidence does not promote the static `outbound-http` cell. Any future Native promotion requires a deployment-aware prerequisite, new evidence, and a spec change. +- [ ] Add `bash scripts/check_outbound_legacy_api.sh` and `node scripts/check_outbound_docs_contract.mjs` as deterministic nonzero-on-violation gates, with the legacy scan roots documented beside its workflow step. +- [ ] Add the four CLI capability suites as explicit CI commands so the declarations and action-specific gate paths cannot disappear behind broad workspace success: + +```sh +scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec +scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec +scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec +scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec +``` + +- [ ] Make the generated-project job run `scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test -p edgezero-cli --test generated_project_builds`. The integration harness then performs Task 1's `generated_outbound_http_smoke` list/count assertion inside the generated workspace before executing its core suite. A successful zero-test command at either level is a CI failure. +- [ ] Commit: `ci: enforce outbound HTTP completion matrix`. + +### Task 6: Final full-system verification + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- [ ] `scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test contract` +- [ ] `scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils --test contract` +- [ ] `scripts/run_test_nonzero.sh send_all_dispatches_every_slot_before_wait cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils --test contract` +- [ ] `scripts/run_test_nonzero.sh send_all_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features test-utils --test contract` +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_outbound_spec cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features cli --lib adapter_capability_matrix_matches_outbound_spec` +- [ ] `env CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner scripts/run_test_nonzero.sh cloudflare_fetch_options_are_raw_manual_abortable_and_no_redirect cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --test contract` +- [ ] `(cd crates/edgezero-adapter-cloudflare && ../../scripts/run_test_nonzero.sh cloudflare_fetch_options_are_raw_manual_abortable_and_no_redirect cargo test --offline --locked --no-default-features --features cloudflare,test-utils --test contract)`; this separate run has no target/runner override and proves the committed crate-local Cargo config selects `wasm32-unknown-unknown` plus `wasm-bindgen-test-runner`. +- [ ] `test "$(viceroy --version)" = "viceroy 0.21.0"` +- [ ] `(cd crates/edgezero-adapter-fastly && ../../scripts/run_test_nonzero.sh send_all_dispatches_every_slot_before_wait cargo test --offline --locked --no-default-features --features fastly,test-utils --test contract)` +- [ ] `(cd crates/edgezero-adapter-fastly && ../../scripts/run_test_nonzero.sh backend_creation_error_table_is_exhaustive cargo test --offline --locked --no-default-features --features fastly,test-utils --lib)` +- [ ] `cargo metadata --offline --locked --format-version 1 --manifest-path crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/Cargo.toml` +- [ ] `cargo tree --offline --locked --manifest-path crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/Cargo.toml | rg 'fastly v0\.13\.1'` +- [ ] `fastly compute build --non-interactive --dir crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly` +- [ ] `test -f crates/edgezero-adapter-fastly/tests/fixtures/outbound-fastly/pkg/edgezero-outbound-probe.tar.gz` +- [ ] `(cd crates/edgezero-adapter-spin && ../../scripts/run_test_nonzero.sh response_caller_result_succeeds_only_after_native_eof cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test contract)`; the crate-local runner must contain the exact Phase 5 validated command. +- [ ] `(cd crates/edgezero-adapter-spin && ../../scripts/run_test_nonzero.sh spin_error_code_table_is_exhaustive cargo test --offline --locked --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources)` +- [ ] `cargo metadata --offline --locked --format-version 1 --manifest-path crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml` +- [ ] `cargo tree --offline --locked --manifest-path crates/edgezero-adapter-cloudflare/tests/fixtures/outbound-worker/Cargo.toml | rg 'worker v0\.8\.5'` +- [ ] `npm ci --prefix crates/edgezero-adapter-cloudflare` +- [ ] `npm --prefix crates/edgezero-adapter-cloudflare run build:outbound-fixture` +- [ ] `npm --prefix crates/edgezero-adapter-cloudflare run test:workerd`; the host driver requires every exact probe ID and a positive count. +- [ ] Push the reviewed final HEAD to `refs/heads/outbound-probe-reviewed`, dispatch the + default-branch `.github/workflows/outbound-cloudflare-deployed.yml` with + `commit_sha=$(git rev-parse HEAD)`, and require a successful workflow URL whose recorded + checkout SHA matches exactly and whose driver reports every exact probe ID plus a positive + count. A direct credentialed npm invocation does not prove protected-environment or + default-branch workflow behavior. +- [ ] Dispatch the default-branch + `.github/workflows/outbound-fastly-characterization.yml` for the same exact reviewed SHA + and require its successful workflow URL, exact checkout SHA, positive probe count, and + cleanup result. +- [ ] `scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test -p edgezero-cli --test generated_project_builds` +- [ ] `(cd examples/app-demo && cargo fmt --all -- --check)` +- [ ] `(cd examples/app-demo && cargo clippy --workspace --all-targets --all-features -- -D warnings)` +- [ ] `(cd examples/app-demo && cargo test --locked --workspace --all-targets)` +- [ ] `(cd examples/app-demo && cargo check --locked -p app-demo-adapter-cloudflare --target wasm32-unknown-unknown --no-default-features --features cloudflare)` +- [ ] `(cd examples/app-demo && cargo check --locked -p app-demo-adapter-fastly --target wasm32-wasip1 --no-default-features --features fastly)` +- [ ] `(cd examples/app-demo && cargo check --locked -p app-demo-adapter-spin --target wasm32-wasip2 --no-default-features --features spin)` +- [ ] `bash scripts/check_outbound_legacy_api.sh` +- [ ] `node scripts/check_outbound_docs_contract.mjs` +- [ ] `npm --prefix docs ci` +- [ ] `npm --prefix docs run lint` +- [ ] `npm --prefix docs run format` +- [ ] `npm --prefix docs run build` +- [ ] Rerun the Task 3 typed-stream inventory. Every construction/consumption match must retain its reviewed owned/external classification; compilation plus the named Phase 2 and adapter tests must cover each error-producing owned boundary introduced by the migration. Then run `git diff --check`. +- [ ] Review `git status --short`; only intended implementation/docs/lock/CI changes may remain. + +Expected result: the repository has one public outbound HTTP API, generated consumers and app-demo exercise it, documentation matches the exact capability contract, and CI covers every deterministic acceptance surface. diff --git a/docs/superpowers/plans/2026-09-08-inbound-ingress-admission.md b/docs/superpowers/plans/2026-09-08-inbound-ingress-admission.md new file mode 100644 index 000000000..bb069360e --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-inbound-ingress-admission.md @@ -0,0 +1,224 @@ +# Inbound Ingress Admission Implementation Plan + +> **Status:** Partially implemented in PR #275. Core route resolution, admission, grants, +> request timing, lazy bounded body state, adapter entry seams, and the opt-in bounded body +> drain with a transferable admission grant and application-selected overflow/timeout responses +> before canonical 404/405 responses are implemented. The Axum raw-parser boundary and +> deployed cancellation probes remain open; raw framing/head-limit capabilities stay +> `Unsupported`, and every current adapter reports host-managed head accounting and framing. +> This plan is owned by the +> [inbound-body design](../specs/2026-08-22-inbound-body-design.md), not the outbound HTTP +> implementation phases. +> +> Checkboxes record implementation status. Mixed adapter/certification tasks remain unchecked +> until every assertion in that task has named evidence. + +**Goal:** Add stable pre-dispatch route resolution, one app-owned ingress admission gate, +request-owned grants, absolute body-read deadlines, parser-level request-target/header +limits and raw HTTP/1 framing rejection on Axum, and lazy bounded inbound-body consumption +across all adapters. + +**Architecture:** Each adapter stamps one monotonic request start and validates raw framing +where possible. Core resolves the route once without dispatching, admission sees that stable +resolution, and admitted requests dispatch through an opaque single-use token. The router +constructs `RequestContext` with the original start, route metadata, one-shot grant, and a +lazy deadline-bound body. Core owns the body cache/poison state machine; adapters own native +read cancellation and honest capability claims. + +**Dependencies:** The outbound Phase 1a `Deadline`, `BudgetSource`, and `BadGatewayReason` +work, plus the Phase 2 `ResponseLimitReason` work, must be present before the total +`StoredError` match lands. No outbound adapter client phase is otherwise required. + +## Global Constraints + +- Do not add Tokio to core or shared adapter crates. +- Route resolution may run before admission; middleware, handlers, extractors, and body + polling may not. +- A resolved token is consumed once and cannot be replayed against another router or a + changed method/path. +- Every admitted body has one finite absolute deadline. Relative per-read timeout resets are + forbidden. +- Raw head limits and framing validation must use a pre-normalization parser boundary. A + normalized target or `HeaderMap` check cannot claim parser-allocation or + request-smuggling protection. +- Preserve `RequestContext::new(Request, PathParams)` for low-level callers; it records no + route or admission grant. +- Follow red-green-refactor for every behavioral task and run the focused test after each + production edit. + +## Task 1: Stable route identity and single-use resolved dispatch + +**Files:** `crates/edgezero-core/src/router.rs`, route tests, public re-exports. + +- [x] Write red tests for structural `RouteId(method, registered_pattern)`, dynamic path + independence, optional manifest route class on matched/405 metadata, deterministic 405 + candidates, and `NotFound`. +- [x] Write red tests proving `resolve` executes no middleware/handler/body poll and captures + exact path params. +- [x] Write red tests proving `dispatch_resolved` does not rematch, consumes its token, and + rejects a foreign-router token or changed method/path as `Internal`. +- [x] Add `RouteId`, class-bearing `RouteMetadata`, `RouteResolution`, opaque + `ResolvedDispatch`, `RouterService::resolve`, `RouterBuilder::route_with_class`, and + `dispatch_resolved` exactly as specified. Route class must not enter `RouteId`. +- [x] Run `cargo test -p edgezero-core --lib router`. + +## Task 2: App admission policy, parser limits, and normalized ingress head + +**Files:** `crates/edgezero-core/src/app.rs`, app tests, macro/configure tests. + +- [x] Write red tests for validated nonzero `IngressHeadLimits`, `IngressHeadAccounting`, + `IngressFraming`, immutable/body-blind `IngressHead`, `AdmissionDecision`, and non-clone + opaque `IngressGrant` downcast behavior. +- [x] Write red ordering tests: framing, resolution, admission, then dispatch; refusal skips + middleware/handler/body polling and retains the chosen response. +- [x] Add the synchronous policy and immutable head-limit setters on `App`; preserve both + through complete app-to-service construction rather than cloning only `app.router()`. +- [x] Add the cloneable app-owned `MonotonicClock`, its setter/snapshot accessors, and tests + proving admission retains that exact handle rather than resnapshotting a global clock. +- [x] Add the default decision: empty grant plus + `request_start + DEFAULT_INBOUND_READ_BUDGET` (30 seconds). Clamp every policy deadline + to `request_start + DEADLINE_FAR_FUTURE` with checked arithmetic; reject overflow or an + invalid policy result before body ownership moves. +- [x] Run `cargo test -p edgezero-core --lib app`. + +## Task 3: RequestContext ingress metadata and grant lifetime + +**Files:** `crates/edgezero-core/src/context.rs`, router/context tests. + +- [x] Write red tests proving `IngressHead` and routed `RequestContext` expose the identical + `MonotonicInstant`, paired `MonotonicClock`, and class-bearing `RouteMetadata` values. +- [x] Write red drop-count tests for taken, untaken, refused, 404, and 405 grants. A second + `take_ingress_grant()` returns `None`; no grant type is clonable or serialized. +- [x] Preserve `RequestContext::new(Request, PathParams)`: snapshot start at construction, + expose no route, return no grant, and make no admission claim. +- [x] Add a crate-private routed constructor consuming `AdmittedIngress`; initialize all + metadata and the admitted clock before middleware starts. +- [x] Run `cargo test -p edgezero-core --lib context`. + +## Task 4: Lazy body cell, bounded extractors, and absolute deadline + +**Files:** `crates/edgezero-core/src/body.rs`, `context.rs`, `extractor.rs`, `error.rs`. + +- [x] Write red state-machine tests for `Initial`, `Draining`, `Cached`, `Poisoned`, and + `Taken`, including reentrancy and dropped-drain poison. +- [x] Write red deadline races for first byte, inter-chunk waits, EOF, source error, and + simultaneous readiness using the admitted clock. Expiry wins and remains sticky as + `RequestTimeout` (408). +- [x] Write red cap tests for exact limit, first byte over, checked accounting overflow, and + stricter re-check of already cached bytes without poisoning the cache. +- [x] Implement private `BodyCell`/`StoredError`, no borrow across await, and fallible + `take_body`/`into_request`. Keep the total error match wildcard-free. +- [x] Migrate JSON/form extractors to the documented defaults and explicit `Within` forms. +- [x] Run `cargo test -p edgezero-core`. + +## Task 5: Future Axum raw request-head and framing certification + +**Files:** pinned Hyper patch/upstream hook, workspace dependency patch, Axum connection +setup/request conversion, raw-socket integration tests. + +Current Axum ingress uses `IngressHeadAccounting::HostManaged` and +`IngressFraming::HostManaged`. Unchecked raw-accounting/framing items in this task are future +promotion evidence, not claims about the current adapter. + +The separate bounded HTTP/1 resource boundary is implemented: validated `AxumIngressConfig`, a +finite connection-owning accept pool, pinned Hyper 1.12.0 combined raw-head/field limits, waking +absolute idle-plus-header timers, and response-head cache compaction. TCP tests cover saturation, +initial/subsequent trickling, idle expiry, exact/over-limit heads, disconnect recovery, and shutdown. +See the [Axum guide](../../guide/adapters/axum.md#bounded-http1-ingress) for its effective configuration +and allocation assumptions. This evidence does not complete the independent raw-accounting or +strict framing items below; HTTP/2 and upgrades remain outside the current server contract. + +- [ ] Add red raw-socket tests for exact/over-limit request-target bytes, raw header bytes, + and field-line count; checked accounting overflow; CL+TE in both orders; duplicate + equal/unequal and comma-list CL; signed/malformed/overflow CL; + repeated/non-final/unsupported TE; HTTP/2 TE; and valid controls. +- [ ] Assert target overflow is 414, header byte/count overflow is 431, diagnostics echo no + request data, and every head rejection happens before resolution/admission/body polling. + Exact limits pass and expose the exact `IngressHeadAccounting::RawValidated` totals. +- [ ] Assert rejection is 400, closes HTTP/1 or resets only the multiplexed stream, invokes + no admission policy, and polls no body. +- [ ] Characterize pinned Hyper 1.12.0 first: preserve source references and tests proving a + `Content-Length` after `Transfer-Encoding` is skipped and equal duplicate lengths are + accepted. These are the red cases the patch must change. +- [x] Replace `axum::serve` with a bounded connection-owning Hyper HTTP/1 accept path while + preserving connect metadata, keep-alive, the existing service/body/egress contract, and + shutdown cleanup. Configure `max_headers`, `max_header_size`, `max_buf_size`, and waking + `header_read_timeout` as resource defense in depth, not strict raw-accounting/framing evidence. +- [ ] Define separate resource models before enabling HTTP/2, socket upgrades, or a graceful + shutdown drain beyond the current bounded close-on-shutdown behavior. +- [ ] Add the smallest audited parser patch (or consume an upstream equivalent) in Hyper's + existing request-line/ordered `httparse` header path. Bound parser reads so the raw head is + rejected rather than accumulated past the installed target/header policy; count duplicate + field lines and exact syntax bytes. In the same boundary reject any CL+TE presence in + either order; any second or comma-list CL; and malformed, repeated/non-final `chunked`, or + unsupported transfer codings before normalization. Keep Hyper as the only HTTP/1 parser; + do not add an independent socket pre-parser that could disagree on pipelined boundaries. +- [ ] Pin the exact patched source/revision and add an upgrade gate that fails when the + patch no longer applies or its source assertions change. Document the upstream issue/PR + and removal condition. +- [ ] Only after raw acceptance, derive the normalized `IngressFraming` summary from the + surviving headers and HTTP version and attach the raw accounting totals. Do not claim the + summaries themselves performed parser enforcement or smuggling validation. +- [ ] Convert accepted bodies lazily, wrap native cancellation under the admitted absolute + deadline, and dispatch with the exact resolved token. +- [ ] Run Axum unit, integration, and raw-socket suites. + +## Task 6: Cloudflare, Fastly, and Spin ingress implementations + +**Files:** each adapter request/service entry point and contract tests. + +Axum shares the current `HostManaged` baseline described in Task 5. This task records the +equivalent baseline and target-specific deadline behavior for the other three adapters. + +- [ ] Write per-adapter ordering tests with an observable first body poll. +- [ ] Stamp `request_start` from `App::monotonic_now()` at earliest guest entry, pass + `HostManaged` framing, attach + `IngressHeadAccounting::HostManaged`, apply only documented post-materialization + defense-in-depth limits, invoke policy once, and preserve the resolved token through + dispatch. +- [x] Implement pre-read/post-ready deadline checks against the admitted app clock and the + strongest available drop/abort primitive. Cloudflare and Spin also race pending reads + against a platform timer; Fastly does not claim preemption around synchronous host calls. +- [x] Add Spin WASI regressions with a permanently pending source for matched dispatch and + both fallback resolutions. Prove timer-first timeout, exact 408/application-selected + response behavior, and exactly-once source/grant release with a deterministic timer seam. +- [ ] Prove buffered provider values enter as `Body::Once` only after admission and within a + documented platform bound; all other paths use lazy `Body::Stream`. +- [ ] Run each adapter's contract suite, including its WASM target where applicable. + +## Task 7: Capabilities, generators, and deployed probes + +**Files:** manifest schema/parser, adapter registry, CLI checks, scaffold templates, docs. + +- [ ] Add separate `ingress-admission`, `inbound-read-deadlines`, + `raw-ingress-head-limits`, and `raw-ingress-framing-validation` cells with the exact + initial matrix in the spec. +- [ ] Test parse/display/round-trip, unknown value rejection, and fail-closed build/serve/ + deploy/demo behavior for Native requirements. +- [ ] Update manifest-generated and hand-written app construction so both retain admission + policy; keep low-level `into_router()` explicit about its lack of adapter admission. +- [x] Keep Cloudflare's pre-select cooperative zero-delay yield and document the frozen-clock + caveat. Spin uses the last positive app-clock snapshot to arm its provider timer and keeps + its post-ready app-clock check. +- [ ] Add Cloudflare and Spin deployed cancellation probes and retain Fastly cooperative + timing evidence. Store machine-readable target/runtime/version/tolerance results. +- [ ] Update adapter capability docs without upgrading a cell from local mocks alone. + +## Task 8: Final verification and integration boundary + +- [ ] Audit for eager request-body collection, post-materialization parser-limit claims, + normalized-header framing claims, rematching after admission, relative timeout resets, + wildcard `EdgeError` matches, and leaked grants. +- [ ] Run: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +``` + +- [ ] Record deployed-probe results separately from deterministic local tests. +- [ ] Commit ingress work independently from outbound adapter phases so each contract can be + reviewed and reverted without changing the other. diff --git a/docs/superpowers/plans/2026-09-08-response-egress-implementation.md b/docs/superpowers/plans/2026-09-08-response-egress-implementation.md new file mode 100644 index 000000000..3e7d71608 --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-response-egress-implementation.md @@ -0,0 +1,484 @@ +# Owned Response Egress Implementation Plan + +PR #275 follow-up expands local Axum TCP qualification: suppressed-body completion +resources, pending producers, non-reading clients, disconnect cleanup, native deadline +wakeup under a frozen injected clock, stopped source polling and single-connection recovery. +Hosted Cloudflare/Fastly/Spin evidence remains a separate unfulfilled qualification gate; +local tests and provider emulators must not be presented as deployed runtime proof. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to +> implement this plan task by task. Apply `superpowers:test-driven-development` to every +> behavioral change and `superpowers:verification-before-completion` before claiming success. + +**Goal:** Replace response-object handoff and bounded buffering with adapter-owned response +delivery lifetimes across Axum, Cloudflare, Fastly, and Spin, while preserving one application +clock, one absolute deadline, exact accepted-byte accounting, and exactly one terminal report. + +**Architecture:** Core owns the portable lifecycle state machine, policy evaluation, fallback +lifecycle, and shared HTTP framing normalization. Each adapter owns a runtime-specific +coordinator and writer pump through its strongest available transport boundary. There is no +cross-adapter sink abstraction. The migration is a hard cut: obsolete response-returning APIs, +buffering constants, compatibility aliases, tests, docs, and generated entrypoints are removed. + +**Source of truth:** +[`2026-09-08-response-egress-design.md`](../specs/2026-09-08-response-egress-design.md). + +**Tech stack:** Rust 1.95, `http`/`http-body`, Hyper/Axum/Tokio, Cloudflare Workers JS streams, +Fastly Compute body handles, Spin SDK WASIp3, Handlebars generator templates. + +**Implementation status:** The hard-cut API migration and runtime-specific send-owning paths are +complete. Remaining unchecked items are evidence hardening that requires a deployed provider or +additional raw-socket fixtures; they do not justify a `Native` capability claim and are not hidden +as implementation success. + +## Global Constraints + +- Preserve the injected `MonotonicClock` from ingress through every egress sample. +- Evaluate the application response-egress policy exactly once per response attempt. +- Use one absolute write deadline. Never reset it per source poll, write, flush, or finish. +- Account only payload bytes accepted at the adapter's documented boundary, including an + accepted partial prefix before a terminal error. +- One adapter-local coordinator owns `ResponseEgressAttempt`; helpers report events to it and + never terminalize independently. +- Precommit fallback uses the same attempt, records fallback body metadata, preserves the + original terminal cause, and is bounded by the internal fallback safety deadline. +- After commit, never replace or append an application error body. +- Capability cells are `Native` only with direct target-level evidence, `BestEffort` when a + meaningful approximation is tested, and `Unsupported` otherwise. +- One explicitly transitional `#[doc(hidden)]` lifecycle method may coexist with the old method + from Task 1 through Task 8 so the workspace stays buildable while adapters migrate. It is not a + compatibility promise: each migrated adapter must eliminate its old call sites, the legacy scan + must show a monotonically decreasing inventory, and the final tree contains neither method pair + nor any old public API. +- After every production code edit, run the narrowest affected `cargo test -p ...` command. + +## Task 0: Prove the risky target boundaries before the API cut + +**Files:** +- Create: `crates/edgezero-adapter-axum/src/connection.rs` +- Modify: `crates/edgezero-adapter-axum/src/lib.rs` +- Test: colocated tests in `connection.rs` +- Test: `crates/edgezero-adapter-fastly/src/response.rs` +- Test: `crates/edgezero-adapter-spin/src/response.rs` +- Test: `crates/edgezero-adapter-cloudflare/src/response.rs` + +- [x] Add an executable Axum/Hyper HTTP/1 prototype that proves a private non-`Send` response + body can run on `LocalSet`, an independent timer can close a connection while Hyper is blocked + below `poll_frame`, and ordered response IDs can attribute a pipeline-closing deadline without + sharing the attempt between tasks. +- [x] Add a raw-socket proof that distinguishes frame production from the strongest observable + OS-acceptance boundary. If that boundary cannot be associated with one response, plan Axum + success as `HostHandoff`/`BestEffort` rather than `Completed`/`Native`. +- [x] Compile minimal target-gated prototypes for Fastly `StreamingBodyHandle`, Spin raw WASIp3 + response/result writers, and Cloudflare fixed-length/transform writers plus request signal. + These prototypes become the final private adapter primitives; no throwaway public API remains. +- [x] Run the focused Axum raw-socket tests and all three target checks before changing core's + public lifecycle surface. Stop and revise the design if any required primitive is unavailable. + +## Task 1: Harden the core lifecycle and fallback contract + +**Files:** +- Modify: `crates/edgezero-core/src/response_egress.rs` +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Test: colocated tests in the files above + +- [x] Add red tests for `RequestCancelled`, application versus fallback body kind, fallback + `Completed` versus `Aborted`, accepted partial-prefix accounting, terminal races, policy panic, + deadline normalization failure, fallback timeout, fallback write failure, backward/frozen + clocks, observer unwind containment, and category-only EdgeZero diagnostic logging. Treat the + application-owned Rust panic hook as an explicit trust boundary; do not replace it from a library. +- [x] Add the owned begin contract under an explicitly transitional `#[doc(hidden)]` method that + returns a live attempt and structured precommit cause when policy evaluation or deadline + normalization fails. Core must not pre-terminalize. Keep the old method only long enough for + unmigrated adapters to compile, and forbid new call sites. +- [x] Add `ResponseEgressBodyKind::{Application, Fallback}` and + `ResponseEgressFallbackDisposition::{Completed, Aborted}` to bounded terminal reports. +- [x] Add `ResponseEgressOutcome::RequestCancelled`. Defer removal of `ResponseReturned` and the + old begin method until every adapter uses the owned contract in Task 8; run a workspace compile + after each lifecycle signature transition. +- [x] Make fallback lifecycle mutation explicit on `ResponseEgressAttempt`; reject invalid + application-to-fallback transitions and preserve exactly-once observer delivery. +- [x] Ensure routing errors, admission refusals, 404, and 405 responses reach adapters as + `ResponseEgressEnvelope` values. Keep only the explicitly documented low-level core dispatch + escape hatch outside adapter serving paths. +- [x] Run `cargo test -p edgezero-core --lib response_egress` and + `cargo test -p edgezero-core --lib app`, then `cargo check --workspace --all-targets`. + +## Task 2: Centralize HTTP response framing preparation + +**Files:** +- Create: `crates/edgezero-core/src/response_egress_framing.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/src/response_egress.rs` +- Test: `crates/edgezero-core/src/response_egress_framing.rs` + +- [x] Add table-driven red tests for all `Content-Length` field lines and comma members, + whitespace, invalid decimal forms, overflow, identical duplicates, conflicting duplicates, + application `Transfer-Encoding`, `Trailer`, final 1xx, 101, successful CONNECT, HEAD, 204, 205, + and 304 precedence. +- [x] Add red tests proving every `Connection` field is parsed as comma-separated tokens and + removes both nominated fields and the standard hop-by-hop set before adapter conversion. +- [x] Add red body-length tests: exact `Body::Once`, incremental streamed equality, early EOF, + one-byte-over, accepted partial native writes, body-suppressed responses, immediate source + release without polling, and no application trailers. +- [x] Implement an adapter-facing prepared-response type containing normalized status, headers, + body transmission mode, request method, and optional declared length. Wrap the body with one + core length-enforcing stream so all adapters share incremental overrun and early-EOF behavior; + the wrapper must not contain runtime writer logic. +- [x] Expose the helper only as hidden adapter plumbing; application-facing response APIs remain + unchanged. +- [x] Run `cargo test -p edgezero-core --lib response_egress_framing` and then + `cargo test -p edgezero-core`. + +## Task 3: Replace Fastly response return with owned transmission + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/response.rs` +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` +- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` +- Modify: `crates/edgezero-adapter-fastly/Cargo.toml` +- Modify: `crates/edgezero-adapter-fastly/src/templates/src/main.rs.hbs` +- Modify: `examples/app-demo/crates/app-demo-adapter-fastly/src/main.rs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Test: `crates/edgezero-adapter-fastly/tests/contract.rs` +- Test: colocated tests in `response.rs` and `lib.rs` + +- [x] Add red adapter-local pump tests for empty, once, multi-chunk, short write, zero-progress + write, source error, write error, finish error, abandon failure, deadline equality at every + observable boundary, and exactly-once finish/abandon/report ordering. +- [x] Add red precommit fallback tests for policy panic, normalization/conversion failure, + already-expired deadline, HEAD suppression, fallback success, partial write, fallback safety + timeout, finish failure, original-cause preservation, and fallback metadata. +- [x] Prototype the low-level path under the real Fastly feature: initialize once, receive the + client request, create an empty `BodyHandle`, commit with + `ResponseHandle::stream_to_client`, and consume through `StreamingBodyHandle`. +- [x] Carry the original request method into framing preparation and prove HEAD and status-based + suppression drops the application source without polling it. +- [x] Implement exact short-write looping and account each accepted prefix before checking the + deadline or processing the next result. On postcommit failure, abandon the body handle and + retain the original cause. +- [x] Hard-cut `run_app` and extension/config variants to no-request, send-owning signatures. + Remove all response-returning service methods used by normal dispatch and remove + `#[fastly::main]` support. +- [x] In the same edit, migrate the Fastly scaffold, demo entrypoint, and generator assertions to + the no-request send-owning runner. Run generated-project and demo Fastly target checks before + considering the old signature removed. +- [x] Delete `FASTLY_RESPONSE_STREAM_BUFFER_BYTES`, buffered response conversion, and imports or + dependencies used only by that path. +- [x] Keep response-write deadline support `BestEffort`: synchronous hostcalls get boundary + checks, not a claim of preemptive cancellation. Define successful completion as Fastly host + acceptance of body-handle close. +- [x] Run host tests with `--features cli`, compile every native and WASM feature combination, + and execute the `fastly,test-utils` contract and library suites under Viceroy. A host + `--all-features` test link is not a valid Fastly gate because provider ABI imports are supplied + only by the WASM runtime. + +## Task 4: Replace Spin full-response conversion with raw WASIp3 delivery + +**Files:** +- Modify: `crates/edgezero-adapter-spin/src/response.rs` +- Modify: `crates/edgezero-adapter-spin/src/request.rs` +- Modify: `crates/edgezero-adapter-spin/src/lib.rs` +- Modify: `crates/edgezero-adapter-spin/src/cli.rs` +- Modify: `crates/edgezero-adapter-spin/Cargo.toml` +- Modify: `crates/edgezero-adapter-spin/src/templates/src/lib.rs.hbs` +- Modify: `examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Test: `crates/edgezero-adapter-spin/tests/contract.rs` +- Test: colocated tests in `response.rs` + +- [x] Add red adapter-local coordinator tests for empty, once, streamed, partial write, pending + write versus deadline equality, cancellation acknowledgement, source failure, body-writer + failure, result-writer failure, response-result success/error/pending, destruction during every + state, immediate source release for suppressed bodies, and exactly one terminal report. +- [x] Add the complete precommit fallback matrix: policy/conversion failure, expired deadline, + HEAD suppression, success, partial write, safety timeout, finish/result failure, original-cause + preservation, fallback metadata, and source release. +- [x] Prototype returning a raw `spin_sdk::wasip3` response from the existing + `#[http_service]` entrypoint and owning its `BodyWriter` in a spawned coordinator. +- [x] Carry the original request method into framing preparation and prove HEAD and status-based + suppression drops the application source without polling it. +- [x] Implement cancellable write operations so a losing write remains owned until cancellation + is acknowledged or the response is destroyed. No losing future may retain attempt access. +- [x] Account each accepted prefix, own both the body writer and response-result writer through + terminal observation, close on clean EOF, and use `HostHandoff` for successful completion until + target evidence proves network completion. +- [x] Remove `SpinFullResponse`, `FullBody`, `http_into_wasi_response`, + `SPIN_RESPONSE_STREAM_BUFFER_BYTES`, and all response collection paths. Remove the direct + `wasip3` dependency if the final code uses only the SDK re-export. +- [x] In the same edit, migrate the Spin scaffold, demo adapter, and generator assertions to the + raw-response runner, then compile the generated and demo Spin applications. +- [x] Run `cargo test -p edgezero-adapter-spin --all-features` and + `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`. + +## Task 5: Give Cloudflare one owned writer and cancellation lifetime + +**Files:** +- Modify: `crates/edgezero-adapter-cloudflare/src/response.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/request.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/context.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/lib.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/cli.rs` +- Modify: `crates/edgezero-adapter-cloudflare/Cargo.toml` +- Modify: `crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs` +- Modify: `crates/edgezero-adapter-cloudflare/src/templates/wrangler.toml.hbs` +- Modify: `examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs` +- Modify: `examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Create: `scripts/probe_response_egress.mjs` +- Create: `crates/edgezero-adapter-cloudflare/tests/fixtures/response-egress-worker/Cargo.toml` +- Create: `crates/edgezero-adapter-cloudflare/tests/fixtures/response-egress-worker/wrangler.toml` +- Create: `crates/edgezero-adapter-cloudflare/tests/fixtures/response-egress-worker/src/lib.rs` +- Test: `crates/edgezero-adapter-cloudflare/tests/contract.rs` +- Test: colocated WASM/helper tests in `response.rs` + +- [x] Add red state-machine tests for one staged chunk, writer backpressure, declared-length + stream success/mismatch, unknown-length transform stream, enqueue/write rejection, request + abort, independent deadline equality, frozen application clock, cooperative yield, suppressed + body release, and terminal races. +- [x] Add the complete precommit fallback matrix: policy/conversion failure, expired deadline, + fixed-length HEAD suppression, success, partial writer acceptance, safety timeout, close failure, + original-cause preservation, and fallback metadata. +- [x] Capture `Request.signal` before request conversion and retain it through response delivery. + Enable request signals in generated/runtime configuration. +- [x] Carry the original request method into framing preparation and prove HEAD and status-based + suppression drops the application source without polling it. +- [x] For normalized declared lengths, create a fixed-length Workers stream; for unknown length, + create a transform stream. Register the sole writer/coordinator promise with Workers + `Context` so it survives handler return. +- [x] Account bytes only after the writer promise accepts them. On signal abort report + `RequestCancelled`; do not claim `ClientDisconnected` without deployed proof. +- [x] Complete clean close as `HostHandoff`, and keep abort/backpressure/completion/deadline + capabilities `BestEffort` unless the target-level probe proves the stronger boundary. +- [x] Remove the old Rust stream lifecycle wrapper, premature enqueue accounting, and + `ResponseReturned` path. +- [x] In the same edit, migrate the Cloudflare scaffold, demo adapter, and generator assertions; + enable request signals in both Wrangler configurations and compile both applications. +- [ ] **Deferred provider evidence:** add a dedicated Worker fixture with + `/_probe/ready`, `/_probe/slow`, `/_probe/no-demand`, `/_probe/partial`, and + `/_probe/complete` routes plus machine-readable observer log records. In local mode, + `scripts/probe_response_egress.mjs` must spawn + `wrangler dev --config /wrangler.toml --port `, wait for the ready route, + execute assertions, capture logs, and terminate the exact child process in `finally` on success, + failure, or signal. Run it as `node scripts/probe_response_egress.mjs --mode local`. The script + must assert slow-reader backpressure, no-demand expiry, cancel/disconnect behavior, partial-byte + accounting, clean close, and one terminal report. Run the same HTTP assertions without process + ownership as `node scripts/probe_response_egress.mjs --mode deployed --base-url ` and + archive runtime/version/tolerance output. No `Native` promotion may rely on compilation, mocks, + or local Wrangler alone. +- [x] Run deterministic host coordinator tests, + `cargo test -p edgezero-adapter-cloudflare --all-features`, and + `cargo check -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare`. + +## Task 6: Move Axum serving to a connection-owned Hyper boundary + +**Files:** +- Modify: `crates/edgezero-adapter-axum/src/response.rs` +- Modify: `crates/edgezero-adapter-axum/src/service.rs` +- Modify: `crates/edgezero-adapter-axum/src/dev_server.rs` +- Modify: `crates/edgezero-adapter-axum/src/lib.rs` +- Modify: `crates/edgezero-adapter-axum/src/cli.rs` +- Modify: `crates/edgezero-adapter-axum/Cargo.toml` +- Modify: root `Cargo.toml` +- Modify: `crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs` +- Modify: `examples/app-demo/crates/app-demo-adapter-axum/src/main.rs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Test: `crates/edgezero-adapter-axum/tests/contract.rs` +- Test: new raw-socket cases colocated with `service.rs` or in adapter integration tests + +- [x] Add a compile-time red prototype for a direct Hyper HTTP/1 connection running on a Tokio + `LocalSet` with a private non-`Send` response body and no unbounded channel. +- [x] Add red body/coordinator tests for source laziness, one staged frame, accepted-byte + accounting, source error, write deadline waking while Hyper is not polling, response drop, + transport error, deadline equality after accepted progress, suppressed-body source release, and + one terminal report. +- [ ] Add raw-socket precommit fallback tests for policy/conversion failure, expired deadline, + HEAD suppression, complete fallback, partial write, safety timeout, connection failure, + original-cause preservation, and fallback metadata. +- [ ] Add raw-socket tests for a normal reader, zero-read client, disconnect before body, + disconnect mid-body, deadline while blocked, HTTP/1 pipelining, collateral connection close, + exact `Content-Length`, and postcommit failure without replacement payload. +- [x] Replace `axum::serve` with an owned Hyper HTTP/1 accept loop. One response coordinator owns + the attempt; the connection supervisor only sends ordered connection events and closes the + HTTP/1 connection when deadline enforcement requires it. +- [x] Carry the original request method into framing preparation and prove HEAD and status-based + suppression drops the application source without polling it. +- [x] Define collateral pipelined outcomes: the response whose deadline wins keeps + `DeadlineExceeded`; other unsettled responses on that connection settle as `TransportError` + unless an earlier cause already won. +- [x] Remove public `EdgeZeroAxumService`, buffered `into_axum_response`, + `AXUM_RESPONSE_STREAM_BUFFER_BYTES`, and any transport-blind normal serving path. +- [x] In the same edit, migrate the Axum scaffold, demo server, and generator assertions to the + connection-owning server API, then compile and smoke-test both generated and demo servers. +- [x] Promote individual Axum capability cells to `Native` only for boundaries established by + the raw-socket tests; leave any unproved cell `BestEffort` or `Unsupported`. +- [x] Run `cargo test -p edgezero-adapter-axum --all-features`. + +## Task 7: Validate all migrated templates and demo apps together + +**Files:** +- Verify: all adapter templates and demo entrypoints migrated in Tasks 3-6 +- Modify: `crates/edgezero-cli/src/generator.rs` +- Test: generator tests in `crates/edgezero-cli/src/generator.rs` + +- [x] Consolidate cross-adapter generator assertions that reject `#[fastly::main]`, + request-in/response-out Fastly runners, `SpinFullResponse`, old Axum service construction, and + missing Cloudflare request-signal configuration. +- [x] Verify every scaffold and demo adapter uses the send-owning entrypoint established in its + adapter task. Do not retain alternate legacy examples. +- [x] Generate one project per adapter into temporary directories and verify the rendered source + contains only current APIs. +- [x] Run `cargo test -p edgezero-cli generator` and + `cargo check --manifest-path examples/app-demo/Cargo.toml --workspace`. +- [x] Run generated target checks for native, `wasm32-wasip1`, `wasm32-unknown-unknown`, and + `wasm32-wasip2` where the installed toolchain supports them. + +## Task 8: Remove old APIs, dependencies, and accidental response bypasses + +**Files:** +- Modify: all adapter `lib.rs`, `request.rs`, `response.rs`, manifests, and contract tests above +- Modify: `scripts/check_outbound_legacy_api.sh` +- Modify: `scripts/check_outbound_docs_contract.mjs` +- Modify: `Cargo.lock` +- Delete: `docs/superpowers/plans/2026-09-12-fastly-manual-response-egress.md` + +- [x] Derive the forbidden inventory from design §6 and extend the legacy checker to scan + production code, templates, demos, generated fixtures, current guides, manifests, and feature + definitions. Fail on `ResponseReturned`, the old `begin` signature, all response buffer + constants, `SpinFullResponse`, `FullBody`, `EdgeZeroAxumService`, public converters such as + `from_core_response`, alternate response-returning dispatch/runners, `#[fastly::main]`, obsolete + feature flags, adapter use of `collect_response_stream*`, and buffered-fallback guide language. +- [x] After all adapters use the owned contract, remove the transitional begin method, rename the + owned method to the final `begin`, remove `ResponseReturned` and its special case, update every + call site, and run `cargo check --workspace --all-targets` immediately. +- [x] Audit every adapter return and `?` path after the request-egress context exists. Route it + through the owned coordinator, including errors from handlers, middleware, routing, fallback + drain, response normalization, and policy evaluation. +- [x] Audit dependencies and feature gates with `cargo tree`/`rg`; `cargo machete` was not + installed. Remove dependencies and gates that existed only for buffered conversion or obsolete + entrypoints. +- [x] Keep outbound buffered-fetch helpers in core: they are live outbound APIs and are not part + of downstream response buffering cleanup. +- [x] Run cross-adapter conformance cases for streamed declared-length early EOF, one-byte overrun, + partial native writes, HEAD/status suppression, deadline equality, fallback accounting, and + original-cause preservation. +- [x] Run the legacy script, docs contract script, adapter tests, and `git diff --check`. + +## Task 9: Align capability evidence and public documentation + +**Files:** +- Modify: `docs/guide/capabilities.md` +- Modify: `docs/guide/architecture.md` +- Modify: `docs/guide/proxying.md` +- Modify: `docs/guide/adapters/overview.md` +- Modify: `docs/guide/adapters/axum.md` +- Modify: `docs/guide/adapters/cloudflare.md` +- Modify: `docs/guide/adapters/fastly.md` +- Modify: `docs/guide/adapters/spin.md` +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` +- Modify: adapter capability declarations in each `src/cli.rs` + +- [x] Add tests that compare adapter capability declarations with the public matrix and reject a + `Native` row lacking a named evidence fixture. +- [x] Document each adapter's commit point, byte-acceptance boundary, terminal success meaning, + cancellation primitive, deadline limitation, provider-owned allocation exclusion, and probe + status. +- [x] Remove language describing the old buffering fallback or response-object handoff as current + behavior. Keep historical implementation plans historical and clearly non-normative. +- [x] Record target/runtime versions and tolerances for any deployed probes that were actually + run. No deployed probe was run in this implementation session, and no capability was promoted + based only on compilation, host mocks, or local runtime behavior. +- [x] Run `node scripts/check_outbound_docs_contract.mjs`, docs lint, and docs build. + +## Task 10: Broad verification and adversarial self-review + +- [x] Run focused adapter and core tests after the final cleanup. +- [x] Run all repository gates: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly +cargo check -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +``` + +- [x] Run demo and generated-project checks, legacy/API checks, docs checks, `git diff --check`, + and inspect `git status --short` for unintended files. +- [x] Perform a direct dead-code, duplication, cfg-boundary, and build-matrix review. Separate + review agents were unavailable in this session; do not claim independent-agent evidence. +- [x] Re-read every normative statement in the response-egress spec and record evidence or an + explicit capability limitation for each one. +- [x] Report the final decision log: investigation agents, competing proposals, chosen + runtime-specific-pump design, proof obtained per adapter, remaining provider limitations, and + commands actually run. + +## Decision And Evidence Record + +### Hosted Qualification Follow-up + +- [x] Add a default-off synthetic qualification application through the four standard adapter + entrypoints, with bounded source/terminal/resource counters and compiled build identity. +- [x] Add allowlisted, finite HTTPS client probes and a fail-closed evidence validator; keep + `HostHandoff` distinct from peer delivery and record missing instance identity honestly. +- [x] Exercise validator corruption cases and compile every qualification target in CI. +- [ ] Run against designated nonproduction hosted targets and export complete unsampled telemetry + pinned to the committed source, SDK/runtime, compatibility and deployed binary identity. +- [ ] Prove same-instance recovery and actual transport failure/cancellation boundaries where + observable; do not infer these from a healthy subsequent invocation or promote capabilities. + +No hosted deployment was run for this follow-up. The fixture/validator is qualification +infrastructure, not evidence that the remaining provider lifetime guarantees have been met. + +- **Architecture:** Retained one core lifecycle/framing contract and four private runtime-specific + pumps. Rejected a shared sink abstraction and every response-object or bounded-buffer fallback. +- **API migration:** Removed the old Fastly response-returning entrypoint, Spin full-response + conversion, public Axum transport-blind service, converter-only terminal outcome, and response + buffer constants. Demo and generated applications compile only against the send-owning APIs. +- **Capability policy:** The four response-egress guarantees remain `BestEffort` on every adapter. + Lazy passthrough is `Native` only on Axum and Cloudflare. No local mock or compile check was used + to promote provider behavior. +- **Target evidence:** Fastly contract and library suites passed locally under Viceroy `0.19.0`; + Spin contract and SDK-resource suites passed under Wasmtime `45.0.0`. Those versions are newer + than the repository pins, so CI remains the authoritative pinned-runtime run. Cloudflare WASM + tests compile locally, but no `wasm-bindgen-test-runner` or Wrangler deployment was available. +- **Open evidence:** The deployed Workers probe and the expanded Axum socket matrix remain open. + Their absence is reflected by the unchecked tasks and conservative capability declarations. +- **Review:** Separate review agents were unavailable. Direct review covered framing precedence, + clock/deadline races, accepted-prefix accounting, cancellation ownership, provider diagnostics, + cfg/feature combinations, unsafe boundaries, dependency reachability, retired APIs, demos, and + generated projects. + +## Review Follow-Up: Axum EOF And Assembly Invariants + +- [x] Add Hyper/socket regressions for exact and zero declared-length streams, excess tails, + late source errors, and deadline-bounded EOF validation. Check exactly-once reports and source + release, not only received bytes. +- [x] Stage only the final declared-length chunk until source EOF validates; validate zero-length + streams before Hyper handoff and retain the application clock and response completion owner. +- [x] Add a deterministic expired-fallback-registration regression for GET and HEAD. Determine + EOF from source exhaustion, exhaust failed bodies, and preserve selected finite fallback bytes. +- [x] Add a replacement-hook regression with nondefault platform facts; reject changed metadata + after successful configuration while propagating hook errors unchanged. +- [x] Run scoped Axum/core tests, workspace gates, provider WASM checks, demo tests, and docs + contracts; update the spec/guide evidence without promoting BestEffort guarantees. + +**Decision and evidence (2026-10-06):** Investigation/proposal agents Pasteur and Plato recommended +core-owned driver EOF validation and a complete platform-metadata comparison. Axum reviewer +Noether identified a registration-time deadline race; its deterministic regression failed before +the fix and now selects 504 rather than 500. Core reviewer Beauvoir requested population-only +metadata and cancellation-during-finalization coverage; both are included. Build-validator +Lagrange checked combined native features, strict clippy on all three WASM targets, and the demo. +The chosen fix retains the private Axum pump and adds only final-frame lookahead, rather than +the optional shared-driver/pump refactors. + +Local evidence: workspace tests and strict clippy pass; core has 720 passing unit tests; Axum's +standard-service socket regressions cover clean/empty EOF, excess/error/pending tails, a frozen +application clock, and zero-length registration expiry. Demo tests (54), demo logging tests (2), +formatting, docs lint/build, and docs/dependency contracts pass. These are local proofs, not +deployed-provider certification, and no capability was promoted. Delivery and review-thread +replies are recorded in PR 275's commit and discussion history. diff --git a/docs/superpowers/plans/2026-09-10-demo-generator-lifecycle-alignment.md b/docs/superpowers/plans/2026-09-10-demo-generator-lifecycle-alignment.md new file mode 100644 index 000000000..dbe3f8aa8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-demo-generator-lifecycle-alignment.md @@ -0,0 +1,78 @@ +# Demo and Generator Lifecycle Alignment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the checked-in demo and newly generated projects exercise the current outbound HTTP and ingress-admission contracts instead of relying on compatibility defaults. + +**Architecture:** Extend `app!` with one optional `configure = ` callback that implements the existing `Hooks::configure(&mut App)` seam. The demo uses that callback to demonstrate application-owned leases and composed completion resources. The generated core template installs the same finite route-aware read policy with empty core-owned lifecycle tokens, avoiding a no-op semaphore in every new application. Their outbound examples share explicit encoded, decoded, final-buffer, request-body, header, Brotli, and timeout policy, while a batch endpoint exposes positional per-slot elapsed time. + +**Tech Stack:** Rust, proc macros (`syn`/`quote`), Handlebars templates, TOML manifests, EdgeZero core/adapters, native and WASM contract tests. + +--- + +### Task 1: Add the macro configuration callback + +**Files:** +- Modify: `crates/edgezero-macros/src/app.rs` +- Modify: `crates/edgezero-macros/tests/app_macro.rs` + +- [x] Add parser tests for `configure = crate::configure_app`, mixed keyword ordering, duplicate rejection, and the updated unknown-key diagnostic. +- [x] Run the focused macro tests and confirm they fail because `configure` is not accepted. +- [x] Parse one optional configure expression and emit `Hooks::configure(app)` as a call to it; preserve the empty default for existing macro invocations. +- [x] Add an integration test proving `App::build::(platform)` invokes the emitted callback and installs its policy. +- [x] Run all macro tests and strict macro clippy. + +### Task 2: Align ingress behavior in the demo and scaffold + +**Files:** +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` +- Modify: `examples/app-demo/edgezero.toml` +- Modify: `crates/edgezero-cli/src/templates/core/src/lib.rs.hbs` +- Modify: `crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs` +- Modify: `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` + +- [x] Add failing demo tests proving manifest route classes reach resolution, the configured callback supplies finite class-aware deadlines, and the opaque grant can be consumed exactly once by a handler. +- [x] Add route classes for health, diagnostic, and outbound routes in both manifests. +- [x] Define a small application-owned admission lease in the demo; keep the scaffold on empty core lifecycle tokens. In both, outbound routes receive a tighter read budget and every request receives a finite deadline. +- [x] Add an admission diagnostic handler that consumes the grant once and confirms its route class without exposing provider data; the demo additionally proves typed downcast and late-bound ownership. +- [x] Run the demo core tests and generated-source assertions. + +### Task 3: Align outbound examples with current limits and timing + +**Files:** +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs` +- Modify: `examples/app-demo/edgezero.toml` +- Modify: `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` +- Modify: `crates/edgezero-cli/src/templates/root/README.md.hbs` + +- [x] Extend outbound mock tests first to require explicit per-call timeout, request-body limit, independent encoded/decoded/final limits, header byte/count caps, and Brotli policy. +- [x] Apply one shared outbound policy helper to proxy and batch requests in both demo and template. +- [x] Add a positional batch endpoint that computes one absolute deadline, calls `send_all`, and returns each slot's index, elapsed milliseconds, and typed success/failure category. +- [x] Add tests for positional ordering, empty batches, and distinct per-slot elapsed values. +- [x] Document the generated routes, configured limits, timing semantics, and adapter timing-quality caveat. + +### Task 4: Lock generator/demo parity + +**Files:** +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `.github/workflows/test.yml` +- Test: generated project under a temporary directory + +- [x] Add generator assertions for the configure callback, finite ingress policy, empty core lifecycle tokens, absence of demo-only permits, route classes, independent outbound caps, and per-slot timing endpoint. +- [x] Run focused generator tests and confirm the new assertions fail before template changes, then pass after them. +- [x] Generate a fresh all-adapter project and run its tests and strict clippy. +- [x] Run the checked-in demo tests, strict clippy, and all three adapter WASM checks. + +### Task 5: Full verification and PR update + +**Files:** +- Modify: PR 275 description through `gh` + +- [x] Run `cargo fmt --all -- --check`. +- [x] Run `cargo test --workspace --all-targets`. +- [x] Run `cargo clippy --workspace --all-targets --all-features -- -D warnings`. +- [x] Run `cargo check --workspace --all-targets --features "fastly cloudflare spin"`. +- [x] Run `scripts/run_tests.sh` and documentation checks. +- [x] Update PR metadata, commit, push, wait for all checks, and verify a clean synchronized branch. diff --git a/docs/superpowers/plans/2026-09-10-ingress-fallback-review-hardening.md b/docs/superpowers/plans/2026-09-10-ingress-fallback-review-hardening.md new file mode 100644 index 000000000..d6ffd6e26 --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-ingress-fallback-review-hardening.md @@ -0,0 +1,104 @@ +# Ingress Fallback Review Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let applications hold an admission lease while enforcing a finite body cap and absolute read deadline before EdgeZero emits a pre-resolved 404 or 405, with application-controlled overflow and timeout responses. + +**Architecture:** Extend the synchronous, body-blind `ReadBodyBeforeFallback` disposition with a transferable `IngressGrant` and buffered terminal responses. Core retains the exact resolved route token and grant for the full drain lifetime, adapters install the existing deadline-bound lazy body, and resolved dispatch maps typed drain outcomes to either the application response or canonical 404/405 without middleware, handlers, rematching, or string classification. Response-egress capability levels remain unchanged and explicitly document the current certification boundary. + +**Tech Stack:** Rust 2024, `edgezero-core`, Axum/Hyper adapter integration tests, Handlebars project templates, VitePress Markdown, Cargo workspace CI. + +--- + +## Task 1: Add the bounded fallback admission contract + +**Files:** +- Modify: `crates/edgezero-core/src/ingress.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: `crates/edgezero-core/src/router.rs` +- Modify: `crates/edgezero-core/src/app.rs` + +- [x] Add failing core tests proving `ReadBodyBeforeFallback` is accepted only for `NotFound` and `MethodNotAllowed` and is rejected for matched routes before body polling. +- [x] Add failing dispatch tests proving an exact-cap lengthless body produces the canonical 404/405, the first byte over produces 400 before 404/405, and an expired absolute deadline produces 408. +- [x] Run the focused core tests and record the expected failures caused by the missing variant and dispatch behavior. +- [x] Add the initial bounded fallback disposition. Task 6 hardens its final contract with a transferable grant and exact buffered terminal responses while retaining the no-context/no-handler path. +- [x] Reuse the existing deadline checks in a bounded count-and-discard drain for fallback dispatch. Do not rematch, invoke middleware, construct a routed request context, or poll bodies for ordinary `Admit`/`Refuse` outcomes. +- [x] Run `cargo test -p edgezero-core --lib ingress`, `cargo test -p edgezero-core --lib app`, and `cargo test -p edgezero-core --lib router` until green. + +## Task 2: Prove adapter-level 400-before-404/405 behavior + +**Files:** +- Modify: `crates/edgezero-adapter-axum/src/service.rs` + +- [x] Add failing Axum service tests with body streams that omit `Content-Length`: exact-cap unmatched and wrong-method requests retain 404/405, while one-byte-over requests return 400. +- [x] Add a deadline test proving fallback draining uses the admitted absolute read deadline and releases the native body without middleware or handler execution. +- [x] Run the focused tests before production changes to confirm the missing core behavior is the failure source. +- [x] Make only the adapter wiring changes required by the public contract; standard adapters must continue to wrap the unread native body before `dispatch_admitted`. +- [x] Run `cargo test -p edgezero-adapter-axum --all-targets`. + +## Task 3: Keep the demo and generated project current + +**Files:** +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/lib.rs.hbs` +- Modify: `crates/edgezero-cli/src/generator.rs` + +- [x] Add failing demo/template assertions for a 4 KiB unmatched/wrong-method fallback cap and finite fallback deadline. +- [x] Update both admission callbacks to return `ReadBodyBeforeFallback` for `NotFound` and `MethodNotAllowed`; matched routes retain route-class grants and class-aware deadlines. +- [x] Add demo lifecycle tests for under-cap 404/405 and overflow 400 precedence. +- [x] Extend generator source assertions so future templates cannot silently drop the fallback policy. +- [x] Run demo core tests, generator tests, and the ignored generated-workspace integration test. + +## Task 4: Correct evidence and certification documentation + +**Files:** +- Modify: `docs/superpowers/specs/2026-08-22-inbound-body-design.md` +- Modify: `docs/superpowers/specs/2026-09-08-response-egress-design.md` +- Modify: `docs/guide/capabilities.md` +- Modify: `docs/superpowers/plans/2026-09-08-inbound-ingress-admission.md` +- Modify: `docs/superpowers/plans/2026-09-08-response-egress-implementation.md` + +- [x] Specify the bounded fallback decision, ordering, cap/deadline errors, route-token preservation, and no-handler/no-middleware behavior. +- [x] Mark parser-level head-limit and Axum raw-socket framing rows as future acceptance criteria. State that every current adapter reports `HostManaged` and both raw ingress capabilities remain `Unsupported`. +- [x] State that Axum currently reports `ResponseReturned` after conversion but before Hyper transmission. Keep abort, backpressure, completion, and write-deadline capabilities `Unsupported` and identify them as certification blockers, not implemented delivery guarantees. +- [x] Update implementation-plan status notes without claiming unfinished raw-boundary or transport-egress work. +- [x] Run documentation format, lint, build, and contract checks. + +## Task 5: Full verification and PR update + +- [x] Run `cargo fmt --all -- --check`. +- [x] Run `cargo test --workspace --all-targets`. +- [x] Run `cargo clippy --workspace --all-targets --all-features -- -D warnings`. +- [x] Run `cargo check --workspace --all-targets --features "fastly cloudflare spin"`. +- [x] Run `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`. +- [x] Run `scripts/run_tests.sh`, generated-project validation, demo target checks, and `git diff --check`. +- [x] Self-review the complete diff for precedence, cancellation, grant lifetime, error redaction, capability honesty, and demo/template parity. +- [ ] Update PR 275 metadata, commit, push, wait for all checks, and verify a clean synchronized branch. + +## Task 6: Transfer the fallback grant and terminal responses + +**Files:** +- Modify: `crates/edgezero-core/src/ingress.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: `crates/edgezero-core/src/router.rs` +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-adapter-axum/src/service.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/request.rs` +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` +- Modify: `crates/edgezero-adapter-spin/src/request.rs` +- Modify: adapter contract tests under `crates/edgezero-adapter-*/tests/contract.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/lib.rs.hbs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `docs/superpowers/specs/2026-08-22-inbound-body-design.md` +- Modify: `docs/guide/capabilities.md` + +- [x] Add failing core tests proving a non-empty fallback grant remains live during every body poll and drops exactly once after EOF, overflow, timeout, source failure, or cancellation. +- [x] Add failing core tests proving saturated admission returns an application 503 without polling the body, and fallback terminal responses preserve exact application-selected status, headers, and buffered bytes. +- [x] Add a cloneable buffered ingress-response value and extend `ReadBodyBeforeFallback` with `grant`, `on_exceeded`, and `on_timeout`. Keep `Refuse` as the zero-read overload path. +- [x] Replace fallback drain message classification with private `Complete`, `Exceeded`, and `TimedOut` outcomes. Hold the grant through the drain, release it before response conversion, preserve source errors, and retain canonical 404/405 only after clean EOF. +- [x] Extend Axum, Cloudflare, Fastly, and Spin contract suites so each adapter exercises exact-cap `NotFound` and `MethodNotAllowed` requests, first-byte-over precedence, absolute deadline behavior, zero middleware/handler calls, source release, zero-read refusal, and grant lifecycle at the strongest capability each host exposes. Axum additionally pins deadline-bounded release after an outer abort request because its blocking bridge is not promptly cancellable. +- [x] In every adapter contract suite, assert the exact application-selected overflow and timeout status, headers, and buffered body bytes so adapter conversion cannot silently restore the generic JSON 400/408 renderer. +- [x] Update the demo and generated app policy to carry a fallback lease plus explicit plain-text overflow and timeout responses; extend generator assertions and lifecycle tests so the two remain identical. +- [x] Update the ingress specification, capability guide, and this plan to remove the empty-grant and generic-error claims and state the exact lifecycle and wire-response contract. +- [x] Run focused core, adapter, demo, generator, documentation, full workspace, strict Clippy, feature, and WASM target checks before committing and pushing. diff --git a/docs/superpowers/plans/2026-09-10-outbound-http-review-hardening.md b/docs/superpowers/plans/2026-09-10-outbound-http-review-hardening.md new file mode 100644 index 000000000..1f2ac1f98 --- /dev/null +++ b/docs/superpowers/plans/2026-09-10-outbound-http-review-hardening.md @@ -0,0 +1,171 @@ +# Outbound HTTP Review Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the remaining current-head review gaps by preventing provider diagnostics from reaching HTTP responses, splitting typed config failure reasons, and carrying manifest route class plus one injectable monotonic clock through ingress and outbound HTTP. + +**Architecture:** Keep detailed errors available inside `EdgeError`, but render fixed category-only messages at the wire boundary and stop adapters from embedding provider values where no internal diagnostic is needed. Hard-cut the ambiguous config reasons so exhaustive downstream matches must migrate to the precise taxonomy. Route identity remains method plus pattern, while route class is optional metadata. Use one `App`-owned portable clock handle backed by `web_time` by default; the same handle captures ingress start, evaluates admitted body deadlines, and is cloned into the standard outbound client installed by every adapter. + +**Tech Stack:** Rust 2024, `web-time`, `http`, Axum/Tokio, Cloudflare Workers, Fastly Compute, Fermyon Spin, Cargo workspace and WASM target checks. + +--- + +### Task 1: Wire-Safe Server Error Messages + +**Files:** +- Modify: `crates/edgezero-core/src/error.rs` +- Modify: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-adapter-axum/src/outbound.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound.rs` +- Test: colocated unit tests in the files above + +- [x] Add token-bearing tests proving `BadGateway`, `GatewayTimeout`, `Internal`, and `ResponseTooLarge` preserve their internal diagnostics while `IntoResponse` emits only fixed category messages and never serializes the token, URL, query, or provider text. +- [x] Run `cargo test -p edgezero-core error` and verify the new tests fail on the current `self.message()` wire rendering. +- [x] Add a private exhaustive `wire_message()` policy and use it only in `IntoResponse`; keep `message()`, `Display`, typed reasons, and causes available for internal inspection. +- [x] Replace Axum, Cloudflare, and Fastly outbound provider-error interpolation with fixed diagnostics. Keep Spin's already category-only mappings unchanged. +- [x] Add token-bearing wire regression coverage and adapter-facing secret-error assertions; scan every outbound provider mapping to verify rendered responses cannot contain provider values. +- [x] Run `cargo test -p edgezero-core` and focused adapter tests. + +### Task 2: Split Typed Config Extraction Reasons + +**Files:** +- Modify: `crates/edgezero-core/src/error.rs` +- Modify: `crates/edgezero-core/src/extractor.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: `docs/superpowers/specs/2026-06-16-blob-app-config.md` +- Modify: `docs/superpowers/plans/2026-06-17-blob-app-config.md` + +- [x] Add failing tests for four independently inspectable reasons: `Deserialization`, `Validation`, `MalformedEnvelope`, and `UnsupportedVersion`. +- [x] Run the focused extractor and error tests and verify the pre-split reason assertions fail. +- [x] Add the four variants to the non-exhaustive enum and its exhaustive wire status/kind policy. Remove the ambiguous predecessor variants; this is an intentional compile-time migration break. +- [x] Map malformed envelope parsing, unsupported envelope version/discriminator, typed `data` deserialization, validator failures, and structural secret-walk failures to their exact new reasons. +- [x] Update stored-error round trips and exhaustive reason tests. +- [x] Run `cargo test -p edgezero-core`. + +### Task 3: Manifest Route Class Metadata + +**Files:** +- Modify: `crates/edgezero-core/src/manifest.rs` +- Modify: `crates/edgezero-core/src/router.rs` +- Modify: `crates/edgezero-macros/src/app.rs` +- Modify: `docs/superpowers/specs/2026-08-22-inbound-body-design.md` +- Modify: `docs/superpowers/plans/2026-09-08-inbound-ingress-admission.md` + +- [x] Add failing manifest, macro-token, router-resolution, admission-head, and request-context tests for optional `class = "auction"` metadata. +- [x] Run focused core and macro tests and verify the field/API are absent. +- [x] Add optional validated `class` to `ManifestHttpTrigger`; do not overload the existing unique trigger `id`. +- [x] Add optional class to `RouteMetadata` without changing `RouteId`; add `RouteMetadata::class()` and a builder path for classed routes while preserving all existing route methods. +- [x] Make `app!` propagate each trigger's class for every generated method. Ensure 404/405 behavior and deterministic allowed-route ordering remain unchanged. +- [x] Run `cargo test -p edgezero-core` and `cargo test -p edgezero-macros`. + +### Task 4: Paired Injectable Ingress Clock + +**Files:** +- Modify: `crates/edgezero-core/src/time.rs` +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-core/src/ingress.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: `crates/edgezero-adapter-axum/src/service.rs` +- Modify: `crates/edgezero-adapter-axum/src/request.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/request.rs` +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` +- Modify: `crates/edgezero-adapter-spin/src/request.rs` +- Test: colocated core and adapter tests + +- [x] Add failing tests with a manually advanced clock proving the same source captures `request_start`, normalizes the admission deadline, wins simultaneous body readiness/expiry, and poisons repeated body reads exactly once. +- [x] Add a cloneable `MonotonicClock` handle wrapping `Fn() -> MonotonicInstant + Send + Sync`, with a `web_time` default. Make `Deadline::remaining_at` and `is_expired_at` public clock-paired operations. +- [x] Store the clock on `App`, expose a setter and snapshot method, attach it to admitted ingress, and carry it into `RequestContext`. +- [x] Change all standard adapter entry points to capture start from `App`'s clock as their first EdgeZero-owned operation. Pass the admitted clock into each lazy body wrapper and use it for pre-poll/post-ready deadline decisions; retain each platform's strongest available timer/cancellation primitive. +- [x] Keep low-level conversion APIs explicitly non-admitting and preserve their existing default-clock behavior. +- [x] Run focused core and all four adapter request/contract tests. + +### Task 5: Documentation, Metadata, and Full Verification + +**Files:** +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` +- Modify: `docs/superpowers/specs/2026-06-16-blob-app-config.md` +- Modify: `docs/superpowers/specs/2026-08-22-inbound-body-design.md` +- Modify: relevant implementation plans under `docs/superpowers/plans/` +- Modify: PR 275 title/body through `gh` + +- [x] Document fixed wire messages, split config reasons, route class semantics, and the paired clock contract. +- [x] Preserve explicit `Unsupported` declarations for raw parser accounting, provider-side config allocation/cancellation, and transport-observed response abort/backpressure/completion/write deadlines. +- [x] Update the PR title from design-only wording and refresh the body without claiming unsupported guarantees. +- [x] Run `cargo fmt --all -- --check`. +- [x] Run `cargo test --workspace --all-targets`. +- [x] Run `cargo clippy --workspace --all-targets --all-features -- -D warnings`. +- [x] Run `cargo check --workspace --all-targets --features "fastly cloudflare spin"`. +- [x] Compile and lint Cloudflare, Fastly, and Spin on their target-specific WASM triples; run the exact Fastly and Spin CI sentinels locally. +- [x] Make the Fastly outbound concurrency sentinel part of the WASM contract binary, as required by the existing CI matrix, and prove it under Viceroy. +- [x] Confirm the Cloudflare browser-runtime contract through the PR's Linux CI job; local Safari WebDriver cannot start in this environment. +- [x] Run documentation contract, formatting, lint, and build checks. +- [x] Commit, push, wait for PR checks, and verify the branch is clean and synchronized. + +### Task 6: Final Self-Review Corrections + +**Files:** +- Modify: `crates/edgezero-core/src/error.rs` +- Modify: `crates/edgezero-core/src/extractor.rs` +- Modify: `crates/edgezero-core/src/ingress.rs` +- Modify: `crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/request.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound.rs` +- Modify: `docs/superpowers/specs/2026-08-22-inbound-body-design.md` +- Modify: `.github/workflows/test.yml` + +- [x] Add red regressions for escaped JSON discriminators, config provider-message redaction, + generic Cloudflare fetch classification, paired relative admission deadlines, and native source + release when a terminal body error is emitted. +- [x] Replace textual discriminator detection with structural JSON inspection and keep malformed + envelopes distinct from unsupported versions. +- [x] Redact direct config-store provider failures at conversion and classify an opaque Cloudflare + fetch rejection as `Unspecified` rather than claiming connection-phase evidence. +- [x] Add a request-start-relative deadline helper, stamp ingress before store resolution on every + adapter path, and release body sources at the terminal item rather than retaining them until the + wrapper is dropped. +- [x] Correct BestEffort cancellation wording, stale Spin request-conversion docs, and unsafe + config-plan examples; add explicit Fastly WASM concurrency and public ingress-capability checks. +- [x] Remove the superseded serde constructor and verify no active code depends on it. +- [x] Run focused red/green tests after each correction, then repeat every Task 5 verification gate. + +### Task 7: Pair outbound clients with the application clock + +**Files:** +- Modify: `crates/edgezero-adapter-axum/src/outbound.rs` +- Modify: `crates/edgezero-adapter-axum/src/request.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/request.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound.rs` +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` +- Modify: `crates/edgezero-adapter-spin/src/outbound.rs` +- Modify: `crates/edgezero-adapter-spin/src/request.rs` +- Modify: adapter contract tests under `crates/edgezero-adapter-*/tests/contract.rs` +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` +- Modify: `docs/guide/capabilities.md` + +- [x] Add failing per-adapter tests proving method-entry anchoring, preflight elapsed timing, post-ready expiry, backwards-clock handling, and clock retention by streamed upload/response paths. Cloudflare and Spin deferred-path probes run through their hosted contract binaries rather than unexecuted library-only tests. +- [x] Store a `MonotonicClock` on every native outbound client. Require explicit clocks at every low-level response constructor and use clock-paired client constructors for standard adapter wiring and tests. +- [x] Install each standard request's outbound client with the exact `App::monotonic_clock()` clone used by ingress; keep standalone low-level request converters explicitly default-clocked. +- [x] Replace every production outbound `MonotonicInstant::now`, `Deadline::remaining`, and `Deadline::is_expired` call with the client clock plus explicit `remaining_at` or `is_expired_at`; carry clock clones into all deferred body streams and Fastly pending slots. +- [x] Keep `DispatchBudget` as the copyable result of pure budget selection while ensuring its start snapshot, all later deadline checks, error precedence, dispatch-slack checks, and slot completion observations use one clock domain. Fastly backend identity uses the method-entry `budget.duration`; preparation-time clock samples cannot fragment a homogeneous batch's dynamic-backend identity. +- [x] Update the outbound specification and capability guide with constructor semantics, app-clock propagation, explicit low-level clock ownership, and the full injected-clock acceptance matrix. +- [x] Run focused outbound adapter tests, source scans for global-clock bypasses, full workspace tests, strict Clippy, feature builds, and all three WASM target checks before committing and pushing. + +### Task 8: Final implementation-review corrections + +**Files:** +- Modify: `crates/edgezero-core/src/{app,extractor,lib,outbound,response_egress}.rs` +- Modify: `crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/{outbound,response}.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs` +- Modify: `docs/guide/proxying.md` +- Modify: outbound and response-egress specifications + +- [x] Validate HEAD/304 representation `Content-Length` as one consistent `u64`, reject malformed/conflicting/overflow values before body polling, retain valid metadata, and continue stripping the field on 1xx/204. +- [x] Carry the application clock through `OutboundResponse`, cooperative bounded-until collection, typed config extraction, `ResponseEgressEnvelope`, and every adapter response converter. +- [x] Preserve Cloudflare's intentional abort-on-drop for framing-bodyless responses; handle a null-body 205 without calling `stream()`, disarm only clean absent/zero-length suppression, and keep positive declared length on the abort path. +- [x] Pin Spin's `BodyWriter` default-on-drop host-error contract in source and retain nonblocking deadline exits instead of awaiting after budget expiry. +- [x] Add `max_chunk_bytes` to the demo, generated template, generator sentinel, and both copyable proxy-guide examples; document that rechunking bounds emitted item shape, not provider allocation. +- [x] Add Cloudflare/Spin three-slot preflight-order coverage, executable adapter write-deadline probes, Cloudflare abort lifecycle coverage, and host-duration floor/saturation tests. +- [x] Document intentional zero-cap deny-all behavior and keep loop/self-reference policy explicitly application-owned. +- [x] Run every workspace, strict lint, documentation, generated-project, demo, and target-specific WASM gate. diff --git a/docs/superpowers/plans/2026-09-13-runtime-sdk-hard-cut.md b/docs/superpowers/plans/2026-09-13-runtime-sdk-hard-cut.md new file mode 100644 index 000000000..cb211f347 --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-runtime-sdk-hard-cut.md @@ -0,0 +1,228 @@ +# Runtime SDK Hard-Cut Implementation Plan + +> **Status:** Implemented. Outbound batch capability names and APIs in this historical plan are +> superseded by `2026-09-15-outbound-http-consumer-alignment.md`. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to +> implement this plan task by task. Apply `superpowers:test-driven-development` to every +> behavioral change and `superpowers:verification-before-completion` before claiming success. + +**Goal:** Upgrade the Fastly, Cloudflare, and Spin adapters, demo applications, and generated +projects to the latest stable provider SDKs with no compatibility branches, while correcting +outbound concurrency wording and downgrading unproved Cloudflare deadline capabilities. + +**Architecture:** Keep the existing EdgeZero outbound and response-egress contracts intact and +adapt only provider-boundary code required by `fastly = 0.13.1`, `worker = 0.8.5`, and +`spin-sdk = 7.0.0`. Dependency pins remain exact in the workspace and demo; generated projects +use the matching release lines. Capability declarations describe verified behavior rather than +SDK potential, so Cloudflare deadline capabilities remain `BestEffort` until a deployed, +host-observed cancellation fixture proves `Native` semantics. + +**Tech Stack:** Rust 1.95, Cargo resolver 2, Fastly Compute SDK 0.13.1, Cloudflare Workers Rust +SDK 0.8.5, Spin Rust SDK 7.0.0/WASIp3, Wasmtime 48.0.2, WASM target checks, Viceroy, Node +documentation guards. + +**Source of truth:** +[`2026-05-21-outbound-http-design.md`](../specs/2026-05-21-outbound-http-design.md) and +[`2026-09-08-response-egress-design.md`](../specs/2026-09-08-response-egress-design.md). + +--- + +## Scope And Constraints + +- This is a hard cut. Do not retain SDK 6/0.12/0.8.3 `cfg` branches, aliases, fallback code, + generated templates, or dependency pins. +- Preserve the existing outbound public API, positional `send_all` behavior, per-slot elapsed + metadata, independent encoded/decoded limits, and response-egress lifecycle. +- Do not use Fastly's direct `PendingRequest::send_to_client` path for EdgeZero outbound calls; + it bypasses EdgeZero response limits, decoding, typed outcomes, and slot timing. +- Keep Fastly's raw status-returning response ABI while the high-level SDK send wrappers can + panic on hostcall errors. +- Do not promote a capability based only on compilation or SDK documentation. A `Native` + deadline/cancellation claim requires target-level and deployed host-observed evidence. +- Do not add absolute terminal instants or a public batch-start instant. Under the current + contract, `Ok` is proof of on-time terminal completion and `elapsed` is metadata, not an + independent lateness detector. +- Historical phase plans and baseline prose may retain old version numbers when explicitly + labeled historical. Normative/current documentation must use the upgraded versions. +- Do not commit or push during this execution unless the user separately requests it. +- After every production Rust edit, run the narrowest affected crate test before proceeding. + +## File Map + +- `Cargo.toml`, `Cargo.lock`: exact workspace provider SDK pins and resolved graph. +- `.tool-versions`: Viceroy release capable of loading Fastly SDK 0.13.1 host imports. +- `examples/app-demo/Cargo.toml`, `examples/app-demo/Cargo.lock`: exact demo pins and graph. +- `crates/edgezero-adapter-{fastly,cloudflare,spin}/src/**`: provider API adaptations only where + the upgraded SDK fails to compile or changes verified behavior. +- `crates/edgezero-adapter-cloudflare/src/cli.rs`: authoritative Cloudflare capability values + and table-driven expectations. +- `crates/edgezero-cli/src/generator.rs`: generated application dependency versions and tests. +- `docs/guide/capabilities.md`: public capability matrix and Cloudflare evidence caveat. +- `docs/superpowers/specs/2026-05-21-outbound-http-design.md`: normative SDK pins, fan-out + wording, Cloudflare support levels, evidence gate, and elapsed contract. +- `scripts/check_outbound_docs_contract.mjs`: executable parity guard for documentation matrix. + +### Task 1: Establish Dependency And Capability Red Tests + +**Files:** +- Modify: `crates/edgezero-adapter-cloudflare/src/cli.rs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `scripts/check_outbound_docs_contract.mjs` + +- [x] Change the Cloudflare capability-table test expectations for + `OutboundDeadlines` and `StreamedUploadDeadlines` from `Native` to `BestEffort` without yet + changing production capability dispatch. +- [x] Run + `cargo test -p edgezero-adapter-cloudflare capability --all-features` and verify it fails only + because production still returns `Native` for those two rows. +- [x] Change generator assertions to require `fastly = "0.13"`, `worker = "0.8.5"`, and + `spin-sdk = "7"` without yet changing generated dependency strings. +- [x] Run the focused generator tests and verify failures report the old generated versions. +- [x] Change the docs contract guard's expected Cloudflare deadline cells to `BestEffort` and run + `node scripts/check_outbound_docs_contract.mjs`; verify it fails against the still-`Native` + public guide/spec matrices. + +### Task 2: Upgrade Exact Workspace And Demo Dependencies + +**Files:** +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Modify: `examples/app-demo/Cargo.toml` +- Modify: `examples/app-demo/Cargo.lock` + +- [x] Replace workspace exact pins with `fastly = "=0.13.1"`, + `fastly-shared = "=0.13.1"`, `fastly-sys = "=0.13.1"`, + `log-fastly = "=0.13.1"`, `worker = "=0.8.5"`, and `spin-sdk = "=7.0.0"` while preserving + current feature sets. The logger pin must move with the SDK so it does not retain a transitive + Fastly 0.12 graph. +- [x] Apply the corresponding exact pins in `examples/app-demo/Cargo.toml`. +- [x] Update the Viceroy tool pin to 0.21.0; older repository/local releases reject host imports + emitted by Fastly SDK 0.13.1 before tests execute. Update Wasmtime to 48.0.2; the former 44/45 + runner line cannot link Spin SDK 7's stable WASI HTTP 0.3 `request-options` resource. +- [x] Refresh both lockfiles with targeted `cargo update -p --precise ` commands; + do not accept unrelated dependency churn. +- [x] Use `cargo tree -i` or lockfile inspection to prove each graph resolves exactly the requested + provider versions and that Spin uses its SDK-provided WASIp3 generation. +- [x] Run `cargo check --workspace --all-targets --features "fastly cloudflare spin"` to expose + provider API changes before editing adapter code. + +### Task 3: Adapt Fastly To SDK 0.13.1 + +**Files:** +- Modify only if required: `crates/edgezero-adapter-fastly/src/outbound.rs` +- Modify only if required: `crates/edgezero-adapter-fastly/src/fastly_response_abi.rs` +- Modify only if required: `crates/edgezero-adapter-fastly/src/response.rs` +- Modify only if required: `crates/edgezero-adapter-fastly/src/request.rs` +- Test: `crates/edgezero-adapter-fastly/tests/contract.rs` + +- [x] Compile the Fastly adapter for `wasm32-wasip1` with its provider feature and record every + concrete 0.13.1 API or exhaustive-enum failure. +- [x] For each behavioral compiler failure, add or tighten the smallest host-testable mapping + test before changing production code. Treat new SDK error variants conservatively and preserve + fixed public diagnostics. +- [x] Adapt the provider boundary without introducing `PendingRequest::send_to_client`, an SDK + compatibility `cfg`, or a response-buffering fallback. +- [x] Run `cargo test -p edgezero-adapter-fastly --features cli` after each Rust edit. +- [x] Run the Fastly target check and Viceroy-backed tests used by the repository. Verify owned + response transmission, outbound limits, positional harvest, and `BestEffort` deadline behavior + remain unchanged. + +### Task 4: Adapt Cloudflare To Worker 0.8.5 And Correct Deadline Claims + +**Files:** +- Modify: `crates/edgezero-adapter-cloudflare/src/cli.rs` +- Modify only if required: `crates/edgezero-adapter-cloudflare/src/outbound.rs` +- Modify only if required: `crates/edgezero-adapter-cloudflare/src/response.rs` +- Modify only if required: `crates/edgezero-adapter-cloudflare/src/request.rs` +- Test: `crates/edgezero-adapter-cloudflare/tests/contract.rs` + +- [x] Change production capability dispatch so `OutboundDeadlines` and + `StreamedUploadDeadlines` return `BestEffort`; keep `OutboundFlexiblePhaseBudget`, + `SendAllSlotIsolation`, `OutboundHttp`, and lazy passthrough at their independently verified + levels. +- [x] Run the previously failing capability test and verify it passes. +- [x] Compile the adapter for `wasm32-unknown-unknown` with Worker 0.8.5 and capture actual binding + failures before editing provider code. +- [x] Add a focused failing regression test before any required behavior adaptation, then make + the minimum provider-boundary change while preserving manual redirect handling, + `encodeResponseBody = "manual"`, the single owned `AbortController`, typed errors, and fixed + public diagnostics. +- [x] Run `cargo test -p edgezero-adapter-cloudflare --all-features` and the Cloudflare WASM + target check. Compilation and local abort tests must not be described as deployed cancellation + proof. + +### Task 5: Adapt Spin To SDK 7.0.0 As A Hard Cut + +**Files:** +- Modify only if required: `crates/edgezero-adapter-spin/src/outbound.rs` +- Modify only if required: `crates/edgezero-adapter-spin/src/request.rs` +- Modify only if required: `crates/edgezero-adapter-spin/src/response.rs` +- Modify only if required: `crates/edgezero-adapter-spin/src/lib.rs` +- Test: `crates/edgezero-adapter-spin/tests/contract.rs` + +- [x] Compile the adapter for `wasm32-wasip2` with Spin SDK 7 and record concrete WASIp3, + `wit-bindgen`, future, stream, body-writer, and result-writer API differences. +- [x] Add a focused failing test for every behavioral adaptation that can be exercised on the + host; use target compilation as the red gate for generated binding signature changes. +- [x] Update imports and owned exchange plumbing directly to the SDK 7 API. Remove obsolete SDK 6 + aliases/imports instead of retaining version branches. +- [x] Preserve the timer race for inbound reads, outbound exchange ownership, response-result + completion, cancellation acknowledgement, and existing `BestEffort` provider limitations. +- [x] Run `cargo test -p edgezero-adapter-spin --all-features` after each Rust edit and finish with + the Spin `wasm32-wasip2` target check. +- [x] Execute the nonzero Spin contract and SDK-resource suites under Wasmtime 48.0.2 with + Preview 3 async and HTTP enabled; compilation alone does not establish runtime compatibility. + +### Task 6: Align Generator, Demo, And Normative Documentation + +**Files:** +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify if generated output changes: provider templates under + `crates/edgezero-adapter-*/src/templates/` +- Modify: `docs/guide/capabilities.md` +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` +- Modify: `scripts/check_outbound_docs_contract.mjs` +- Verify: `examples/app-demo/crates/app-demo-adapter-*/` + +- [x] Update generated dependency strings to Fastly 0.13, Worker 0.8.5, and Spin SDK 7; run the + focused generator tests until the red assertions pass. +- [x] Generate a fresh all-adapter application in a temporary directory and compare its + entrypoints and manifests with `examples/app-demo`; remove any obsolete SDK 6/0.12 path rather + than preserving it. +- [x] Change both public and normative matrices so Cloudflare outbound and streamed-upload + deadlines are `BestEffort`, with a concise footnote that `AbortController` wiring is present + but deployed host-observed cancellation evidence is still required for `Native`. +- [x] Tighten the trait summary to “attempt every eligible request before harvest” and preserve + the explicit distinction: Axum/Cloudflare/Spin drive complete exchanges concurrently, while + Fastly dispatches sequentially before ordered harvest and does not guarantee overlap. +- [x] Clarify that a successful slot is contractually terminal and on time; consumers must not + reconstruct lateness from a post-batch clock sample, and no absolute terminal instant is part + of the API. +- [x] Update current SDK references and evidence commands to 0.13.1/0.8.5/7.0.0. Leave explicitly + historical baseline and phase-plan references unchanged. +- [x] Run `node scripts/check_outbound_docs_contract.mjs` and confirm matrix parity. + +### Task 7: Remove Obsolete Paths And Verify The Complete Hard Cut + +**Files:** +- Inspect: all modified files and lockfiles +- Modify only to remove discovered obsolete compatibility code or stale current documentation + +- [x] Run `rg` for old SDK pins, SDK-version `cfg` branches, response-buffering constants, + removed entrypoint signatures, and stale `Native` Cloudflare deadline claims. Classify each + remaining old version reference as explicitly historical or remove/update it. +- [x] Run `cargo fmt --all -- --check`. +- [x] Run `cargo test --workspace --all-targets`. +- [x] Run `cargo clippy --workspace --all-targets --all-features -- -D warnings`. +- [x] Run `cargo check --workspace --all-targets --features "fastly cloudflare spin"`. +- [x] Run provider target checks for Fastly `wasm32-wasip1`, Cloudflare + `wasm32-unknown-unknown`, and Spin `wasm32-wasip2`. +- [x] Run the repository's Viceroy, legacy-API, documentation-contract, and generated-project + integration checks. +- [x] In `examples/app-demo`, run format, clippy, tests, and all three provider target checks with + its refreshed lockfile. +- [x] Run documentation format/lint/build checks. +- [x] Review `git diff --check`, `git diff --stat`, and the full scoped diff. Confirm no unrelated + dirty-worktree changes were overwritten and report any provider-host evidence that could not be + run locally. diff --git a/docs/superpowers/plans/2026-09-14-outbound-http-final-review-hardening.md b/docs/superpowers/plans/2026-09-14-outbound-http-final-review-hardening.md new file mode 100644 index 000000000..0466cf350 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-outbound-http-final-review-hardening.md @@ -0,0 +1,126 @@ +# Outbound HTTP Final Review Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close every independently reproduced defect from the final whole-feature self-review without compatibility shims. + +**Architecture:** Preserve the existing core-owned outbound, ingress, config, and response-egress contracts. Tighten validation and lifecycle ordering at their current ownership boundaries, carry the application monotonic clock through bounded store APIs, and enforce Spin platform-host drift before runtime-producing actions. + +**Tech Stack:** Rust 2024, `http`, `url`, `futures`, Reqwest, Workers/Web APIs, Fastly SDK, Spin SDK/WASI HTTP, TOML manifests, Cargo and Node documentation gates. + +**Execution note:** Unchecked `run_test_nonzero.sh` red-phase steps are retained as the original +TDD recipe. All implementation and final green-phase checks are recorded below. + +**Post-push CI note:** The exact Cloudflare WASM Clippy gate exposed target-only restriction +lints that the earlier host/all-feature runs did not surface. The follow-up scopes test imports, +removes shadowed bindings, keeps test modules after production items, corrects the superseded Axum +blocking-bridge text, and adds a documentation regression guard. The exact target Clippy command +and the 21-test browser contract plus 18-test runtime suites pass after the correction. + +--- + +### Task 1: Core protocol and stream terminal hardening + +**Files:** +- Modify: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-core/src/compression.rs` +- Modify: `crates/edgezero-core/src/response_egress_framing.rs` +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` +- Modify: `docs/superpowers/specs/2026-09-08-response-egress-design.md` + +- [x] Add failing tests named `response_normalization_rejects_non_utf8_content_length`, `typed_outbound_request_rejects_empty_userinfo`, `outbound_normalization_strips_proxy_connection`, and `terminal_stream_error_releases_source_before_yield`. +- [x] Run `scripts/run_test_nonzero.sh response_normalization_rejects_non_utf8_content_length cargo test --offline --locked -p edgezero-core response_normalization_rejects_non_utf8_content_length`, `scripts/run_test_nonzero.sh typed_outbound_request_rejects_empty_userinfo cargo test --offline --locked -p edgezero-core typed_outbound_request_rejects_empty_userinfo`, `scripts/run_test_nonzero.sh outbound_normalization_strips_proxy_connection cargo test --offline --locked -p edgezero-core outbound_normalization_strips_proxy_connection`, and `scripts/run_test_nonzero.sh terminal_stream_error_releases_source_before_yield cargo test --offline --locked -p edgezero-core terminal_stream_error_releases_source_before_yield`; confirm failure for the reviewed reason. +- [x] Preserve framing-sensitive headers until validation, apply typed-URI raw-authority checks, extend request/upstream-response/downstream-egress hop-by-hop stripping to `Proxy-Connection`, and drop terminal stream state before yielding errors. +- [x] Amend the normative field-preservation and terminal-cleanup rules for those exact behaviors. +- [x] Run `cargo test --offline --locked -p edgezero-core` and `node scripts/check_outbound_docs_contract.mjs`. + +### Task 2: Axum and Cloudflare deadline ordering + +**Files:** +- Modify: `crates/edgezero-adapter-axum/src/outbound.rs` +- Modify: `crates/edgezero-adapter-axum/src/response.rs` +- Modify: `crates/edgezero-adapter-axum/tests/contract.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/response.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` + +- [x] Add failing tests named `request_timer_arms_from_final_remaining_budget` and `response_egress_post_ready_deadline_precedence` for Axum, plus `raw_fetch_timer_arms_from_final_remaining_budget`, `response_egress_preflight_precedes_writer_operation`, and `bodyless_response_checks_cancel_and_deadline_before_handoff` for Cloudflare. +- [ ] Run `scripts/run_test_nonzero.sh request_timer_arms_from_final_remaining_budget cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils request_timer_arms_from_final_remaining_budget` and `scripts/run_test_nonzero.sh response_egress_post_ready_deadline_precedence cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils response_egress_post_ready_deadline_precedence`; confirm the reviewed failures. +- [ ] Run `scripts/run_test_nonzero.sh raw_fetch_timer_arms_from_final_remaining_budget cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils raw_fetch_timer_arms_from_final_remaining_budget`, `scripts/run_test_nonzero.sh response_egress_preflight_precedes_writer_operation cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils response_egress_preflight_precedes_writer_operation`, and `scripts/run_test_nonzero.sh bodyless_response_checks_cancel_and_deadline_before_handoff cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils bodyless_response_checks_cancel_and_deadline_before_handoff`; confirm the reviewed failures. +- [x] Move outbound remaining-budget samples to the last pre-dispatch boundary, make Cloudflare operation construction lazy until after preflight, and apply post-ready response-egress arbitration consistently. +- [x] Run `cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils`, `cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils`, and `cargo check --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --all-targets`. +- [x] Run `CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner scripts/run_test_nonzero.sh dispatch_runs_router_and_returns_response cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --test contract` and `CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner scripts/run_test_nonzero.sh streamed_upload_eof_yields_before_final_precedence cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --lib`. + +### Task 3: Owned ingress and error egress on every adapter + +**Files:** +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-adapter-axum/src/service.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/request.rs` +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` +- Modify: `crates/edgezero-adapter-spin/src/request.rs` +- Modify: each adapter's `tests/contract.rs` +- Modify: `docs/superpowers/specs/2026-08-22-inbound-body-design.md` +- Modify: `docs/superpowers/specs/2026-09-08-response-egress-design.md` + +- [x] Add `normalized_ingress_error_returns_typed_response_without_application_observation` and `post_admission_failure_uses_owned_error_egress` to every applicable adapter contract suite. +- [ ] Run both named filters through `scripts/run_test_nonzero.sh` for each adapter: Axum with `--no-default-features --features axum,test-utils --test contract`, and Cloudflare, Fastly, and Spin with `--no-default-features --features test-utils --test contract`; confirm all eight named invocations fail for the reviewed boundary mismatch. +- [x] Keep provider parser failures before normalized ingress metadata outside response egress. Route normalized-head failures and admission refusals through a detached envelope carrying the app clock, bounded default policy, and no-op observer. +- [x] Treat successful `PreparedIngress` creation as the owned response-egress boundary. Add one core helper that constructs a post-admission error envelope with the app clock, request method/start, route metadata where available, application policy, and observer. +- [x] Route body-source construction, core-request conversion, and dispatch-rendering failures after that boundary through the owned helper; retain provider errors only when bounded response construction or delivery also fails. +- [x] Amend the ingress/egress specs to pin this exact boundary and its no-application-observation pre-admission behavior. +- [x] Run `cargo test --offline --locked -p edgezero-core`, `cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test contract`, `cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils --test contract`, `cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils --test contract`, and `cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features test-utils --test contract`. + +### Task 4: Config deadline clock and precedence hard cut + +**Files:** +- Modify: `crates/edgezero-core/src/config_store.rs` +- Modify: `crates/edgezero-core/src/secret_store.rs` +- Modify: `crates/edgezero-core/src/extractor.rs` +- Modify: all adapter `config_store.rs` and `secret_store.rs` implementations +- Modify: `docs/superpowers/specs/2026-06-16-blob-app-config.md` +- Modify: `docs/guide/capabilities.md` + +- [x] Add core tests `bounded_read_uses_injected_clock` and `bounded_read_deadline_wins_ready_provider_error`; add `bounded_config_read_uses_injected_clock` and `bounded_secret_read_uses_injected_clock` to every adapter. +- [ ] Run `scripts/run_test_nonzero.sh bounded_read_uses_injected_clock cargo test --offline --locked -p edgezero-core bounded_read_uses_injected_clock` and `scripts/run_test_nonzero.sh bounded_read_deadline_wins_ready_provider_error cargo test --offline --locked -p edgezero-core bounded_read_deadline_wins_ready_provider_error`; confirm the reviewed failures. +- [ ] Run the two adapter filters through `scripts/run_test_nonzero.sh` for Axum with `--no-default-features --features axum,test-utils`, and for Cloudflare, Fastly, and Spin with `--no-default-features --features test-utils`; include provider-error-at-expiry coverage and confirm all eight named invocations fail. +- [x] Hard-cut bounded config/secret read signatures to receive the application `MonotonicClock`; remove process-global checks from extraction paths. +- [x] Perform post-ready deadline arbitration before propagating provider results. +- [x] Update every normative API/extractor sketch and capability explanation to require the same application clock. +- [x] Run `cargo test --offline --locked -p edgezero-core`, `cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils`, `cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils`, `cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils`, and `cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features test-utils`. + +### Task 5: Spin host plumbing, response settlement, evidence, and documentation + +**Files:** +- Modify: Spin adapter CLI validation and tests +- Modify: `crates/edgezero-cli` runtime/generator tests as needed +- Modify: generated and demo Spin manifests only if their canonical defaults change +- Modify: outbound implementation/specification documents and documentation checker +- Modify: Fastly and Spin outbound/contract/resource tests + +- [x] Add failing selected-component host-drift tests with the shared prefix `spin_selected_component_` for absent/default, explicit hosts, wildcard expansion, normalization, malformed platform entries, ordering, and ambiguous components. +- [ ] Run `scripts/run_test_nonzero.sh spin_selected_component_ cargo test --offline --locked -p edgezero-adapter --features cli spin_selected_component_` and confirm failure. +- [x] Enforce canonical Spin `allowed_outbound_hosts` equality before build/serve/deploy without linking the Spin adapter into no-default-feature CLI builds. +- [x] Add `spin_host_drift_blocks_runtime_actions_before_dispatch` in `edgezero-cli` covering build, serve, deploy, staged deploy, shell and registered targets, and proving zero child/adapter side effects on drift. +- [ ] Run `scripts/run_test_nonzero.sh spin_host_drift_blocks_runtime_actions_before_dispatch cargo test --offline --locked -p edgezero-cli spin_host_drift_blocks_runtime_actions_before_dispatch` and confirm failure; also run `cargo check --offline --locked -p edgezero-cli --no-default-features --features cli` to prove the check does not require the Spin adapter. +- [x] Exercise production Fastly dispatch/harvest under Viceroy, plus Spin coordinator and target-SDK resource construction/drop behavior. Keep deployed provider-observation promotion evidence explicitly unavailable. +- [x] Align Spin framing-bodyless and `205 Reset Content` settlement with the shared contract: framing-bodyless responses release without polling, declared-body 205 responses release immediately, and undeclared-body 205 responses perform at most one deadline-bounded read before release. +- [x] Mark implemented phase status accurately and remove stale Spin SDK/Viceroy references; extend the docs checker. +- [x] Run `CARGO_TARGET_WASM32_WASIP1_RUNNER='viceroy run' scripts/run_test_nonzero.sh send_all_dispatches_every_slot_before_wait cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features fastly,test-utils --target wasm32-wasip1 --test contract`. +- [x] Run `CARGO_TARGET_WASM32_WASIP2_RUNNER='/tmp/wasmtime-v48.0.2-aarch64-macos/wasmtime run -W component-model-async=y -S p3=y -S http=y' scripts/run_test_nonzero.sh sdk_request_and_response_completion_resources_construct cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources`. +- [x] Run `cargo test --offline --locked -p edgezero-adapter --features cli`, `cargo test --offline --locked -p edgezero-cli`, `scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test --offline --locked -p edgezero-cli --test generated_project_builds`, `(cd examples/app-demo && cargo test --locked --workspace --all-targets)`, and `node scripts/check_outbound_docs_contract.mjs`. + +### Task 6: Full verification and delivery + +- [x] Run `cargo fmt --all -- --check`. +- [x] Run `cargo test --workspace --all-targets`. +- [x] Run `cargo clippy --workspace --all-targets --all-features -- -D warnings`. +- [x] Run `cargo check --workspace --all-targets --features "fastly cloudflare spin"`. +- [x] Run `bash scripts/check_adapter_feature_matrix.sh axum native`, `bash scripts/check_adapter_feature_matrix.sh cloudflare native`, `bash scripts/check_adapter_feature_matrix.sh fastly native`, and `bash scripts/check_adapter_feature_matrix.sh spin native`. +- [x] Run `bash scripts/check_adapter_feature_matrix.sh cloudflare wasm32-unknown-unknown`, `bash scripts/check_adapter_feature_matrix.sh fastly wasm32-wasip1`, and `bash scripts/check_adapter_feature_matrix.sh spin wasm32-wasip2`. +- [x] Run `CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner scripts/run_test_nonzero.sh dispatch_runs_router_and_returns_response cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --test contract`, `CARGO_TARGET_WASM32_WASIP1_RUNNER='viceroy run' scripts/run_test_nonzero.sh dispatch_runs_router_and_returns_response cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features fastly,test-utils --target wasm32-wasip1 --test contract`, and `CARGO_TARGET_WASM32_WASIP2_RUNNER='wasmtime run -W component-model-async=y -S p3=y -S http=y' scripts/run_test_nonzero.sh router_dispatches_get_and_returns_response cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features spin,test-utils --target wasm32-wasip2 --test contract`. +- [x] Run `CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner scripts/run_test_nonzero.sh streamed_upload_eof_yields_before_final_precedence cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --lib`, `CARGO_TARGET_WASM32_WASIP1_RUNNER='viceroy run' scripts/run_test_nonzero.sh backend_creation_error_table_is_exhaustive cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features fastly,test-utils --target wasm32-wasip1 --lib`, and `CARGO_TARGET_WASM32_WASIP2_RUNNER='wasmtime run -W component-model-async=y -S p3=y -S http=y' scripts/run_test_nonzero.sh spin_error_code_table_is_exhaustive cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources`. +- [x] Run `scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test --offline --locked -p edgezero-cli --test generated_project_builds`. +- [x] From `examples/app-demo`, run `cargo test --locked --workspace --all-targets`, `cargo check --locked -p app-demo-adapter-cloudflare --target wasm32-unknown-unknown --no-default-features --features cloudflare`, `cargo check --locked -p app-demo-adapter-fastly --target wasm32-wasip1 --no-default-features --features fastly`, and `cargo check --locked -p app-demo-adapter-spin --target wasm32-wasip2 --no-default-features --features spin`. +- [x] Run `bash scripts/check_outbound_legacy_api.sh`, `node scripts/check_outbound_docs_contract.mjs`, and from `docs`, `npm run lint`, `npm run format`, and `npm run build`. +- [x] Run `git diff --check`, review `git diff --stat` and `git diff`, commit the remediation, run `git diff --check origin/main...HEAD`, push PR 275, and report the exact SHA and remaining provider limitations. diff --git a/docs/superpowers/plans/2026-09-15-outbound-http-consumer-alignment.md b/docs/superpowers/plans/2026-09-15-outbound-http-consumer-alignment.md new file mode 100644 index 000000000..0633c490c --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-outbound-http-consumer-alignment.md @@ -0,0 +1,396 @@ +# Outbound HTTP Consumer Alignment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complete the portable outbound contract needed by completion-driven, deadline-bound applications while preserving EdgeZero ownership of provider transport and Fastly response egress. + +**Architecture:** Replace the terminal-vector-only batch trait method with an adapter-owned `OutboundBatch` that yields indexed buffered completions until an absolute cutoff. Keep ordered collection as a core convenience over that one driver, add portable cache-bypass and validated wire-authority policy to `OutboundRequest`, and add a closed Fastly lifecycle runner whose hooks cannot take over response transmission. + +**Tech Stack:** Rust 2024, `async-trait`, `futures-util`, `web-time`, Fastly SDK, Cloudflare `worker`, Spin WASI HTTP, Axum/reqwest, Cargo contract tests, Node documentation checks. + +--- + +### Task 1: Lock the revised public contract in the specification + +**Files:** +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` +- Modify: `docs/guide/capabilities.md` + +- [x] **Step 1: Replace the terminal-vector-only batching decision** + +Document this hard-cut API and remove normative statements that call `send_all` the only fan-out primitive: + +```rust +pub struct OutboundBatchItem { + pub index: usize, + pub result: OutboundSlotResult, +} + +pub struct OutboundBatchResults { + pub slots: Vec>, +} + +pub trait OutboundHttpClient: Send + Sync { + async fn send(&self, request: OutboundRequest) + -> Result; + + fn start_batch_until( + &self, + requests: Vec, + cutoff: Deadline, + ) -> OutboundBatch; +} +``` + +Specify that `OutboundBatch::next()` is cancellation-safe and yields each terminal slot at most once in observed completion order. The cutoff wins equality. Dropping or cancelling stops observation and applies the adapter's strongest available teardown without claiming that already-issued network side effects are undone. + +- [x] **Step 2: Specify ordered collection as one-driver convenience** + +Document `HttpClient::send_all_until` as `start_batch_until(...).collect().await`, with `slots.len() == requests.len()`, preflight failures as `Some(Err(_))`, and only unresolved-at-cutoff slots as `None`. Remove the old `send_all` API instead of retaining a compatibility alias. + +- [x] **Step 3: Specify request transport policy** + +Add `OutboundCachePolicy::{PlatformDefault, Bypass}` and a validated `host_authority_override`. State that the URI continues to own connection destination, SNI, and certificate identity; the override controls only the outgoing HTTP authority. Document Spin as `Unsupported` for authority separation and require preflight rejection rather than silent semantic drift. + +- [x] **Step 4: Specify the closed Fastly lifecycle runner** + +Define ordering: received request -> raw request mutation/extension injection -> EdgeZero conversion and ingress -> route -> response mutation -> egress policy/framing -> platform send -> return post-send state. Hooks borrow values and cannot send, stream, or consume the response themselves. + +- [x] **Step 5: Run documentation checks** + +Run: `node scripts/check_outbound_docs_contract.mjs` + +Expected: PASS with no stale normative `send_all`-only or canonical-authority contradiction. + +### Task 2: Add the core completion-driven batch API + +**Files:** +- Modify: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: `crates/edgezero-core/src/time.rs` + +- [x] **Step 1: Write failing core contract tests** + +Add tests proving empty batches, reverse completion order, original indices, exact-once delivery, cancellation-safe `next()`, explicit cancellation, preflight result delivery, cutoff equality, ordered `None` collection, one shared start sample, and backwards-clock handling. + +- [x] **Step 2: Run the focused tests and verify RED** + +Run: `cargo test -p edgezero-core outbound::tests::batch -- --nocapture` + +Expected: FAIL because `OutboundBatch`, `start_batch_until`, and `send_all_until` do not exist. + +- [x] **Step 3: Implement the opaque batch driver** + +Add `OutboundBatch`, `OutboundBatchItem`, and `OutboundBatchResults`. Keep adapter internals opaque through a hidden driver boundary. `next()` must not remove a slot until it returns the item, and `cancel()` must return unresolved indices synchronously while dropping driver-owned work. + +- [x] **Step 4: Hard-cut the trait and handle** + +Remove `OutboundHttpClient::send_all` and `HttpClient::send_all`. Add `start_batch_until` to both, plus `HttpClient::send_all_until` implemented only by collecting the returned batch. + +- [x] **Step 5: Extend dispatch-budget selection** + +Pass the batch cutoff into budget selection as a third candidate without mutating each request. Hard-cut `BudgetSource::BatchDeadline` to `RequestDeadline`, add `BatchCutoff`, and preserve exact attribution to the tightest source. + +- [x] **Step 6: Run core tests and verify GREEN** + +Run: `cargo test -p edgezero-core` + +Expected: PASS. + +### Task 3: Add cache and wire-authority request policy + +**Files:** +- Modify: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/src/error.rs` + +- [x] **Step 1: Write failing policy tests** + +Test default cache policy, bypass round-tripping through parts, valid DNS/IPv4/bracketed-IPv6 authority overrides, malformed/userinfo/path/control rejection, raw `Host` stripping, wire-authority selection, and continued URI-derived backend/SNI/certificate identity. + +- [x] **Step 2: Run focused tests and verify RED** + +Run: `cargo test -p edgezero-core outbound::tests::outbound_request_policy -- --nocapture` + +Expected: FAIL because the policy fields and builders do not exist. + +- [x] **Step 3: Implement the typed fields and builders** + +Add: + +```rust +pub enum OutboundCachePolicy { + PlatformDefault, + Bypass, +} + +pub fn cache_policy(self, policy: OutboundCachePolicy) -> Self; +pub fn host_authority_override(self, authority: &str) -> Result; +pub fn host_authority(&self) -> &str; +``` + +Store a parsed authority, not an unchecked string. Preserve it through `into_parts` and `from_parts`; keep `backend_target`, `sni_hostname`, and `cert_host` URI-derived. + +- [x] **Step 4: Run core tests and verify GREEN** + +Run: `cargo test -p edgezero-core` + +Expected: PASS. + +### Task 4: Implement Axum batch, cache, and authority behavior + +**Files:** +- Modify: `crates/edgezero-adapter-axum/src/outbound.rs` +- Modify: `crates/edgezero-adapter-axum/tests/contract.rs` + +- [x] **Step 1: Write failing Axum contract tests** + +Cover completion order, cutoff with recoverable completed slots, drop cancellation, Host override observed by a local server while TLS/connection target stays URI-derived, and cache bypass not synthesizing origin-visible cache headers. + +- [x] **Step 2: Run tests and verify RED** + +Run: `cargo test -p edgezero-adapter-axum outbound -- --nocapture` + +Expected: FAIL against the removed trait method and missing policy conversion. + +- [x] **Step 3: Implement a private `FuturesUnordered` batch driver** + +Prepare every slot from one start sample, enqueue immediate preflight failures, drive complete buffered exchanges, race against the absolute cutoff, and drop unresolved reqwest futures on cancellation. + +- [x] **Step 4: Apply wire authority and cache semantics** + +Set the final `Host` from the validated authority field. Treat cache bypass as satisfied without adding `Cache-Control` because reqwest has no adapter-managed intermediary cache. + +- [x] **Step 5: Run Axum tests and workspace tests** + +Run: `cargo test -p edgezero-adapter-axum && cargo test --workspace --all-targets` + +Expected: PASS. + +### Task 5: Implement Cloudflare batch, cache, and authority behavior + +**Files:** +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` +- Modify: `examples/app-demo/wrangler.toml` +- Modify: `examples/app-demo/wrangler.ci.toml` + +- [x] **Step 1: Write failing native and WASM contract tests** + +Cover completion-order driving, cutoff cancellation through `AbortController`, buffered-only preflight, `CacheMode::NoStore`, duplicate headers, and validated Host override conversion. Keep deployed Host observation as the evidence gate before claiming Native authority separation. + +- [x] **Step 2: Run available tests and verify RED** + +Run: `cargo test -p edgezero-adapter-cloudflare --features test-utils` + +Expected: FAIL until the new batch and request policy are mapped. + +- [x] **Step 3: Implement the Cloudflare driver and mappings** + +Use completion-driven futures with one abort guard per slot. Set `NoStore` only for `Bypass`; append the validated Host authority and continue manual redirect handling. Keep authority separation `BestEffort` until the host-observed probe proves the runtime retained the override. + +- [x] **Step 4: Run Cloudflare checks** + +Run: `cargo test -p edgezero-adapter-cloudflare --features test-utils` + +Run: `cargo check -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare` + +Expected: PASS. + +### Task 6: Implement Spin batch and fail-closed authority behavior + +**Files:** +- Modify: `crates/edgezero-adapter-spin/src/outbound.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` + +- [x] **Step 1: Write failing Spin contract tests** + +Cover completion order, cutoff, owned exchange teardown, full-budget timers, cache bypass without synthetic headers, and explicit Host override rejection before body polling or provider dispatch. + +- [x] **Step 2: Run native tests and verify RED** + +Run: `cargo test -p edgezero-adapter-spin --features test-utils` + +Expected: FAIL until the driver and authority preflight exist. + +- [x] **Step 3: Implement the Spin driver and mappings** + +Drive complete exchange futures under the batch cutoff. Keep cancellation `BestEffort`. Treat cache bypass as satisfied without request-header mutation. Reject authority override because WASI HTTP's authority is also the connection target. + +- [x] **Step 4: Run Spin tests and WASM check** + +Run: `cargo test -p edgezero-adapter-spin --features test-utils` + +Run: `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` + +Expected: PASS. + +### Task 7: Implement Fastly indexed completion and transport policy + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/outbound.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` + +- [x] **Step 1: Write failing target-neutral orchestration tests** + +Cover dispatch-before-select, duplicate targets retaining unique indices, reverse completion order, preflight failures, cutoff equality, explicit cancellation, heterogeneous backend identities, Host override identity separation, and cache bypass on both single and batch paths. + +- [x] **Step 2: Run tests and verify RED** + +Run: `cargo test -p edgezero-adapter-fastly --features test-utils outbound -- --nocapture` + +Expected: FAIL because the existing orchestrator returns only an ordered terminal vector. + +- [x] **Step 3: Replace terminal-vector harvest with an indexed select driver** + +Dispatch every eligible request before blocking. Use Fastly pending-handle selection to associate the selected handle with its original index. Preserve documented sequential dispatch, bounded selection-group, and synchronous selected-body-drain limitations. Stop observation at cutoff and classify cancellation/slot isolation as `BestEffort`. + +- [x] **Step 4: Apply Fastly request policy** + +Use the wire authority for `.override_host`, keep backend target/SNI/certificate URI-derived, include wire authority and relevant budget in backend identity, and call `set_pass(true)` for cache bypass. + +- [x] **Step 5: Run Fastly tests and WASM check** + +Run: `cargo test -p edgezero-adapter-fastly --features test-utils` + +Run: `cargo check -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly` + +Expected: PASS. + +### Task 8: Add the closed Fastly request/response lifecycle runner + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` +- Modify: `crates/edgezero-adapter-fastly/src/response.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` +- Modify: `docs/guide/adapters/fastly.md` + +- [x] **Step 1: Write failing lifecycle tests** + +Prove request mutation precedes conversion, injected extensions reach middleware, response mutation precedes egress policy/framing, invalid framing fails closed, streaming stays adapter-owned, transport send happens exactly once, detached ingress failures skip the routed-response hook, and post-send state is returned only after terminal egress observation. + +- [x] **Step 2: Run tests and verify RED** + +Run: `cargo test -p edgezero-adapter-fastly request -- --nocapture` + +Expected: FAIL because only the auto-receiving, request-extension-only runner exists. + +- [x] **Step 3: Implement the lower-level closed runner** + +Add a `FastlyService` method that accepts an already-received request, a borrow-only request preparation hook, and a borrow-only response finalization hook that may return post-send state. Keep conversion, ingress, response-egress begin/finish, stream ownership, and platform delivery private. + +- [x] **Step 4: Build the top-level runner over it and remove the superseded API** + +Keep `run_app` as the simple generated entrypoint. Replace `run_app_with_request_extensions` with one `run_app_with_hooks` helper built over the closed runner; do not keep the request-extension-only compatibility API. + +- [x] **Step 5: Run Fastly tests** + +Run: `cargo test -p edgezero-adapter-fastly` + +Expected: PASS. + +### Task 9: Update capabilities, documentation, demos, and generators + +**Files:** +- Modify: `crates/edgezero-core/src/manifest.rs` +- Modify: `crates/edgezero-adapter/src/registry.rs` +- Modify: `crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/lib.rs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` +- Modify: `docs/guide/proxying.md` +- Modify: `docs/guide/adapters/overview.md` +- Modify: `docs/guide/adapters/{axum,cloudflare,fastly,spin}.md` +- Modify: `docs/guide/capabilities.md` +- Modify: `scripts/check_outbound_docs_contract.mjs` +- Modify: `scripts/check_outbound_legacy_api.sh` + +- [x] **Step 1: Write failing capability and generator tests** + +Add capabilities for completion-driven batching, pending cancellation, cache bypass, and wire-authority override. Assert each adapter's exact `Native`/`BestEffort`/`Unsupported` row. Assert generated/demo code uses the hard-cut API and no removed symbol remains. + +Use these rows: + +| Capability | Axum | Cloudflare | Fastly | Spin | +| --- | --- | --- | --- | --- | +| `outbound-batch-completion-order` | Native | Native | BestEffort | Native | +| `outbound-batch-cancellation` | Native | BestEffort | BestEffort | BestEffort | +| `outbound-batch-slot-isolation` | Native | Native | BestEffort | Native | +| `outbound-cache-bypass` | Native | Native | Native | Native | +| `outbound-authority-override` | Native | BestEffort | Native | Unsupported | + +Remove the old `send-all-slot-isolation` capability rather than retaining an alias. + +- [x] **Step 2: Run tests and verify RED** + +Run: `cargo test -p edgezero-core manifest -- --nocapture` + +Run: `cargo test -p edgezero-cli generator -- --nocapture` + +Expected: FAIL until capability metadata and generated source are migrated. + +- [x] **Step 3: Implement metadata and migrate consumers** + +Update adapter capability tables, manifest parsing/rendering, public guides, example handlers, and scaffold assertions. Extend the legacy checker to reject `send_all(` and direct Fastly response transmission in generated entrypoints. + +- [x] **Step 4: Run focused verification** + +Run: `cargo test -p edgezero-core && cargo test -p edgezero-cli` + +Run: `node scripts/check_outbound_docs_contract.mjs` + +Run: `bash scripts/check_outbound_legacy_api.sh` + +Expected: PASS. + +### Task 10: Complete whole-feature verification and cleanup + +**Files:** +- Review: all files changed by Tasks 1-9 + +- [x] **Step 1: Remove superseded code** + +Search for the old trait method, ordered Fastly batch helper, stale cache/Host behavior, and direct response-send bypasses. Remove helpers that no longer have callers rather than leaving compatibility code. + +- [x] **Step 2: Run formatting and full native gates** + +Run: `cargo fmt --all -- --check` + +Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings` + +Run: `cargo test --workspace --all-targets` + +Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin"` + +Expected: PASS with zero warnings. + +- [x] **Step 3: Run target and demo gates** + +Run: `cargo check -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly` + +Run: `cargo check -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare` + +Run: `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` + +Run: `cargo test --manifest-path examples/app-demo/Cargo.toml --locked` + +Expected: PASS. + +- [x] **Step 4: Run repository contract gates** + +Run: `node scripts/check_outbound_docs_contract.mjs` + +Run: `bash scripts/check_outbound_legacy_api.sh` + +Run: `node scripts/check_brotli_dependency_contract.mjs` + +Run: `bash scripts/check_no_legacy_typed_reads.sh` + +Expected: PASS. + +- [x] **Step 5: Review the final diff** + +Run: `git diff --check && git status --short` + +Expected: no whitespace errors; only intended files changed. diff --git a/docs/superpowers/plans/2026-09-15-reusable-app-lifecycle.md b/docs/superpowers/plans/2026-09-15-reusable-app-lifecycle.md index 05a68c728..064313266 100644 --- a/docs/superpowers/plans/2026-09-15-reusable-app-lifecycle.md +++ b/docs/superpowers/plans/2026-09-15-reusable-app-lifecycle.md @@ -2,16 +2,22 @@ > Historical implementation plan. The private-helper design and custom callback > choices are superseded by [the custom serving lifecycle plan](2026-09-17-custom-serving-lifecycle.md). -> The implemented public `Sandbox` and `Hooks`-typed Cloudflare/Spin dispatch APIs -> are described in the updated [spec](../specs/2026-09-15-reusable-app-lifecycle-design.md). +> PR #275's outbound hard-cut also supersedes the original infallible assembly, +> public raw conversion, response-returning dispatch, and buffering choices. +> Current APIs are reconciled below; the [outbound HTTP design](../specs/2026-05-21-outbound-http-design.md) +> remains authoritative for admission, outbound execution, response egress, and +> capabilities. The updated [lifecycle spec](../specs/2026-09-15-reusable-app-lifecycle-design.md) +> documents successful-only retention. Historical checkmarks and verification +> counts do not verify the reconciled implementation. -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkboxes for tracking; exceptions and evidence limits are recorded in the execution checkpoint. +> This is a historical decision record, not authorization to restore superseded +> APIs. Any new implementation requires a current approved plan. **Goal:** Add explicit application retention for Fastly, Cloudflare, and Spin while preserving existing defaults and Axum's retained-router behavior. -**Architecture:** Keep ownership in the existing core `App`. Add a private, host-testable initialization helper behind Fastly's new `Serve` conveniences and explicit-metadata dispatch wrappers for Cloudflare/Spin. Concrete application caches and provider HTTP fixtures demonstrate retention without introducing a common scheduler or generic singleton. +**Architecture:** Keep ownership in core `App`, built through fallible `App::build::(PLATFORM)`. Fastly uses public, host-testable `Sandbox` state with `serve_app` / `serve_app_with_hooks`; Cloudflare/Spin use `Hooks`-typed prebuilt dispatch. Retain successful apps only and create fresh registries, admission, and response-egress resources per request. No common scheduler or generic singleton is introduced. -**Tech Stack:** Rust, Fastly SDK 0.12.1/Viceroy, worker 0.8/worker-build/Wrangler, Spin SDK 6/P3, Axum, shell, VitePress/Prettier. +**Tech Stack:** Rust, Fastly SDK 0.13.1/Viceroy 0.21.0, worker 0.8.5/worker-build/Wrangler, Spin SDK 7/P3, Axum, shell, VitePress/Prettier. --- @@ -19,8 +25,8 @@ - Source baseline: `593fc9282a1c56e12bae15f91eef2162f4b6a1b7`. - Governing design: [Reusable application lifecycle spec](../specs/2026-09-15-reusable-app-lifecycle-design.md), including the host/WASM test split and attempted-request accounting added during review. -- This document plans implementation. Before executing code changes, obtain plan approval as required by `CLAUDE.md`. Planning does not authorize deployment, publishing, external comments, or committing unrelated work. -- Preserve existing entry points, generated templates, macro grammar, logging ownership, error translation, buffering, stream caps, and defaults. No new Tokio dependency in core/WASM adapters, no provider-independent scheduler, and no external consumer identifiers in documents or fixtures. +- This document records historical implementation. New code work requires approval under `CLAUDE.md`; this record does not authorize deployment, publishing, external comments, or unrelated commits. +- Preserve lifecycle defaults, macro grammar, and logging ownership while following PR #275's canonical fallible assembly and lazy delivery. Do not restore historical response buffering, collection caps, or removed APIs through compatibility aliases. No new Tokio dependency in core/WASM adapters, provider-independent scheduler, or external consumer identifiers. - Re-read current `CLAUDE.md` and compare source with the baseline before execution. Use `superpowers:using-git-worktrees` when starting approved code work. This planning-only document remains in the current workspace. - Run scoped `cargo test` after each code increment. Provider fixture changes also need the real-target check/build. No host stub proves provider behavior. - The commit messages below are implementation checkpoints, not commands to commit during planning. Commit only focused, verified changes within the approved execution scope. @@ -44,7 +50,7 @@ Production and existing tests: - `crates/edgezero-core/src/router.rs`: forced interleaving/isolation test. - `crates/edgezero-core/src/app_config.rs`: audit recursion-guard cleanup and extend colocated tests only where coverage is missing. - `crates/edgezero-adapter-fastly/src/request.rs`: audit bounded warning-cache behavior; preserve production behavior. -- `crates/edgezero-adapter-fastly/src/lib.rs`: private retained state, colocated host tests, two public serving helpers, SDK exports. +- `crates/edgezero-adapter-fastly/src/lib.rs` and `src/lifecycle.rs`: successful-only `Sandbox`, host tests, `serve_app` / `serve_app_with_hooks`, and SDK exports. - `crates/edgezero-adapter-cloudflare/src/lib.rs`: additive `dispatch_app`. - `crates/edgezero-adapter-spin/src/lib.rs`: additive `dispatch_app`. - `.github/workflows/test.yml`: native harness tests and Fastly HTTP smoke using existing Viceroy setup. @@ -79,7 +85,10 @@ tests/fixtures/reusable-app/ Documentation: `docs/guide/adapters/{overview,fastly,cloudflare,spin,axum}.md`, fixture README, and the fresh-Fastly-instance comment in `examples/app-demo/crates/app-demo-core/src/lib.rs`. -No production request/response module changes are expected. Reuse their dispatchers; add no core lifecycle module or manifest setting. +The original retention work minimized request/response changes. PR #275 now owns +canonical admission and response egress in those modules; do not restore the +historical conversion or collection paths. No core lifecycle module or reuse +manifest setting is introduced. ## Task 1: Protect retained ownership and request separation @@ -106,78 +115,32 @@ fn app_can_be_retained_in_a_shared_owner() { ## Task 2: Host-testable Fastly production initialization -**File:** `crates/edgezero-adapter-fastly/src/lib.rs`. +**Files:** `crates/edgezero-adapter-fastly/src/lib.rs`, `src/lifecycle.rs`. + +The original private, infallible initialization sketch is superseded by the +custom lifecycle work and PR #275. Current production uses `Sandbox` with +independent successful setup and fallible construction guards. Native `cargo test --features fastly` has a reproduced pre-existing unresolved-hostcall linker failure. Compile shared initialization logic under `cfg(any(feature = "fastly", test))`, as this file already does for `FastlyLogging`/`EnvConfig`. Do not introduce fake hostcalls. -- [x] **2.1 Write tests referring to private `RetainedApp` first.** Use counted closures and real core `App` values. Run `cargo test -p edgezero-adapter-fastly --lib retained_app`; expected initial failure: missing helper. -- [x] **2.2 Add this private production state** adjacent to the entry points: +- [x] **2.1 Protect successful-only retention.** Use the production `Sandbox` state with counted closures and real core app construction. Initialization failure leaves the slot empty; setup succeeds independently of app construction. +- [x] **2.2 Use the current production lifecycle:** ```rust -#[cfg(any(feature = "fastly", test))] -#[derive(Default)] -struct RetainedApp { - app: Option, -} +use edgezero_adapter_fastly::{FASTLY_PLATFORM, lifecycle::Sandbox}; +use edgezero_core::app::App; -#[cfg(any(feature = "fastly", test))] -impl RetainedApp { - fn get_or_init( - &mut self, - env: &edgezero_core::env_config::EnvConfig, - owns_logging: impl FnOnce() -> bool, - install_logger: impl FnOnce(&str, log::LevelFilter, bool) -> Result<(), E>, - build: impl FnOnce() -> edgezero_core::app::App, - ) -> Result<&edgezero_core::app::App, E> { - if self.app.is_none() { - let logging = FastlyLogging::from(env); - if logging.use_fastly_logger && !owns_logging() { - install_logger( - logging.endpoint.as_deref().unwrap_or("stdout"), - logging.level, - logging.echo_stdout, - )?; - } - } - Ok(self.app.get_or_insert_with(build)) - } -} +let mut retained = Sandbox::::default(); +retained.initialize(|| App::build::(FASTLY_PLATFORM))?; +let app = retained.state().expect("successful initialization"); ``` -Apply existing documentation/lint conventions. The helper stores successful initialization only; it does not cache errors or emulate SDK termination. Task 3 uses this exact state in production. +`Hooks::configure` returns `Result<(), EdgeError>`; `App::build` installs and +validates platform metadata. No partial app or initialization error is retained. +Custom callbacks decide whether a failure is recoverable; standard serving +propagates it and stops without another send. -- [x] **2.3 Add this degraded-snapshot regression**, importing `App`, `RouterService`, and `EnvConfig` from core in the colocated module: - -```rust -#[test] -fn retained_app_keeps_degraded_first_logging_decision() { - let mut retained = RetainedApp::default(); - let installs = std::cell::Cell::new(0); - let builds = std::cell::Cell::new(0); - let first = EnvConfig::from_vars(std::iter::empty::<(String, String)>()); - let later = EnvConfig::from_vars([ - ("EDGEZERO__LOGGING__ENDPOINT", "fixture-logs"), - ("EDGEZERO__LOGGING__LEVEL", "debug"), - ]); - for env in [&first, &later] { - let app = retained.get_or_init( - env, - || false, - |_, _, _| { - installs.set(installs.get() + 1); - Ok::<(), &'static str>(()) - }, - || { - builds.set(builds.get() + 1); - App::with_name(RouterService::builder().build(), "retained") - }, - ).expect("initialization"); - assert_eq!(app.name(), "retained"); - } - assert_eq!(builds.get(), 1); - assert_eq!(installs.get(), 0); -} -``` +- [x] **2.3 Preserve the degraded first logging snapshot.** Exercise production `setup_once` with an empty initial environment and later successful selector reads. Logging is not reconfigured after successful setup, while runtime/store resolution remains per request. The earlier private-helper test sketch is superseded. - [x] **2.4 Complete these cases:** @@ -185,7 +148,7 @@ fn retained_app_keeps_degraded_first_logging_decision() { | ------------------ | ---------------------------------------------------------------------------------------------- | | Configured logging | `logger`, then `build`, once across two calls; endpoint/level from first env; stdout true | | Owned logging | Zero adapter installs, one build | -| Logger error | Exact injected error, zero builds, no stored app; do not present same-owner retry as supported | +| Logger error | Exact injected error, zero builds, no stored app; custom retries require safe setup side effects | | Fresh owner | Two state values initialize independently, no shared static | | Retained app | Two dispatches use the same configured router/state | @@ -203,31 +166,22 @@ pub use fastly::http::serve::{Serve, ServeSummary}; #[cfg(feature = "fastly")] #[inline] -pub fn serve_app(serve: Serve) -> ServeSummary { - serve_app_with_request_extensions::(serve, |_request, _extensions| {}) -} +pub fn serve_app(serve: Serve) -> ServeSummary; #[cfg(feature = "fastly")] #[inline] -pub fn serve_app_with_request_extensions( +pub fn serve_app_with_hooks( serve: Serve, - mut extend: F, + prepare: Prepare, + finalize: Finalize, ) -> ServeSummary where A: Hooks, - F: FnMut(&fastly::Request, &mut Extensions), -{ - let stores = A::stores(); - let logging = FastlyLogging::from(A::logging_for("fastly")); - let mut retained = RetainedApp::default(); - serve.run(move |req| -> Result { - let app = retained.get_or_init(&logging, A::owns_logging, init_logger, A::build_app)?; - request::dispatch_with_registries(app, req, stores, &mut extend) - }) -} + Prepare: FnMut(&mut fastly::Request, &mut Extensions), + Finalize: FnMut(&mut edgezero_core::Response) -> State; ``` -- [x] **3.2 Add API docs** for ordinary `main`, first-callback initialization, baked logging settings, fresh registries/extensions, `owns_logging`, limits, terminal SDK callback errors, attempted counts, and `.into_result()`. Do not promise progressive streaming on this standard path. +- [x] **3.2 Add API docs** for ordinary `main`, successful-only `Sandbox` retention, degraded logging snapshot, fresh registries/admission/egress, `owns_logging`, limits, terminal errors, attempted counts, and `.into_result()`. Preparation borrows the request before admission; finalization borrows only routed responses before policy/framing. The adapter owns lazy delivery; no native response is returned to the SDK for another send. - [x] **3.3 Verify:** ```sh @@ -238,7 +192,7 @@ cargo check -p edgezero-adapter-fastly --features fastly --all-targets Expected: host logic passes and SDK wrappers type-check. HTTP execution remains Task 7. -- [x] **3.4 Confirm all existing helpers/logger/templates are unchanged.** Checkpoint: `feat(fastly): add opt-in retained app serving`. +- [x] **3.4 Preserve lifecycle defaults and logging ownership.** PR #275 supersedes the earlier unchanged-helper guarantee; do not restore removed APIs or buffering. Checkpoint: `feat(fastly): add opt-in retained app serving`. ## Task 4: Cloudflare and Spin prebuilt dispatch @@ -249,24 +203,12 @@ Expected: host logic passes and SDK wrappers type-check. HTTP execution remains ```rust #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[inline] -pub async fn dispatch_app( +pub async fn dispatch_app( app: &edgezero_core::app::App, - stores: StoresMetadata, req: Request, env: Env, ctx: Context, -) -> Result { - let env_config = env_config_from_worker(&env, stores); - request::dispatch_with_registries( - app, req, env, ctx, - request::RegistryInputs { - config_meta: stores.config, - kv_meta: stores.kv, - secret_meta: stores.secrets, - env_config: &env_config, - }, - ).await -} +) -> Result; ``` - [x] **4.2 Run `cargo test -p edgezero-adapter-cloudflare` and `cargo check -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown`.** Binding/runtime assertions come in Task 8. @@ -275,31 +217,25 @@ pub async fn dispatch_app( ```rust #[cfg(all(feature = "spin", target_arch = "wasm32"))] #[inline] -pub async fn dispatch_app( +pub async fn dispatch_app( app: &App, - stores: edgezero_core::app::StoresMetadata, req: SpinRequest, -) -> anyhow::Result { - let env = EnvConfig::from_env(); - request::dispatch_with_registries( - app, req, stores.config, stores.kv, stores.secrets, &env, - ).await -} +) -> anyhow::Result; ``` -- [x] **4.4 Run `cargo test -p edgezero-adapter-spin` and `cargo check -p edgezero-adapter-spin --features spin --target wasm32-wasip2`.** Add public docs for explicit metadata, caller-owned app/logging, fresh request setup, and overlapping invocations. -- [x] **4.5 Leave both `run_app` bodies unchanged.** Delegating them directly would move configuration reads after app construction. Small additive wrappers preserve exact ordering without extra plumbing. Checkpoint: `feat(adapters): dispatch prebuilt Cloudflare and Spin apps`. +- [x] **4.4 Run `cargo test -p edgezero-adapter-spin` and `cargo check -p edgezero-adapter-spin --features spin --target wasm32-wasip2`.** Document `A::stores()` selection, caller-owned successful app/logging, fresh registries/admission/egress, raw Spin response ownership, and overlapping invocations. +- [x] **4.5 Current `run_app` delegates to `dispatch_app::`.** Logging precedes fallible `App::build::(PLATFORM)`; configuration, runtime variables, and registries are resolved for every dispatch. The original nondelegating wrapper choice is superseded by PR #275. Callers pair retained apps with their construction `Hooks` type and selected platform. Checkpoint: `feat(adapters): dispatch prebuilt Cloudflare and Spin apps`. ## Task 5: Build the portable probe fixture **Files:** `tests/fixtures/reusable-app/{Cargo.toml,Cargo.lock,.gitignore}` and `crates/fixture-core/{Cargo.toml,src/lib.rs}` beneath that directory. - [x] **5.1 Create an isolated workspace.** Use resolver 2, edition 2024, `publish = false`, and `default-members = ["crates/fixture-core"]`. Workspace EdgeZero paths are `../../../crates/`. Provider packages enable their own features; core uses `default-features = false`. Match the repository's SDK versions. Keep Tokio confined to the native Axum fixture. Ignore `target`, `.runs`, `.wrangler`, `.spin`, and generated worker output. Generate and commit the fixture lockfile; all subsequent builds use `--locked`. -- [x] **5.2 Add counted `FixtureApp` and a distinct `OtherApp`.** Count `build_app` and `configure` independently. Supply explicit config/KV/secret metadata with fixture-only names. Issue a fresh unique synthetic candidate boot token on every incoming request; each guest latches only its first candidate as its instance marker and increments a guest-local request ordinal. Never share a candidate token across a run or runtime process: fresh guests must report different markers. This test marker must never contain real request data or appear as a production caching example. +- [x] **5.2 Add counted `FixtureApp` and a distinct `OtherApp`.** Count route construction and fallible `configure` independently through `App::build::(PLATFORM)`. Supply explicit config/KV/secret metadata with fixture-only names. Issue a fresh unique synthetic candidate boot token on every incoming request; each guest latches only its first candidate as its instance marker and increments a guest-local request ordinal. Never share a candidate token across a run or runtime process: fresh guests must report different markers. This test marker must never contain real request data or appear as a production caching example. - [x] **5.3 Add a failing probe contract test.** Two requests must return their own path, header, body, and request-extension tokens; an intentionally shared `Arc` counter must increment. Run `cargo test --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-core`; expect the missing probe behavior to fail. - [x] **5.4 Implement the probe with `#[action]` handlers and core HTTP imports.** Keep shared state limited to counters and the controlled fixture identity. Return JSON records containing instance, ordinal, build/configure counts, and echoed synthetic request fields. - [x] **5.5 Add `/cookies` and `/rendered-error`.** Append two distinct `Set-Cookie` values; return an ordinary renderable application error on the latter route. Add assertions for both and rerun the scoped tests. -- [x] **5.6 Add `/stream`, `/stream-error`, and `/overlap/{id}`.** Use a controlled loopback backend through the injected proxy. The overlap route captures request values before awaiting a barrier and returns them afterward. An in-flight guard increments/decrements atomics without keeping a mutex guard across an await. Return the observed maximum in-flight count. +- [x] **5.6 Add `/stream`, `/stream-error`, and `/overlap/{id}`.** Use a controlled loopback backend through the injected outbound HTTP interface. The overlap route captures request values before awaiting a barrier and returns them afterward. An in-flight guard increments/decrements atomics without keeping a mutex guard across an await. Return the observed maximum in-flight count. - [x] **5.7 Add `/bindings` and `/origin/{id}`.** Report only binding presence and nonsecret fixture markers. Origins come from a finite harness-owned allowlist. Test unknown-origin rejection and both concrete apps' different route identities. - [x] **5.8 Run the locked core fixture tests.** Expected: all portable assertions pass without provider calls. Checkpoint: `test(lifecycle): add portable retained-app probes`. @@ -320,28 +256,28 @@ pub async fn dispatch_app( **Files:** `tests/fixtures/reusable-app/crates/fixture-fastly/{Cargo.toml,fastly.toml,src/lib.rs,src/bin/single_request.rs,src/bin/rebuild_per_request.rs,src/bin/retained_app.rs,src/bin/custom_single_request.rs,src/bin/custom_rebuild_per_request.rs,src/bin/custom_retained_app.rs,src/bin/logger_negative.rs}`; extend harness Fastly orchestration. -- [x] **7.1 Declare explicit binary names.** Use `fixture-fastly-a`, `fixture-fastly-b`, `fixture-fastly-c`, `fixture-fastly-custom-a`, `fixture-fastly-custom-b`, `fixture-fastly-custom-c`, and `fixture-fastly-logger-negative`. Pin SDK 0.12.1 and build on `wasm32-wasip1`. -- [x] **7.2 Implement standard A with the existing single-request macro entry point.** Call `run_app_with_request_extensions`, adding only fixture observations. Preserve its current logging and construction behavior. -- [x] **7.3 Implement standard B using raw `Serve::run`.** Use the baked logging decision once, build the app each callback, and call `dispatch_with_registries`. Use the same logging policy as C. Do not call retaining `serve_app` for B. -- [x] **7.4 Implement standard C with `serve_app_with_request_extensions`.** Start with a finite request limit of 10 and a bounded idle wait. Record SDK attempted-request summaries separately from client success. Compare A/B/C with identical payloads, dispatch, and instrumentation. -- [x] **7.5 Implement custom A/B/C around a shared raw dispatch function.** A owns one native request; B builds each callback; C captures an ordinary `Option` and initializes it lazily. Mutate a synthetic native header, record native metadata, convert into core, route, recover response extensions, and apply a fixture finalizer. A health request before C's first application request must leave its build count at zero. +- [x] **7.1 Declare explicit binary names.** Use `fixture-fastly-a`, `fixture-fastly-b`, `fixture-fastly-c`, `fixture-fastly-custom-a`, `fixture-fastly-custom-b`, `fixture-fastly-custom-c`, and `fixture-fastly-logger-negative`. Use the workspace's exact Fastly SDK pin and build on `wasm32-wasip1`. +- [x] **7.2 Implement standard A with the single-request entry point.** Use `run_app_with_hooks`, adding fixture observations through preparation and routed-response finalization. Preserve per-invocation construction and canonical adapter-owned delivery. +- [x] **7.3 Implement standard B using `lifecycle::serve_custom`.** Complete baked logging setup once, call `App::build::(FASTLY_PLATFORM)` each callback, and use `request::send_request_with_registries_and_hooks`. Use the same logging policy and deployed logical store aliases as C. Do not call retaining `serve_app` for B. +- [x] **7.4 Implement standard C with `serve_app_with_hooks`.** Start with a finite request limit of 10 and a bounded idle wait. Record SDK attempted-request summaries separately from completed delivery. Compare A/B/C with identical payloads, canonical dispatch, and instrumentation. +- [x] **7.5 Implement custom A/B/C through the canonical send-owning path.** A uses `run_custom` for one native request; B builds each callback; C initializes a `Sandbox` lazily and retains only success. Use request preparation for synthetic native metadata and routed-response finalization for extension observations. A health callback before C's first application request must leave its initialization count at zero. The original manual-conversion design is superseded by PR #275. - [x] **7.5a Verify request correlation independently of sandbox identity.** Capture `Request::get_client_request_id()` on each callback and record `FASTLY_TRACE_ID` only as sandbox metadata. Never use the latter as a unique request ID. When the native ID is unavailable, use the harness-issued unique per-request token as an explicitly labeled fixture fallback; record availability and do not present fallback evidence as native-ID support. Across callbacks in the same observed guest, verify that correlation is derived afresh from each request and does not persist in the global logger or retained app. Keep correlation in request-local extensions or explicit per-event log fields. Exercise the fallback through a supported runtime case or a labeled injected accessor result. -- [x] **7.6 Implement manual progressive response sending in the custom function.** Append every header value. For a core stream, create the native response, commit using `stream_to_client`, pump chunks, and finish the writable body before callback return. Handle errors after commitment locally with explicit logging/cleanup; do not return an error that causes the SDK to attempt another response. Complete instrumented post-send backend work within the callback and prove the next callback starts afterward. -- [x] **7.7 Configure local backends and stores.** Keep config, KV, and secret fixtures isolated and use the same logical aliases as the baked store metadata. Capture a named logging endpoint distinctly from echoed stdout; if the selected runtime cannot expose endpoint-specific evidence, report that assertion unavailable. -- [x] **7.8 Add terminal and continuing cases.** Rendered handler errors should allow later callbacks in the same instance. Required-KV open errors, inbound conversion errors, response collection errors, and error-rendering failure are separate escaping paths. Test controlled reachable failures; label injected/unavailable cases honestly. Add constructor panic, pre-commit failure, post-commit stream failure, and a later fresh-owner initialization case. -- [x] **7.9 Add the logger negative control.** Naively wrapping enabled-logger `run_app` in `Serve` should expose the second-installation failure. Keep this out of B's performance samples. +- [x] **7.6 Verify canonical progressive response delivery.** Preserve every header value and delayed-chunk production through the adapter's owned stream writer. Terminal failure after commitment cleans up without replacing the response. Lifecycle wrappers record escaping errors without an SDK resend. Complete request-owned post-delivery work before the next callback; do not duplicate transport ownership in a fixture. +- [x] **7.7 Configure local backends and stores.** Use the same logical resource aliases as the baked store metadata, following `scripts/smoke_test_config_key_override.sh`. Keep config, KV, and secret fixtures isolated. Capture a named logging endpoint distinctly from echoed stdout; if the selected runtime cannot expose endpoint-specific evidence, report that assertion unavailable. +- [x] **7.8 Add terminal and continuing cases.** Separate rendered handler errors, admission refusals/aborts, bounded precommit fallbacks, and escaping delivery failures according to the outbound design. Test controlled reachable failures; label injected/unavailable cases honestly. Add configuration failure without caching, constructor panic, post-commit stream failure, and a fresh-owner initialization case. Historical collection-error behavior is superseded. +- [x] **7.9 Add the logger negative control.** Naively wrapping enabled-logger `run_app` in `Serve` should expose the second-installation failure. Keep this out of B's performance samples. The earlier degraded-runtime-selector scenario is superseded by deployment-time logical bindings and baked logging. - [x] **7.10 Build and run the fixtures.** From the fixture workspace run `cargo build --locked --release -p fixture-fastly --bins --target wasm32-wasip1`. Launch the selected executable with `viceroy serve --addr 127.0.0.1: --config `. Account for readiness callbacks or restart before measuring. Run `./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smoke`. -- [x] **7.11 Assert actual reuse before accepting comparisons.** Stable guest marker plus increasing ordinal is required. A builds once per fresh invocation; B builds each reused callback; C builds once per retained owner. Check cookies, buffering versus progressive streaming, request isolation, limit 1/10, idle exit, and restart. Compare standard A/B/C and custom A/B/C separately. +- [x] **7.11 Assert actual reuse before accepting comparisons.** Stable guest marker plus increasing ordinal is required. A builds once per fresh invocation; B builds each reused callback; C retains the first successful app. Check cookies, canonical progressive streaming, request isolation, limit 1/10, idle exit, and restart. Historical buffered comparisons must be rerun against matched lazy delivery. - [x] **7.12 Run existing Fastly tests on their proper runners.** Run default host `cargo test -p edgezero-adapter-fastly --lib`. With the selected Viceroy on PATH, run `CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --test contract`, and repeat with `--lib`. Native feature-enabled linking is not a required test route. Checkpoint: `test(fastly): verify reusable sandbox lifecycle over HTTP`. ## Task 8: Exercise Cloudflare and Spin retained ownership **Files:** fixture Cloudflare and Spin package files listed in the file map; provider sections of the harness. -- [x] **8.1 Add the Cloudflare fetch fixture.** Use a `cdylib`/`rlib` and `#[event(fetch)]`. A launch-time fixture mode selects unchanged `run_app` or a concrete `static APP: OnceLock` with `dispatch_app` and full metadata. Add a separate concrete `OTHER_APP`; never place an unkeyed static inside a generic function. Do not retain `Env`, `Context`, bodies, or binding handles. +- [x] **8.1 Add the Cloudflare fetch fixture.** Use a `cdylib`/`rlib` and `#[event(fetch)]`. A launch-time fixture mode selects `run_app` or a concrete `OnceLock` populated only after `App::build::(CLOUDFLARE_PLATFORM)` succeeds, with `dispatch_app::(&app, req, env, ctx)`. Add a separate concrete `OTHER_APP`; never place an unkeyed static inside a generic function. Do not retain initialization errors, `Env`, `Context`, bodies, bindings, admission, or egress resources. - [x] **8.2 Configure local Worker builds and bindings.** Use `main = "build/worker/shim.mjs"`, record a fixed compatibility date, and build with `worker-build --release . -- --locked` from the package. Seed only local KV using the same persistence directory supplied to Wrangler. No remote commands or account credentials are needed. -- [x] **8.3 Add the Spin HTTP fixture.** Use SDK 6's `#[http_service]`, concrete app statics, and explicit store metadata. Configure component permissions, fixture config/secret variables, and a local KV store in `runtime-config.toml`. Keep incoming/outgoing bodies and pending futures request-owned. -- [x] **8.4 Build and boot Spin.** Run `cargo build --locked --release -p fixture-spin --target wasm32-wasip2` from the fixture workspace. The original manifest component source is `../../target/wasm32-wasip2/release/fixture_spin.wasm`; resolve it to an absolute path in copied run manifests. Use the selected `spin up --listen
--runtime-config-file --from `. Verify the emitted interface by successful SDK6/P3 host boot; record independent component inspection as unavailable if no inspection tool is installed. The target name alone is insufficient. +- [x] **8.3 Add the Spin HTTP fixture.** Use the pinned SDK 7's `#[http_service]`, successful-only concrete app statics built with the selected platform, and `dispatch_app::(&app, req) -> anyhow::Result`. This is a raw response, not a collected SDK response. Configure component permissions, fixture variables, and a local KV store in `runtime-config.toml`. Keep bodies, admission/egress, and pending futures request-owned. +- [x] **8.4 Build and boot Spin.** Run `cargo build --locked --release -p fixture-spin --target wasm32-wasip2` from the fixture workspace. Resolve the component source to an absolute path in copied run manifests. Use the selected `spin up --listen
--runtime-config-file --from `. Reverify the current SDK7/P3 interface and host together; historical host boot does not establish current compatibility. Record independent inspection as unavailable if no inspection tool is installed. The target name alone is insufficient. - [x] **8.5 Detect Spin controls from the selected runtime.** The investigated Spin 4.0 help does not expose newer reuse/concurrency controls. Exercise controls only when advertised, record their values, and otherwise observe default behavior while marking those controls unavailable. Never blindly pass flags copied from newer documentation. - [x] **8.6 Run sequential ownership and binding assertions on both providers.** Compare per-request versus retained builds, verify different concrete apps return different routes, and check named/default metadata, cookies, bodies, and rendered errors. Restart to prove cold initialization remains correct. - [ ] **8.7 Force overlapping requests on the same guest.** Hold two distinct requests at the backend barrier, verify both arrivals, release them, and assert stable guest identity, maximum in-flight count at least two, and unchanged per-request fields after awaiting. Different instances or serialized delivery are incomplete evidence, not a pass; retry only within a bounded deadline. Retained Spin and both Cloudflare modes passed; the latest Spin per-request control selected separate guests in all three attempts, so this full matrix criterion remains unverified. @@ -365,7 +301,7 @@ pub async fn dispatch_app( - [x] **10.1 Add measurement validation tests.** Reject missing metrics represented as zero, negative phase deltas, mismatched tool/build identities, and SDK wall time mislabeled as CPU. Run the Rust harness tests before and after implementing the report functions. - [x] **10.2 Preflight guest CPU and memory capabilities.** Before enabling `with_max_memory`, require a successful guest heap snapshot. If unsupported, omit that optional limit in the comparison and report the metric unsupported. Separately test conservative SDK termination only if the selected host can produce the unsupported-snapshot failure naturally or exposes a documented hostcall fault-injection facility. Record that mechanism and label injected evidence. Otherwise mark this runtime case unsupported/unverified; do not patch the SDK or substitute a decision-model test as provider evidence. Record precision and source for every memory/CPU observation. - [x] **10.2a Exercise all four independent SDK limits.** Test `with_max_requests` with 1 and 10, `with_timeout` with a controlled idle gap, `with_max_lifetime` by crossing a positive elapsed deadline during controlled initialization before the next callback, and `with_max_memory` after successful heap preflight with a threshold below the measured fixture baseline. Check summary/next-instance behavior without assuming exact eviction timing or guaranteed reuse. Keep limit-boundary tests separate from performance runs so one limit cannot mask another. -- [x] **10.3 Instrument phases.** Capture before/after initialization and request cleanup memory; build/configure counts; initialization and handler wall time; supported SDK CPU phase deltas; request commitment, guest completion, and client completion. Returned-response sending may occur outside callback timing. Keep SDK attempted counts separate from completions. +- [x] **10.3 Instrument phases.** Capture before/after initialization and request cleanup memory; construction/configure counts; initialization and handler wall time; supported SDK CPU phase deltas; commitment, guest completion, and peer completion. Current Fastly callbacks include adapter-owned terminal delivery, not a returned-response sender outside the callback. Keep SDK attempted counts separate from completions. - [x] **10.4 Run repeated matched A/B/C workloads.** Default to three repetitions of 100 sequential requests per variant, finite limit 10, randomized variant order, release builds, and identical instrumentation/backend/payloads. Record configurable sample counts and seeds. Include cheap and deliberately expensive construction, delayed/large streams, failures, idle gaps, and a longer bounded run with a larger finite limit for memory growth. - [x] **10.5 Exercise finite proxy-origin pressure.** Compare one repeated origin with many distinct loopback origins owned by the harness. Bound count and deadlines, and record waits/failures/completions. Local behavior cannot establish the deployed service-wide dynamic-backend capacity limit, which may block registrations pending capacity. - [x] **10.6 Exercise malformed input and interrupted bodies.** Distinguish host rejection before dispatch from observed adapter conversion errors; label fault injection explicitly. Do not infer a demonstrated exploit from a hypothetical request. @@ -389,7 +325,7 @@ pub async fn dispatch_app( **Files:** no planned new implementation files; fix only findings in the files above. -- [x] **12.1 Review the final diff against the spec.** Confirm unchanged default entry points and generation, core traits and macro grammar, request/response conversion semantics (with the documented Cloudflare duplicate-header correction), header duplication, existing stream caps, and error policy. Production changes include adapter entry points, the public Fastly lifecycle module and registry-aware request conversion, Cloudflare response headers, selected-adapter CLI validation and Spin CLI diagnostic redaction; the updated spec records these scope exceptions. +- [x] **12.1 Review the final diff against current authority.** Preserve lifecycle defaults, macro grammar, and header duplication while using fallible platform-aware assembly, canonical admission, and lazy owned egress. PR #275 supersedes the original unchanged-conversion/stream-cap claims. Production surfaces include the public Fastly lifecycle and borrowing hooks, `Hooks`-typed retained dispatch, and request-local resources; no compatibility alias restores removed paths. - [x] **12.2 Run all repository gates.** Expected: each command exits successfully; record unavailable targets/tools separately rather than claiming success. ```sh @@ -428,7 +364,12 @@ The planning review compared two focused proposals: a private Fastly state helpe A subsequent independent alignment review found two verification omissions from spec §6.3. Tasks 1.6a–1.6b now schedule the persistent-state audit and supported cleanup checks; Tasks 7.5a and 11.2 explicitly cover request correlation and its fallback. These are verification/documentation additions, not evidence of a production defect or a change to lifecycle ownership. -## Execution checkpoint +## Historical execution checkpoint + +The results in this section predate reconciliation with PR #275. They record the +original lifecycle implementation, not current API compatibility or a rerun of +the authoritative outbound contracts. Reverify the current fixtures and gates +before making a new passing claim. Implementation is on `feat/reusable-app-lifecycle` in the original checkout. The implementation is committed on this branch. Existing default entry points, generated templates, @@ -460,10 +401,10 @@ Remaining evidence is environmental or outside this implementation: binding recovery are separate, labeled checks. - No reachable public input triggers an error in the current error renderer. A trap is not evidence of a returned rendering error. -- No component-inspection executable is installed; successful Spin SDK6 component - boot supplies runtime compatibility evidence. -- Standard returned-response helpers expose conversion observations, not a - post-send callback; unavailable CPU/cleanup phases remain unknown. +- No component-inspection executable was installed; the historical Spin component + boot supplies evidence only for that tested SDK/host combination. +- Historical conversion-only phase observations do not measure the current + adapter-owned delivery path. Unavailable CPU/cleanup phases remain unknown. - Deployed reuse frequency, resource accounting, capacity, and application workload benefit require separately authorized deployment and application-owned adoption. - The 300-probe run with limit 100 still observed at most six requests per guest. @@ -490,3 +431,21 @@ now gives concrete mitigations for stale snapshots, cache growth, concurrent mutation, logging ownership, failures, and partial streams. These protect and explain existing behavior; application refresh policies and provider lifecycle limits remain outside the framework's ownership. + +## PR #275 Reconciliation Evidence + +The main merge retains opt-in successful application reuse but hard-cuts historical assembly +and conversion paths to the authoritative outbound contract. Prebuilt Cloudflare/Spin dispatch +and Fastly retained/custom serving use canonical admission and adapter-owned lazy egress. +Every request resolves fresh registries and lifecycle resources. Custom Fastly callbacks return +terminal `Result<(), E>` without allowing the SDK to send a replacement response. + +Verified locally with the current pins: 733 core tests and the full workspace suite; native +and all provider-WASM lint; provider runtime contracts; 5 fixture-core and 26 harness tests; +Fastly fixture finalization and Viceroy 0.21.0 HTTP smoke; demo host tests/lint/WASM builds; +fresh generated-project tests/provider builds; docs format/lint/build and contract guards. +The truncated-upstream proof gates its abort on client observation of the partial body. +The immediate zero-byte source-failure proof requires one correlated terminal completion, +no fallback, and terminal guest eviction; only EOF/reset before a status is accepted, never +a timeout or malformed response. Deployed reuse, teardown, and resource guarantees remain +outside this local evidence. diff --git a/docs/superpowers/plans/2026-09-16-outbound-batch-termination.md b/docs/superpowers/plans/2026-09-16-outbound-batch-termination.md new file mode 100644 index 000000000..f361ee3a4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-outbound-batch-termination.md @@ -0,0 +1,236 @@ +# Outbound Batch Termination Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make normal completion, method-cutoff termination, and malformed adapter-driver termination unambiguous in the public outbound batch API. + +**Architecture:** Replace the stream-like `Option` terminal signal with an explicit `OutboundBatchNext::{Item, Finished}` result and add `OutboundBatchTermination::{Completed, Cutoff}` to ordered results. Adapter streams emit public driver events for slots, cutoff, or failure; core validates index range, uniqueness, and premature EOF and returns `OutboundBatchFailure` with the precise `EdgeError::Internal` and previously collected slots instead of silently converting invariant violations into unresolved slots. + +**Tech Stack:** Rust 2024, `futures`, `async-stream`, EdgeZero `EdgeError`, provider adapter contract tests, VitePress documentation checks. + +--- + +### Task 1: Lock the corrected contract in core tests + +**Files:** +- Modify: `crates/edgezero-core/src/outbound.rs` + +- [x] **Step 1: Replace the premature-EOF expectation with failing invariant tests** + +Add focused tests asserting: + +- all slots resolve as `OutboundBatchTermination::Completed`; +- an explicit driver cutoff yields `OutboundBatchTermination::Cutoff` and only then permits `None` slots; +- premature driver EOF returns `EdgeError::Internal`; +- duplicate and out-of-range slot indices return `EdgeError::Internal`; +- an empty batch completes normally; +- dropping a pending `next()` future remains cancellation-safe. + +- [x] **Step 2: Run focused tests and verify RED** + +Run: `cargo test -p edgezero-core outbound::tests::batch_ -- --nocapture` + +Expected: compilation/test failure because the typed termination API and driver events do not exist. + +- [x] **Step 3: Implement the minimal core state machine** + +Add public non-exhaustive enums: + +```rust +pub enum OutboundBatchTermination { + Completed, + Cutoff, +} + +pub enum OutboundBatchNext { + Item(OutboundBatchItem), + Finished(OutboundBatchTermination), +} +``` + +Change `OutboundBatch::next` to return `Result`, `collect` to return `Result`, and `HttpClient::send_all_until` to return `Result`. Add `termination` to `OutboundBatchResults`. + +Use the public adapter driver event with `Item`, `Cutoff`, and `Failed` variants. Mark completion only at driver EOF after every index resolves. Return a fixed category-safe internal error for core-detected premature EOF, duplicate indices, out-of-range indices, or impossible terminal transitions; preserve adapter-supplied failures exactly. Ordered collection retains every slot observed before either class of failure. The event and constructors were promoted from doc-hidden adapter support by the round-6 hardening pass. + +- [x] **Step 4: Run core tests and verify GREEN** + +Run: `cargo test -p edgezero-core` + +Expected: PASS. + +### Task 2: Migrate all adapter drivers + +**Files:** +- Modify: `crates/edgezero-adapter-axum/src/outbound.rs` +- Modify: `crates/edgezero-adapter-axum/tests/contract.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` +- Modify: `crates/edgezero-adapter-spin/src/outbound.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` +- Modify: `crates/edgezero-adapter-spin/tests/sdk_resources.rs` + +- [x] **Step 1: Update adapter contract tests first** + +Assert that ordinary batches finish as `Completed`, deadline/cutoff cases finish as `Cutoff`, and batch iteration handles `Item` then `Finished` through `Result` without treating errors as cutoff. + +- [x] **Step 2: Run adapter contract tests and verify RED** + +Run the four native contract suites through `scripts/run_test_nonzero.sh` using their existing batch sentinels. + +Expected: FAIL until adapters emit explicit driver events. + +- [x] **Step 3: Emit explicit item and cutoff events** + +For Axum, Cloudflare, and Spin, map every completed exchange to an item event and every `finish_batch_item` cutoff decision to a cutoff event. For Fastly, also map initial expiry, dispatch-loop expiry, and selected-body cutoff to a cutoff event; map provider-selection invariant failure to a failure event carrying its precise error. Driver EOF is reserved for full completion only. + +- [x] **Step 4: Run adapter tests and WASM checks** + +Run: + +```sh +cargo test -p edgezero-adapter-axum --features test-utils +cargo test -p edgezero-adapter-cloudflare --features test-utils +cargo test -p edgezero-adapter-fastly --features test-utils +cargo test -p edgezero-adapter-spin --features test-utils +cargo check -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare +cargo check -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +``` + +Expected: PASS. + +### Task 3: Migrate consumers and hard-cut guards + +**Files:** +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: adapter request/service tests that call `send_all_until` +- Modify: `scripts/check_outbound_legacy_api.sh` + +- [x] **Step 1: Add failing demo and generator assertions** + +Require checked-in and generated handlers to choose explicitly whether to inspect `OutboundBatchFailure.slots` or discard them while propagating `failure.error`, inspect the typed termination reason, and avoid the removed direct-result API shape. + +- [x] **Step 2: Run focused consumer tests and verify RED** + +Run: + +```sh +cargo test -p edgezero-cli generator -- --nocapture +cargo test --manifest-path examples/app-demo/Cargo.toml --locked +``` + +Expected: FAIL until consumers use the new result and termination types. + +- [x] **Step 3: Migrate all callers and test clients** + +Update demo/template handlers, mocks, and adapter tests. Remove the old `OutboundBatch::from_stream` constructor and reject stale `while let Some(item) = batch.next().await` and direct `.send_all_until(...).await.slots` shapes in the legacy checker. + +- [x] **Step 4: Run focused consumer tests and verify GREEN** + +Run the generator and app-demo commands from Step 2 plus `bash scripts/check_outbound_legacy_api.sh`. + +Expected: PASS. + +### Task 4: Update the normative contract and public guides + +**Files:** +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` +- Modify: `docs/guide/proxying.md` +- Modify: `docs/guide/capabilities.md` +- Modify: `scripts/check_outbound_docs_contract.mjs` + +- [x] **Step 1: Update specification API and invariants** + +Document explicit `Completed`/`Cutoff` termination, `OutboundBatchFailure`, the successful-result rule that `None` is legal only for `Cutoff`, retained partial slots on failure, and explicit adapter cutoff/failure signaling. + +- [x] **Step 2: Update guides and examples** + +Show `OutboundBatchNext::Item`/`Finished` iteration and explicit handling of `send_all_until(...).await` as `Result`. State that malformed drivers never degrade into timeout-like unresolved slots and that ordered collection preserves slots observed before failure. + +- [x] **Step 3: Extend documentation regression checks** + +Reject stale claims that `next()` returns `Option`, that `send_all_until` directly returns results, or that every missing slot means cutoff without consulting termination. + +- [x] **Step 4: Run documentation checks** + +Run: + +```sh +node scripts/check_outbound_docs_contract.mjs +cd docs && npm run format && npm run lint && npm run build +``` + +Expected: PASS. + +### Task 5: Whole-feature verification and delivery + +**Files:** +- Review: all files changed by Tasks 1-4 + +- [x] **Step 1: Run formatting and strict native gates** + +Run: + +```sh +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +``` + +Expected: PASS with zero warnings. + +- [x] **Step 2: Run the integrated provider and generated-project suite** + +Run: `scripts/run_tests.sh` + +Expected: PASS, including Fastly Viceroy, provider WASM builds, generated workspace, app-demo, and documentation contracts. + +- [x] **Step 3: Review the final diff** + +Run: `git diff --check`, search for removed API shapes, and confirm only intended files changed. + +- [x] **Step 4: Commit and push PR 275** + +Commit message: `fix(outbound): type batch termination` + +Update the PR description and wait for hosted checks to complete. + +### Review Follow-Up: Driver Finalization + +- [x] Add failing tests for trailing failure, duplicate/out-of-range indices, pending finalization, + zero-slot drivers, and cutoff after every slot resolves. Preserve collected slots on failure. +- [x] Remove completion inference from item count. Accept driver EOF only after every slot + resolves; reject malformed intervening events and retain poisoned-repoll behavior. +- [x] Run `cargo test -p edgezero-core outbound::tests::batch_` and all adapter batch contracts. +- [x] Align the specification, public driver docs, and implementation evidence before delivery. + +**Evidence (2026-10-06):** The trailing-event, zero-slot, and pending-finalization regressions +failed against item-count completion before the fix. Core now validates clean EOF, retains +completed slots on driver failure, and releases a pending finalization driver on cancellation. +The full workspace suite passes, including 720 core unit tests. Explicit nonzero native +contract runs pass for Axum (13), Cloudflare (3), Fastly (1), and Spin (2); strict WASM clippy +passes for all three provider targets. Specs/guides and the documentation checker reflect the +EOF requirement. This follow-up does not claim new deployed-host evidence. + +### Review Follow-Up: Shared Future Driver (R5) + +- [x] Extract `OutboundBatch::from_futures` for Axum, Cloudflare, and Spin, preserving original + indices and preflight/terminal timestamps. Keep Fastly's provider-handle driver separate. +- [x] Drain ready exchange futures before the first yield, retain clean-EOF validation, and + introduce no core clock sampling or timer. Keep transport cancellation inside each adapter. +- [x] Add deterministic timestamp-retention, cutoff-attribution, cancellation, empty/premature + driver, and cutoff-release tests. Release pending futures before delivering `Finished(Cutoff)`. +- [x] Complete merge-wide native/WASM, reusable-fixture, demo/generator, and documentation gates. + +Verified during the main merge: 733 core unit tests and the full workspace suite pass; strict +native and provider-WASM Clippy pass; Cloudflare, Fastly, and Spin runtime contract suites pass. +The reusable fixture suite (5 core and 26 harness tests), Fastly owned-finalization test and +Viceroy 0.21.0 HTTP smoke pass. Demo host tests/lint and all three provider WASM builds pass, +as does a fresh generated workspace. Documentation format/lint/build, dependency contracts, +and the expanded legacy guard pass. This is local evidence, not deployed-provider promotion. diff --git a/docs/superpowers/plans/2026-09-17-custom-serving-lifecycle.md b/docs/superpowers/plans/2026-09-17-custom-serving-lifecycle.md index e78616e78..d5030ff1b 100644 --- a/docs/superpowers/plans/2026-09-17-custom-serving-lifecycle.md +++ b/docs/superpowers/plans/2026-09-17-custom-serving-lifecycle.md @@ -1,5 +1,12 @@ # Custom serving lifecycle +> Historical implementation record, reconciled with PR #275's outbound hard-cut. +> Its former SDK response-sending and manual conversion choices are superseded. +> The [outbound HTTP design](../specs/2026-05-21-outbound-http-design.md) remains +> authoritative for admission, owned response egress, and capabilities; no +> compatibility aliases restore removed APIs. Verification below predates this +> reconciliation and is not evidence of a current rerun. + ## Objective Provide reusable lifecycle mechanics for custom Fastly dispatch without taking @@ -11,10 +18,15 @@ Keep the default entry points unchanged. 1. Add feature-independent `lifecycle::Sandbox` with successful-only lazy initialization, attempted-callback and build counters, and a separate setup guard. Preserve unconstrained initialization errors, including fallback routers. -2. Add Fastly-gated `serve_custom` and `run_custom`. Delegate limits and result - sending to the SDK exactly once. Single mode does not enter the serving loop. +2. Add Fastly-gated `serve_custom` and `run_custom`. Delegate receive limits and + terminal result accounting to the SDK, not response sending. Callbacks return + `Result<(), E>` after owned delivery; escaping errors stop serving without an + SDK-generated response or resend. Single mode does not enter the serving loop. 3. Replace the custom fixture's local state slot and retry bookkeeping with this - API. Keep explicit streaming, duplicate headers, finalization, and post-send work. + API. Use `App::build::(FASTLY_PLATFORM) -> Result` and retain + only successful construction. Route preparation/finalization through + `serve_app_with_hooks` or canonical send-owning request hooks, preserving lazy + streaming, duplicate headers, fresh registries/admission/egress, and post-send work. 4. Document ownership and migration, including request-scoped resources and pre-commit versus post-commit errors. 5. Run host state tests, workspace checks, WASM fixture checks, and the local @@ -28,6 +40,15 @@ with host concurrency and invocation lifetime. Axum constructs its app once for the running server. Fastly's bounded receive loop is not portable to these hosts; this extension does not introduce shared mutable singleton state into them. +Build with `App::build::(PLATFORM)`; `Hooks::configure` is fallible after target +metadata is installed. Cloudflare dispatch takes `(&App, Request, Env, Context)` +and returns `Result`. Spin dispatch takes `(&App, SpinRequest)` +and returns `anyhow::Result`, the raw WASI response backed by owned +egress. Neither dispatcher builds an app or installs logging. Never cache an +initialization failure or retain request registries, admission resources, or +delivery coordinators. Pair the retained app with its construction `Hooks` type +and selected platform. + ## Acceptance Health callbacks count but skip initialization. A failed build is returned and @@ -37,7 +58,7 @@ observe recovery and reuse in the same guest, with progressive streaming, duplicate cookies, finalization metadata and post-commit errors preserved. Local evidence does not establish deployed eviction or long-lived memory bounds. -## Verification +## Historical verification Implemented and independently reviewed against the SDK sending contract and a custom consumer's ownership requirements. Workspace tests: 1,435 passed, one diff --git a/docs/superpowers/plans/2026-09-17-lifecycle-contract-completion.md b/docs/superpowers/plans/2026-09-17-lifecycle-contract-completion.md index 62bc3b373..8d299342f 100644 --- a/docs/superpowers/plans/2026-09-17-lifecycle-contract-completion.md +++ b/docs/superpowers/plans/2026-09-17-lifecycle-contract-completion.md @@ -1,13 +1,26 @@ # Lifecycle contract completion +> Historical implementation record, reconciled with PR #275's outbound hard-cut. +> Raw conversion, response-returning dispatch, and collected response paths are +> superseded by canonical admission and adapter-owned lazy egress. The +> [outbound HTTP design](../specs/2026-05-21-outbound-http-design.md) is authoritative; +> no compatibility aliases restore removed APIs. Recorded checkmarks and test +> counts below predate reconciliation and require current verification. + **Goal:** Make the supported custom lifecycle executable and keep target-specific configuration validation scoped to its destination without disclosing secret references. -**Architecture:** Keep the existing SDK serving loop, raw request conversion, and -retained router interfaces. Applications own initialization failure policy and -response finalization. Extend the existing Rust fixtures instead of adding another -serving abstraction. Standalone config validation remains the portability check; -push and diff validate only their selected adapter. +**Architecture:** Keep provider scheduling separate from successful-only app +retention. Assemble with `App::build::(PLATFORM) -> Result` and +fallible `Hooks::configure`. Fastly uses `Sandbox`, `serve_app`, and +`serve_app_with_hooks` with borrowing preparation/finalization and owned sending; +no raw conversion or response-returning helper bypasses admission or egress. +Cloudflare dispatch takes `(&App, Request, Env, Context)` and returns a Worker +response; Spin takes `(&App, SpinRequest)` and returns raw `SpinResponse`. Both +select `A::stores()` and resolve fresh registries/admission/egress per invocation, +without building an app or installing logging. Applications own retry policy and +never cache initialization errors. Standalone config validation remains the +portability check; push and diff validate only their selected adapter. ## Lifecycle acceptance @@ -30,7 +43,7 @@ push and diff validate only their selected adapter. adapter for push/diff, retaining global schema and handler checks. - [x] Keep field paths and naming rules in errors; omit raw and normalized values. -## Verification +## Historical verification - [x] Run scoped tests after code changes, then workspace tests, format, Clippy, all-adapter feature checks, and Spin WASM compilation. diff --git a/docs/superpowers/plans/2026-09-17-outbound-batch-driver-failure.md b/docs/superpowers/plans/2026-09-17-outbound-batch-driver-failure.md new file mode 100644 index 000000000..f5997bbfe --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-outbound-batch-driver-failure.md @@ -0,0 +1,144 @@ +# Outbound Batch Driver Failure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve precise adapter-driver failures and all terminal batch slots observed before the failure. + +**Architecture:** Extend the private adapter-to-core driver protocol with an explicit failure event. Incremental callers receive its `EdgeError` directly; ordered collection wraps the error with the index-aligned slots collected so far in a new public `OutboundBatchFailure`. Fastly emits this event when pending-handle selection or metadata reassociation violates its invariant, instead of silently ending its stream or misreporting a deadline cutoff. + +**Tech Stack:** Rust 2024, `futures` streams, Fastly Compute SDK, Cargo native/WASM tests, VitePress documentation checks. + +--- + +### Task 1: Preserve Partial Results on Driver Failure + +**Files:** +- Modify: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-core/src/lib.rs` + +- [x] **Step 1: Write failing core tests** + +Add tests proving that: + +```rust +let failure = block_on(batch.collect()).expect_err("driver failure"); +assert!(matches!(failure.error, EdgeError::Internal { .. })); +assert!(failure.slots[completed_index].is_some()); +assert!(failure.slots[unresolved_index].is_none()); +``` + +Cover both an explicit driver failure carrying a distinctive diagnostic and premature EOF after one item. + +- [x] **Step 2: Run the focused tests and verify RED** + +Run: `cargo test -p edgezero-core outbound::tests::batch_collection -- --nocapture` + +Expected: compilation fails because `OutboundBatchDriverEvent::Failed` and `OutboundBatchFailure` do not exist. + +- [x] **Step 3: Implement the minimal core contract** + +Add: + +```rust +#[derive(Debug)] +#[non_exhaustive] +pub struct OutboundBatchFailure { + pub error: EdgeError, + pub slots: Vec>, +} +``` + +Add hidden `OutboundBatchDriverEvent::Failed(EdgeError)`. Make `next()` poison the batch and return that exact error. Make `collect()` convert any `next()` error into `OutboundBatchFailure` without discarding its local slot vector. Change `HttpClient::send_all_until` to return `Result`, export the type, and implement `From for EdgeError` so existing handlers can deliberately discard partials with `?`. + +- [x] **Step 4: Run core tests and verify GREEN** + +Run: `cargo test -p edgezero-core` + +Expected: all core tests pass. + +### Task 2: Propagate Fastly Selection Failures + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/outbound.rs` + +- [x] **Step 1: Write failing Fastly tests** + +Add target-neutral tests for all three reassociation failures: + +- invalid selected handle index; +- unknown or duplicate pending handle; +- omitted pending handle. + +Assert their exact internal diagnostic and add a driver-level regression proving a previously emitted item remains available in `OutboundBatchFailure` when the Fastly failure event follows it. + +- [x] **Step 2: Run focused tests and verify RED** + +Run: `cargo test -p edgezero-adapter-fastly --features test-utils selection` + +Expected: the regression fails because the harvest path ends with EOF instead of emitting the precise error. + +- [x] **Step 3: Emit the explicit failure event** + +Replace the `let Ok(..) else { return; }` branch with a `match` that yields `OutboundBatchDriverEvent::Failed(error)` and returns. Do not emit `Cutoff`; provider selection failed while the cutoff remained live. + +- [x] **Step 4: Run Fastly tests and verify GREEN** + +Run: `cargo test -p edgezero-adapter-fastly --features test-utils` + +Expected: all Fastly tests pass, including exact diagnostics and partial-result retention. + +### Task 3: Align Consumers and Documentation + +**Files:** +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` +- Modify: `docs/superpowers/plans/2026-09-16-outbound-batch-termination.md` +- Modify: `docs/guide/proxying.md` +- Modify: `docs/guide/capabilities.md` +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `scripts/check_outbound_docs_contract.mjs` +- Modify: `scripts/check_outbound_legacy_api.sh` + +- [x] **Step 1: Update executable consumers** + +Keep demo and generated handlers source-compatible through `From for EdgeError`, but make their fail-closed choice explicit by propagating `failure.error`. Demonstrate partial-slot retention in the public guide and update generator assertions to pin the current contract. + +- [x] **Step 2: Update the normative and public contracts** + +Document that explicit adapter failures and malformed EOF are not cutoff, incremental callers receive the precise `EdgeError`, and ordered collection returns `OutboundBatchFailure` containing the same error plus all slots collected before failure. + +- [x] **Step 3: Harden documentation and legacy checkers** + +Require `OutboundBatchFailure` and the new collector signature. Reject stale `Result` declarations and claims that batch-level errors erase prior slot results. + +- [x] **Step 4: Run documentation and generator checks** + +Run: `node scripts/check_outbound_docs_contract.mjs` + +Run: `bash scripts/check_outbound_legacy_api.sh` + +Run: `cargo test -p edgezero-cli generator` + +Expected: all checks pass. + +### Task 4: Full Verification and Delivery + +**Files:** +- Modify: `docs/superpowers/plans/2026-09-17-outbound-batch-driver-failure.md` + +- [x] **Step 1: Run repository verification** + +Run: `scripts/run_tests.sh` + +Run any target-specific strict WASM Clippy commands required by CI if the integrated script does not cover them. + +Expected: formatting, strict Clippy, native tests, all WASM targets, documentation, and contract checks pass. + +- [x] **Step 2: Review the final diff** + +Confirm no selection failure is represented as `Cutoff`, no old collector signature remains, demo/template output matches, and no unrelated lower-priority review item was included. + +- [x] **Step 3: Mark this plan complete, commit, and push** + +Commit the focused implementation and synchronized documentation, push `docs/outbound-http-spec`, and confirm PR #275 checks are green. diff --git a/docs/superpowers/plans/2026-09-17-outbound-http-round6-hardening.md b/docs/superpowers/plans/2026-09-17-outbound-http-round6-hardening.md new file mode 100644 index 000000000..e69c7ed9d --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-outbound-http-round6-hardening.md @@ -0,0 +1,280 @@ +# Outbound HTTP Round 6 Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the actionable round-6 outbound review findings without changing valid monotonic-cutoff semantics. + +**Architecture:** Keep provider orchestration in each adapter, but move the byte-for-byte duplicated terminal-slot classifier into core as documented adapter support. Preserve the concurrent adapters' fail-closed cutoff rule because their shared monotonic completion samples are taken synchronously in poll order; fix Fastly response framing by carrying core's body-transmission disposition through the irreversible commit boundary. Promote the batch-driver construction protocol that generated applications already consume, and align all normative/public documentation with pre-normalization header accounting. + +**Tech Stack:** Rust 2024, `futures`/`futures-util`, Fastly Compute ABI, Cargo workspace tests, Node.js documentation contract checker. + +--- + +### Task 1: Centralize and harden batch-driver invariants + +**Files:** +- Modify: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-adapter-axum/src/outbound.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound.rs` +- Modify: `crates/edgezero-adapter-spin/src/outbound.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` + +- [x] **Step 1: Add red core tests for the shared terminal-slot classifier** + +Add tests covering an on-time outcome, an observation at cutoff, an attributed `BatchCutoff` +timeout, and a terminal sample before the batch start. At this point in the plan, the attributed +timeout characterization records the inherited `None` behavior; Task 5 supersedes that expectation +after confirming timeout provenance is not a batch-abandonment signal. The backward sample pins +zero elapsed plus the existing internal invariant outcome. + +- [x] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +cargo test --offline --locked -p edgezero-core --lib finish_batch_item_ +``` + +Expected: compilation fails because the shared core helper does not exist. + +- [x] **Step 3: Move the duplicated helper into core and update all adapters** + +Implement one documented `edgezero_core::outbound::finish_batch_item` adapter-support function +using the then-existing behavior. Delete all four adapter-local copies and their now-unused imports. +Do not add completion-group buffering or alter cutoff ordering. Task 5 later narrows cutoff +termination to an actual absolute-cutoff clock observation. + +- [x] **Step 4: Run affected core and adapter tests and verify GREEN** + +Run: + +```bash +cargo test --offline --locked -p edgezero-core --lib finish_batch_item_ +cargo test --offline --locked -p edgezero-adapter-axum --lib +cargo test --offline --locked -p edgezero-adapter-fastly --lib +cargo check --offline --locked -p edgezero-adapter-cloudflare --tests --target wasm32-unknown-unknown --features cloudflare +cargo check --offline --locked -p edgezero-adapter-fastly --tests --target wasm32-wasip1 --features fastly +cargo check --offline --locked -p edgezero-adapter-spin --tests --target wasm32-wasip2 --features spin +``` + +Expected: all pass. + +- [x] **Step 5: Strengthen batch invariant tests before changing API documentation** + +Add exact-message assertions for premature EOF, duplicate index, and out-of-range index. Add a direct `OutboundBatch::cutoff` test and a test proving a second `next()` after an invariant failure returns the poisoned re-poll diagnostic. + +- [x] **Step 6: Run the invariant characterization tests** + +Run: + +```bash +cargo test --offline --locked -p edgezero-core --lib batch_collection_ +cargo test --offline --locked -p edgezero-core --lib batch_cutoff_ +cargo test --offline --locked -p edgezero-core --lib batch_poisoned_ +``` + +Expected: the exact diagnostics and poison/cutoff state behavior already pass. These tests pin +existing invariants before the public documentation/export change; they are characterization +coverage, not a behavior change. + +- [x] **Step 7: Add a red public root-export consumer check** + +Update the generated template and app-demo fixture to import +`edgezero_core::OutboundBatchDriverEvent` from the crate root, then run: + +```bash +cargo test --offline --locked --manifest-path examples/app-demo/Cargo.toml -p app-demo-core --no-run +scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test --offline --locked -p edgezero-cli --test generated_project_builds +``` + +Expected: unresolved import because the root export does not exist yet. + +- [x] **Step 8: Promote the real batch-driver construction API** + +Remove `#[doc(hidden)]` from `OutboundBatchDriverEvent`, `OutboundBatch::from_driver`, and `OutboundBatch::cutoff`; document event ordering, EOF, failure, cutoff, and index invariants. Re-export `OutboundBatchDriverEvent` from the core crate root and update generated/demo test clients to use the root export. Keep one API only; add no compatibility alias. + +- [x] **Step 9: Run core, CLI, and demo checks** + +Run: + +```bash +cargo test --offline --locked -p edgezero-core --lib batch_ +cargo test --offline --locked -p edgezero-cli +cargo test --offline --locked --manifest-path examples/app-demo/Cargo.toml -p app-demo-core +scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test --offline --locked -p edgezero-cli --test generated_project_builds +``` + +Expected: all pass and generator assertions match the public API. + +### Task 2: Prevent manual framing for Fastly body-suppressed responses + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/response.rs` + +- [x] **Step 1: Add red HEAD and 304 framing tests** + +Extend the mock committer's prepared value to record `transmits_body`. Assert that HEAD and 304 responses retain valid representation `Content-Length`, transmit no body, and pass `false` through the committer boundary. Add a payload response assertion that passes `true`. + +- [x] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +cargo test --offline --locked -p edgezero-adapter-fastly --lib body_suppressed_response_disables_manual_framing +cargo test --offline --locked -p edgezero-adapter-fastly --lib payload_response_allows_manual_framing +``` + +Expected: compilation/assertion failure because `EgressCommitter::prepare` does not receive the disposition. + +- [x] **Step 3: Thread the body disposition through Fastly preparation** + +Read `PreparedResponseEgress::transmits_body()` before consuming it, pass the boolean through `EgressCommitter::prepare`, and call `set_manual_framing` only when the response transmits a body and contains `Content-Length`. Update the raw-ABI happy-path test call site. Do not change core's HEAD/304 metadata preservation. + +- [x] **Step 4: Run Fastly native and WASM tests** + +Run: + +```bash +cargo test --offline --locked -p edgezero-adapter-fastly --lib +(cd crates/edgezero-adapter-fastly && cargo test --offline --locked --target wasm32-wasip1 --features fastly --lib) +``` + +Expected: all pass. + +### Task 3: Align the contract and documentation + +**Files:** +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` +- Modify: `docs/guide/capabilities.md` +- Modify: `docs/guide/proxying.md` +- Modify: `docs/superpowers/plans/2026-09-06-outbound-http-phase2-body-response-limits.md` +- Modify: `docs/superpowers/plans/2026-09-06-outbound-http-phase4-axum-cloudflare.md` +- Modify: `docs/superpowers/plans/2026-09-06-outbound-http-phase5-spin.md` +- Modify: `docs/superpowers/plans/2026-09-06-outbound-http-phase6-fastly.md` +- Modify: `scripts/check_outbound_docs_contract.mjs` + +- [x] **Step 1: Add red documentation-contract expectations** + +Change the checker to require wording that response-header caps count adapter-visible upstream fields before normalization, including fields later stripped, plus EdgeZero's synthetic proxy field. Require the normative concurrent-driver statement that completion samples are taken synchronously in observation order from one monotonic clock. + +- [x] **Step 2: Run the docs checker and verify RED** + +Run: + +```bash +node scripts/check_outbound_docs_contract.mjs +``` + +Expected: failure because the current guide/spec still say `guest-visible` and omit the monotonic ordering statement. + +- [x] **Step 3: Update normative spec, public guides, and phase-plan terminology** + +Replace misleading `guest-visible header` wording with the exact pre-normalization accounting boundary. State that stripped hop-by-hop fields consume budget and the synthetic `x-edgezero-proxy` field is charged afterward. Document why the concurrent adapters may terminate on their first cutoff observation without discarding an earlier terminal sample, while retaining Fastly's separate BestEffort limitation. Keep proxying's `None` meaning unchanged. + +- [x] **Step 4: Run documentation validation** + +Run: + +```bash +node scripts/check_outbound_docs_contract.mjs +npm --prefix docs run lint +npm --prefix docs run format +``` + +Expected: all pass. + +### Task 4: Verify the complete change and update the PR + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/fastly_response_abi.rs` +- Modify: `docs/superpowers/plans/2026-09-17-outbound-http-round6-hardening.md` + +- [x] **Step 1: Audit the Fastly raw-ABI test boundary** + +Confirm the existing mock suite covers post-commit abandon, write/source/deadline failures, and finish consumption. Add only target-valid status-result coverage if it exercises the real shared mapper. Do not add an ABI indirection layer solely to synthesize provider hostcall failures; record Viceroy fault-injection limits in the final review note. + +Audit result: the portable mock suite covers those lifecycle branches, and the real shared status +mapper now has a `wasm32-wasip1`/Viceroy test for success and host failure. Viceroy does not expose +fault injection for each downstream response hostcall, so provider-hostcall failure coverage stops +at that mapper rather than adding a production ABI indirection solely for tests. + +- [x] **Step 2: Mark implementation/documentation tasks complete in this plan** + +Update completed checkboxes before final validation so the checked tree is the tree that will be +committed. Leave only the final gate/review/commit steps unchecked while commands are running. + +- [x] **Step 3: Run every required repository gate** + +Run: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +bash scripts/run_tests.sh +``` + +Expected: all gates pass. + +- [x] **Step 4: Review the final diff** + +Verify there are no adapter-local `finish_batch_item` definitions, no hidden public driver constructors used by scaffolds, no unconditional Fastly manual framing, and no stale `guest-visible` header-cap claims. + +Independent runtime review found no code defect. Independent contract review found and closed four +documentation/API-hardening gaps: the normative spec now includes the public driver protocol and +shared helper, the historical termination plan no longer calls that protocol doc-hidden, Phase 4/5 +mark their removed `send_all` APIs as superseded, and the public driver event is non-exhaustive. + +- [x] **Step 5: Mark verification complete and validate the final documentation state** + +Mark the gate/review checkboxes complete, then run: + +```bash +git diff --check +node scripts/check_outbound_docs_contract.mjs +npm --prefix docs run format +``` + +Expected: all pass after the final plan-file edits. + +- [x] **Step 6: Commit and push** + +Commit the implementation and documentation together with a focused message, push `docs/outbound-http-spec`, and confirm PR 275 checks start against the pushed SHA. + +### Task 5: Preserve early Fastly phase timeouts as slot results + +**Files:** +- Modify: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound.rs` +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` +- Modify: `scripts/check_outbound_docs_contract.mjs` + +- [x] **Step 1: Add and run the red classifier regression** + +Change the attributed-timeout characterization so a `BatchCutoff` timeout observed before the +absolute cutoff must produce a terminal item. Run the focused test and require one failing test, +not a zero-test filter. + +- [x] **Step 2: Make absolute clock expiry the only cutoff signal** + +Remove provenance inspection from `finish_batch_item`. Keep equality expired and preserve the +existing backward-clock fail-closed behavior. Add a Fastly batch-level regression proving an early +timeout remains terminal while a later sibling is retained. Update the normative spec and +executable docs contract so `BudgetSource::BatchCutoff` cannot be interpreted as batch +abandonment. + +- [x] **Step 3: Run focused and complete verification** + +Run the focused regression, core tests, strict workspace gates, every adapter feature/WASM matrix, +generated-project and app-demo verification, and documentation checks. + +- [x] **Step 4: Commit and push the follow-up** + +Commit the regression and contract correction together, push `docs/outbound-http-spec`, and verify +PR 275 points at the pushed SHA. diff --git a/docs/superpowers/plans/2026-09-19-detached-egress-config-deadlines.md b/docs/superpowers/plans/2026-09-19-detached-egress-config-deadlines.md new file mode 100644 index 000000000..bc9f9dff5 --- /dev/null +++ b/docs/superpowers/plans/2026-09-19-detached-egress-config-deadlines.md @@ -0,0 +1,243 @@ +# Detached Egress and Config Deadline Closure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Cover normalized pre-admission responses with application-owned completion resources and ensure asynchronous bounded config reads return at their absolute deadline without overstating provider cancellation. + +**Architecture:** Core gains one default-empty detached-egress completion factory keyed by bounded failure metadata. Bounded store methods become required hard-cut trait methods; Cloudflare and Spin race asynchronous provider futures against native timers, while Axum and Fastly retain accurately documented synchronous limitations. Application permits remain application-defined, while a generic core late-bound owner centralizes the response-lifecycle synchronization needed to retain any `Send` resource until terminal egress or abandonment. + +**Tech Stack:** Rust 2024, `async-trait`, `futures-util`, Tokio, Cloudflare Workers `Delay`, Spin SDK `sleep`, existing EdgeZero ingress/egress and capability APIs. + +--- + +### Task 1: Add detached normalized-rejection completion ownership + +**Files:** +- Modify: `crates/edgezero-core/src/response_egress.rs` +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Test: `crates/edgezero-core/src/app.rs` + +- [x] **Step 1: Write failing core lifecycle tests** + + Add tests proving a normalized rejection calls the configured factory once with method, + application-clock request start, status, and stable error kind; invokes the returned completion + exactly once at terminal egress; leaves policy/observer untouched; and drops a captured resource + without a fabricated report when the envelope is abandoned before `begin()`. + +- [x] **Step 2: Run the focused tests and verify RED** + + Run: `cargo test -p edgezero-core --lib app::tests::detached_ingress` + + Expected: compile failure because the detached head and setter do not exist. + +- [x] **Step 3: Implement the factory** + + Add `DetachedResponseEgressHead<'_>` with read-only accessors for request method, request start, + status, and `EdgeError::kind()`. Store an `Arc Fn(&DetachedResponseEgressHead<'head>) -> ResponseEgressCompletion + Send + Sync>` in `App`, default it to an empty completion, expose a generic setter, and call it exactly once from + `detached_ingress_error_egress` before consuming the error into its response. + +- [x] **Step 4: Run core tests** + + Run: `cargo test -p edgezero-core --lib app::tests::detached_ingress` + + Expected: all detached lifecycle cases pass. + +### Task 2: Prove detached completion in every adapter + +**Files:** +- Modify: `crates/edgezero-adapter-axum/src/service.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` + +- [x] **Step 1: Strengthen existing normalized-ingress rejection tests** + + Configure a detached completion factory with a drop/completion counter, trigger normalized head + rejection, and prove each adapter settles one completion after delivery while admission, + middleware, handlers, policy, observer, and body polling remain untouched. + +- [x] **Step 2: Run or compile each adapter contract under its supported target** + + Run the Axum `normalized_ingress_error` and admission-policy-error filters with a required + nonzero test count. Cloudflare, Fastly, and Spin ingress contracts are target-only, so compile + those tests under strict target Clippy; local runtime execution remains dependent on the pinned + provider runners used by hosted CI. + +### Task 3: Hard-cut bounded store implementations + +**Files:** +- Modify: `crates/edgezero-core/src/config_store.rs` +- Modify: `crates/edgezero-core/src/secret_store.rs` +- Modify: core and adapter test fixtures implementing either trait + +- [x] **Step 1: Remove both cooperative defaults** + + Make `ConfigStore::get_bounded` and `SecretStore::get_bytes_bounded` required. Keep unbounded + convenience reads separate and explicitly outside extraction guarantees. + +- [x] **Step 2: Run workspace check and record expected compile failures** + + Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin"` + + Expected: every fixture relying on the old default fails with a missing required method. + +- [x] **Step 3: Migrate fixtures explicitly** + + Give deterministic in-memory fixtures finite pre/post checks and exact byte accounting. Do not + restore a blanket compatibility helper that can hide a pending provider future. + +- [x] **Step 4: Run core store and extractor tests** + + Run: `cargo test -p edgezero-core --lib config_store` and + `cargo test -p edgezero-core --lib extractor`. + +### Task 4: Race Cloudflare and Spin asynchronous reads + +**Files:** +- Modify: `crates/edgezero-adapter-cloudflare/src/config_store.rs` +- Modify: `crates/edgezero-adapter-spin/src/config_store.rs` +- Modify: `crates/edgezero-adapter-spin/src/secret_store.rs` +- Test: colocated unit tests in those modules + +- [x] **Step 1: Write never-ready and equality-precedence tests** + + Inject a pending provider future and a controlled timer. Prove a clock-confirmed timer completion + returns the typed deadline error, drops the provider future, retains no partial value, and wins + when both sides are ready at equality. Prove an early timer wake re-arms without discarding the + provider result. Repeat for Spin secret reads because typed config extraction shares the same + deadline. + +- [x] **Step 2: Run focused tests and verify they hang/fail under the old await-only helper** + + Use injected immediately-ready timers so the red test itself remains bounded. + +- [x] **Step 3: Implement native timer races** + + Compute remaining time from the injected application clock before provider polling. Cloudflare + uses `worker::Delay`; Spin uses `spin_sdk::time::sleep`. Poll the timer first for equality + precedence, drop the losing provider future, then retain the existing post-ready deadline and + byte-cap checks. Native test-only backends use an injected timer helper rather than wall sleeps. + +- [x] **Step 4: Run native and WASM checks** + + Run the affected native tests plus Cloudflare `wasm32-unknown-unknown` and Spin `wasm32-wasip2` + max-feature Clippy commands. + +### Task 5: Document late binding and align consumer surfaces + +**Files:** +- Modify: `docs/guide/handlers.md` +- Modify: `docs/guide/capabilities.md` +- Modify: `docs/superpowers/specs/2026-08-22-inbound-body-design.md` +- Modify: `docs/superpowers/specs/2026-09-08-response-egress-design.md` +- Modify: `docs/superpowers/specs/2026-06-16-blob-app-config.md` +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/lib.rs.hbs` +- Test: demo lifecycle tests and generated-project compile test + +- [x] **Step 1: Add the late-bound shared one-shot example** + + Show one handle travelling through `IngressGrant` and one through + `ResponseEgressCompletion`; the handler installs the acquired permit and terminal completion + takes/releases it. State explicitly that handler-local capture releases too early and that + install-after-terminal must fail closed. + +- [x] **Step 2: Align capability language** + + State that Cloudflare/Spin timer races bound EdgeZero's return, not provider cancellation; + Fastly remains non-preemptible; no adapter satisfies a Native cancellation requirement. + +- [x] **Step 3: Keep demo and generator on the public hard-cut API** + + Configure the detached factory and add lifecycle assertions without pretending the illustrative + lease is a production semaphore. Regenerate/compile the scaffold to prove template parity. + +### Task 6: Replace application-owned permit slots with a reusable core primitive + +**Files:** +- Modify: `crates/edgezero-core/src/response_egress.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/lib.rs.hbs` +- Modify: `crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `docs/guide/handlers.md` +- Modify: `docs/superpowers/specs/2026-09-08-response-egress-design.md` + +- [x] **Step 1: Write failing core state-machine tests** + + Specify `ResponseEgressCompletion::late_bound::()` returning one non-clone + `ResponseEgressResource` installer plus its terminal completion. Prove one installation lives + until terminal egress; install-before-terminal and terminal-before-install ordering; duplicate + installation; completion abandonment before and after installation while the installer remains; + and competing terminal attempts. Require duplicate and closed installations to drop the rejected + resource outside the mutex and return distinct typed errors, terminal/abandonment to release an + installed resource exactly once, and envelope abandonment to fabricate no completion or observer + report. Add a compile-fail doctest proving the installer is non-clone and a type assertion proving + it is `Send + Sync` for `T: Send` even when `T` is not `Sync`. Race installation against close, + verify poisoned-lock recovery, and require the typed install failure to implement + `std::error::Error`. + +- [x] **Step 2: Run the focused tests and verify RED** + + Run: `cargo test -p edgezero-core --lib response_egress::tests::late_bound` + + Expected: compile failure because the reusable constructor, resource, and install error do not + exist. + +- [x] **Step 3: Implement the generic ownership primitive** + + Add `ResponseEgressResource` backed by one private shared mutex state and + `ResponseEgressResourceInstallError::{AlreadyInstalled, Closed}`. Its public + `install(&self, resource: T)` method consumes the candidate and never returns it. Require + `T: Send + 'static`. Capture a private completion-side owner in the callback; both callback + execution and owner `Drop` close the slot and take the installed resource while the installer may + remain alive. Always release the mutex before dropping application resources. Keep terminal + callback execution in the existing `ResponseEgressCompletion` exactly-once boundary. + +- [x] **Step 4: Run focused core tests and strict core Clippy** + + Run: `cargo test -p edgezero-core --lib response_egress::tests::late_bound` and + `cargo test -p edgezero-core --doc response_egress` and + `cargo clippy -p edgezero-core --all-targets --all-features -- -D warnings`. + +- [x] **Step 5: Make generator assertions reject the local state machine and verify RED** + + Require `ResponseEgressCompletion::late_bound` and `ResponseEgressResource` in generated core + source, and reject `ResponsePermitSlot`, `ResponsePermitState`, and an application-owned mutex + lifecycle implementation. Run generator tests and verify the existing template fails. + +- [x] **Step 6: Hard-cut demo and generator to the core primitive** + + Delete their local mutex state machine. Keep only the illustrative application permit, + admission lease metadata, and one `install(permit)` call. Retain one integration lifecycle test + in each generated/demo surface; move duplicate/post-terminal/abandonment proofs to core. + +- [x] **Step 7: Replace documentation examples** + + Document the public primitive and its typed failure semantics. Make generated-project assertions + and the normative specification require the core late-bound owner instead of application-owned + synchronization. + +- [x] **Step 8: Run demo, generator, and generated-project gates** + + Run demo tests and strict Clippy, generator unit tests, and the ignored generated workspace gate. + +### Task 7: Full verification + +**Files:** +- Verify all modified files; do not alter unrelated worktree changes. + +- [x] **Step 1: Run repository gates** + + Run workspace tests, strict Clippy, formatting, strict rustdoc, feature compilation, docs/legacy + contract scripts, app-demo tests/Clippy, generated workspace compilation, and all supported WASM + max-feature Clippy checks. + +- [x] **Step 2: Review the final diff** + + Confirm normalized errors own one completion, pending async config reads return typed deadline + errors, capability claims remain conservative, and the pre-existing rustdoc/CI edits are + preserved. diff --git a/docs/superpowers/plans/2026-09-19-detached-ingress-abort-decision.md b/docs/superpowers/plans/2026-09-19-detached-ingress-abort-decision.md new file mode 100644 index 000000000..305a73088 --- /dev/null +++ b/docs/superpowers/plans/2026-09-19-detached-ingress-abort-decision.md @@ -0,0 +1,211 @@ +# Detached Ingress Abort Decision Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let an application atomically choose either a response-owned completion resource or a transport abort for normalized ingress errors before EdgeZero constructs a detached response. + +**Architecture:** Replace the completion-only detached-response factory with a synchronous typed decision factory returning `DetachedResponseEgressDecision::Send(ResponseEgressCompletion)` or `Abort`. `App::detached_ingress_error_egress` converts that decision into the existing `IngressDispatchOutcome`, and every adapter maps `Aborted` through its existing non-response abort/error boundary. This is a hard cut: no compatibility aliases or legacy callback remain. + +**Tech Stack:** Rust 2024, EdgeZero core ingress/response-egress APIs, Axum/Cloudflare/Fastly/Spin adapters, colocated and adapter contract tests, VitePress documentation. + +--- + +### Task 1: Specify the detached error decision contract + +**Files:** +- Modify: `docs/superpowers/specs/2026-08-22-inbound-body-design.md` +- Modify: `docs/superpowers/specs/2026-09-08-response-egress-design.md` +- Modify: `docs/guide/capabilities.md` +- Modify: `docs/guide/handlers.md` + +- [x] **Step 1: Update the normative contract** + + Define `DetachedResponseEgressDecision::{Send(ResponseEgressCompletion), Abort}`. State that the callback executes exactly once after normalized metadata exists and before response construction. `Abort` creates no response, envelope, completion, observer report, middleware call, handler call, or body poll. The native adapter proves zero response bytes over a raw socket; provider adapters return through their existing fixed pre-response error boundaries without claiming a transport-observed reset. + +- [x] **Step 2: Preserve capability boundaries** + + Keep raw parser failures outside the portable lifecycle and retain the existing `BestEffort` response-egress capability classifications. Update the public capability guide without promoting provider abort support. + +- [x] **Step 3: Run documentation checks** + + Run: `node scripts/check_outbound_docs_contract.mjs` + + Expected: PASS with no stale completion-only factory contract. + +### Task 2: Add failing decision tests across core and adapters + +**Files:** +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-adapter-axum/src/service.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` + +- [x] **Step 1: Write failing core tests for `Send`, `Abort`, and the default** + + Add tests proving `Send` creates detached egress and settles its completion exactly once, while `Abort` returns `IngressDispatchOutcome::Aborted` without constructing or settling a completion. Prove an unset factory defaults to `Send(ResponseEgressCompletion::empty())`. + +- [x] **Step 2: Run the focused tests and observe RED** + + Run: `cargo test -p edgezero-core detached_ingress_error` + + Expected: FAIL because the typed decision and outcome-returning API do not exist. + +- [x] **Step 3: Write failing adapter tests before changing production call sites** + + Add two cases per adapter: normalized request-head validation failure and admission-policy failure. Each decision factory returns `Abort`; assert exactly one factory call and zero response delivery, body polling, middleware, handler, completion, and observer activity. Where a failing policy created an owned probe before returning its error, assert its drop count. For the native adapter, add a raw-socket proof of zero response bytes. For provider adapters, assert the existing fixed pre-response error boundary without claiming a transport reset. + +- [x] **Step 4: Run the focused suites and observe RED** + + Run: + + `scripts/run_test_nonzero.sh normalized_ingress_error_decision_abort_closes_without_response cargo test -p edgezero-adapter-axum --no-default-features --features axum,test-utils --lib normalized_ingress_error_decision_abort_closes_without_response` + + `scripts/run_test_nonzero.sh normalized_ingress_error_decision_abort_uses_provider_error cargo test -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --test contract normalized_ingress_error_decision_abort_uses_provider_error` + + `scripts/run_test_nonzero.sh normalized_ingress_error_decision_abort_uses_provider_error cargo test -p edgezero-adapter-fastly --no-default-features --features fastly,test-utils --target wasm32-wasip1 --test contract normalized_ingress_error_decision_abort_uses_provider_error` + + `scripts/run_test_nonzero.sh normalized_ingress_error_decision_abort_uses_provider_error cargo test -p edgezero-adapter-spin --no-default-features --features spin,test-utils --target wasm32-wasip2 --test contract normalized_ingress_error_decision_abort_uses_provider_error` + + Expected: FAIL because the decision enum/setter do not exist. The sentinels prove the intended suites are non-empty. + +### Task 3: Implement the core decision and adapter mappings + +**Files:** +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/src/response_egress.rs` +- Modify: `crates/edgezero-adapter-axum/src/service.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/request.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` +- Modify: `crates/edgezero-adapter-spin/src/request.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` + +- [x] **Step 1: Implement the hard-cut core API** + + Add the public non-exhaustive decision enum and decision-factory alias. Replace `set_detached_response_egress_completion_factory` with `set_detached_response_egress_decision_factory`. Change `detached_ingress_error_egress` to return `IngressDispatchOutcome`, using `Response(Box)` for `Send` and `Aborted` for `Abort`. Default to `Send(ResponseEgressCompletion::empty())`. + +- [x] **Step 2: Migrate existing `Send` fixtures and map every adapter boundary** + + Wrap every existing detached completion in `DetachedResponseEgressDecision::Send`. Match `IngressDispatchOutcome::Response`, `Aborted`, and a wildcard arm at every normalized-validation and admission-policy-error call site. Both `Aborted` and unknown future variants fail closed through the existing non-response abort boundary; do not add another transport error type. + +- [x] **Step 3: Run core and focused adapter tests and observe GREEN** + + Run: `cargo test -p edgezero-core detached_ingress_error` + + Run the four `scripts/run_test_nonzero.sh` commands from Task 2, then run equivalent sentinel commands for each admission-policy-error abort test. + + Expected: PASS. + +- [x] **Step 4: Run the core suite** + + Run: `cargo test -p edgezero-core` + + Expected: PASS. + +### Task 4: Align the reference app and generator + +**Files:** +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/lib.rs.hbs` + +- [x] **Step 1: Add a failing generator assertion** + + Require generated applications to use the decision factory and `Send` variant. Retain one intentional negative assertion rejecting the removed completion-only setter. + +- [x] **Step 2: Run the generator test and observe RED** + + Run: `cargo test -p edgezero-cli generate_new_scaffolds_workspace_layout` + + Expected: FAIL on the legacy setter or missing decision enum. + +- [x] **Step 3: Update demo and templates** + + Wrap completion resources in `DetachedResponseEgressDecision::Send`. Keep the reference behavior unchanged; the API demonstrates where a bounded application may return `Abort`. + +- [x] **Step 4: Run demo and generator tests** + + Run: `cargo test -p edgezero-cli generate_new_scaffolds_workspace_layout` + + Run from `examples/app-demo`: `cargo test --workspace --all-targets` + + Expected: PASS. + +### Task 5: Complete cross-target verification + +**Files:** +- Modify only files required by verification failures caused by this hard cut. +- Modify: `scripts/check_outbound_legacy_api.sh` +- Modify: `scripts/check_outbound_docs_contract.mjs` + +- [x] **Step 1: Enforce the hard cut in repository checkers** + + Extend the legacy API checker to reject the old factory type/setter in active Rust/template surfaces. Extend the docs contract checker to require the new decision enum, `Send`/`Abort` semantics, and unchanged raw-parser/provider capability caveats. + + Run a scoped stale-API search over `crates`, `examples`, and active guide/spec documents, excluding the intentional generator negative assertion and historical plans. + + Expected: no production, template, test, or documentation matches. + +- [x] **Step 2: Run formatting and native gates** + + Run: `cargo fmt --all -- --check` + + Run: `cargo test --workspace --all-targets` + + Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings` + + Expected: PASS. + +- [x] **Step 3: Run feature and WASM gates** + + Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin"` + + Run the strict WASM Clippy matrix: + + ```bash + cargo clippy -p edgezero-adapter-cloudflare --features "cloudflare" --target wasm32-unknown-unknown --all-targets -- -D warnings + cargo clippy -p edgezero-adapter-cloudflare --features "cloudflare test-utils" --target wasm32-unknown-unknown --all-targets -- -D warnings + cargo clippy -p edgezero-adapter-fastly --features "fastly" --target wasm32-wasip1 --all-targets -- -D warnings + cargo clippy -p edgezero-adapter-fastly --features "fastly cli" --target wasm32-wasip1 --all-targets -- -D warnings + cargo clippy -p edgezero-adapter-fastly --features "fastly test-utils" --target wasm32-wasip1 --all-targets -- -D warnings + cargo clippy -p edgezero-adapter-fastly --features "fastly cli test-utils" --target wasm32-wasip1 --all-targets -- -D warnings + cargo clippy -p edgezero-adapter-spin --features "spin" --target wasm32-wasip2 --all-targets -- -D warnings + cargo clippy -p edgezero-adapter-spin --features "spin test-utils" --target wasm32-wasip2 --all-targets -- -D warnings + ``` + + Run the target-specific adapter contract commands from Task 2 with configured WASM runners, plus: + + ```bash + bash scripts/check_adapter_feature_matrix.sh cloudflare wasm32-unknown-unknown + bash scripts/check_adapter_feature_matrix.sh fastly wasm32-wasip1 + bash scripts/check_adapter_feature_matrix.sh spin wasm32-wasip2 + ``` + + Expected: PASS. + +- [x] **Step 4: Run rustdoc, docs, demo, and generated-project gates** + + Run: + + ```bash + RUSTDOCFLAGS='-D warnings' cargo doc --offline --locked --workspace --all-features --no-deps + scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test --offline --locked -p edgezero-cli --test generated_project_builds + bash scripts/check_outbound_legacy_api.sh + node scripts/check_outbound_docs_contract.mjs + (cd docs && npm run lint && npm run format && npm run build) + (cd examples/app-demo && cargo fmt --all -- --check) + (cd examples/app-demo && cargo test --locked --workspace --all-targets) + (cd examples/app-demo && cargo clippy --workspace --all-targets --all-features -- -D warnings) + (cd examples/app-demo && cargo check --locked -p app-demo-adapter-cloudflare --target wasm32-unknown-unknown --no-default-features --features cloudflare) + (cd examples/app-demo && cargo check --locked -p app-demo-adapter-fastly --target wasm32-wasip1 --no-default-features --features fastly) + (cd examples/app-demo && cargo check --locked -p app-demo-adapter-spin --target wasm32-wasip2 --no-default-features --features spin) + ``` + + Expected: PASS. + +- [x] **Step 5: Review, commit, and push** + + Review the diff for accidental compatibility shims, stale terminology, and unrelated changes. Commit with an application-neutral message and push to the existing PR branch. diff --git a/docs/superpowers/plans/2026-09-19-response-completion-ingress-abort.md b/docs/superpowers/plans/2026-09-19-response-completion-ingress-abort.md new file mode 100644 index 000000000..6e7f5f5bc --- /dev/null +++ b/docs/superpowers/plans/2026-09-19-response-completion-ingress-abort.md @@ -0,0 +1,676 @@ +# Response Completion, Ingress Abort, and Fallible App Assembly Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to +> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Apply +> `superpowers:test-driven-development` to every behavior change and +> `superpowers:verification-before-completion` before claiming success. + +**Goal:** Give every response-producing admission decision one non-clone completion owner, add a +portable non-response ingress abort outcome, and make application configuration fail explicitly +before an adapter begins serving a request. + +**Architecture:** `AdmissionDecision` owns either a response completion, grant, and deadline or an +explicit abort. Core transfers the completion through `PreparedIngress`, +`ResponseEgressEnvelope`, and the sole `ResponseEgressAttempt`; the attempt stores terminal state, +invokes the response completion once, then invokes the global observer. Adapters map abort to their +strongest pre-response transport boundary and propagate fallible `App::build::(platform)` before request +conversion, admission, or body polling. This is an atomic hard cut with no extension carrier, +infallible compatibility method, or response-extraction bypass. + +**Source of truth:** +- [`2026-09-08-response-egress-design.md`](../specs/2026-09-08-response-egress-design.md) +- [`2026-08-22-inbound-body-design.md`](../specs/2026-08-22-inbound-body-design.md) +- [`2026-06-16-blob-app-config.md`](../specs/2026-06-16-blob-app-config.md) + +**Tech Stack:** Rust 1.95, `http`, futures, Hyper/Axum/Tokio, Cloudflare Workers, Fastly Compute, +Spin SDK WASIp3, proc macros, Handlebars templates, VitePress documentation. + +--- + +## File Structure + +- `crates/edgezero-core/src/response_egress.rs`: owns the non-clone completion callback and invokes + it from the exactly-once attempt state machine. +- `crates/edgezero-core/src/ingress.rs`: owns admission decision/outcome types and transfers the + completion or explicit abort without body access. +- `crates/edgezero-core/src/app.rs`: retains completion across routing/error replacement and exposes + a response-or-abort high-level dispatch result; hard-cuts `Hooks` to fallible construction. +- `crates/edgezero-core/src/manifest.rs` and `crates/edgezero-core/src/lib.rs`: expose the new public + lifecycle types and `ingress-admission-abort` capability. +- `crates/edgezero-macros/src/app.rs`: emits the fallible `configure`/`build_app` contract without + discarding callback results. +- `crates/edgezero-adapter-axum/src/{service,connection,dev_server}.rs`: maps abort to a Hyper + service error/connection close and configuration failure to an error before listener bind. +- `crates/edgezero-adapter-{cloudflare,fastly,spin}/src/{lib,request}.rs`: maps abort to a fixed + platform error and configuration failure to the documented pre-dispatch boundary. +- `crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/cli.rs`: declares the new abort + capability conservatively. +- `examples/app-demo/crates/app-demo-core/src/lib.rs` and + `crates/edgezero-cli/src/templates/core/src/lib.rs.hbs`: demonstrate the new completion and + fallible configuration signatures. +- `crates/edgezero-cli/src/generator.rs`, adapter templates, and demo adapter entrypoints: assert + generated and shipped applications use the hard-cut APIs. +- `docs/guide/{handlers,capabilities}.md`, adapter guides, and + `scripts/check_outbound_docs_contract.mjs`: publish and mechanically enforce the contract. + +## Global Constraints + +- `ResponseEgressCompletion` is non-clone, never stored in `http::Extensions`, and always present + on response-producing admission decisions; no-resource callers use `empty()`. +- Terminal state is stored before callbacks. Completion runs once before the global observer; on + unwind-capable targets each panic is contained independently with category-only logging. +- `ResponseEgressEnvelope::into_response()` is removed. A response can be transmitted only after + `begin()` transfers completion ownership into an attempt. +- `Abort` creates no response, envelope, attempt, completion report, or global observer report and + polls no request body. +- No adapter maps `Abort` to EdgeZero's normal JSON error response. +- `Hooks::configure` and core-owned `App::build::` return `Result`; no unchecked or infallible bridge is + retained. +- After every production edit, run the narrowest affected package tests before proceeding. + +### Task 1: Add the response-scoped completion state machine + +**Files:** +- Modify: `crates/edgezero-core/src/response_egress.rs` +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-macros/tests/app_macro.rs` +- Modify: `crates/edgezero-adapter-axum/src/{connection,response}.rs` +- Modify: `crates/edgezero-adapter-{cloudflare,fastly,spin}/src/response.rs` +- Test: colocated tests in `crates/edgezero-core/src/response_egress.rs` + +- [x] **Step 1: Write red completion-order and exactly-once tests** + + Add tests proving normal completion, source/transport failure, fallback completion, guard drop, + and competing terminal signals invoke the response completion exactly once before the global + observer. Add an unwind test proving a completion panic does not suppress the observer and an + observer panic does not re-enter completion. + + Use an ordered recorder whose expected sequence is: + + ```rust + vec!["completion:completed", "observer:completed"] + ``` + +- [x] **Step 2: Run the focused tests and verify the missing API fails** + + Run: + + ```bash + cargo test -p edgezero-core --lib response_egress::tests::response_completion + ``` + + Expected: compile failure because `ResponseEgressCompletion` and the completion constructor + parameter do not exist. + +- [x] **Step 3: Implement direct completion ownership** + + Add the public non-clone owner: + + ```rust + pub struct ResponseEgressCompletion { + callback: Option>, + } + + impl ResponseEgressCompletion { + pub fn empty() -> Self; + pub fn new(complete: Complete) -> Self + where + Complete: FnOnce(&ResponseEgressReport) + Send + 'static; + } + ``` + + Move it into `ResponseEgressEnvelope`, transfer it to `ResponseEgressAttempt` before policy or + framing work, store terminal state first, invoke completion through its own unwind boundary, and + then invoke the observer. In this same step, update every direct attempt/envelope constructor in + core `app.rs` and all four adapter response/connection test helpers to pass an explicit empty + completion. Do not defer those call sites to later tasks: Task 1 must leave the workspace + compilable. Re-export the type from `edgezero_core`. + +- [x] **Step 4: Remove the response extraction bypass** + + Delete `ResponseEgressEnvelope::into_response()`. Replace core test inspection with a test-only + helper that calls `begin()`, begins writing, terminally settles the attempt, and only then obtains + the response from `PreparedResponseEgress`. Migrate the macro integration test at the same time + so the workspace-wide all-targets check remains compilable. Add `compile_fail` rustdoc examples + proving the completion cannot be cloned or inserted into `http::Extensions`. + +- [x] **Step 5: Run the core lifecycle suite** + + Run: + + ```bash + cargo test -p edgezero-core --lib response_egress + cargo test -p edgezero-core --doc + cargo check --workspace --all-targets --features "fastly cloudflare spin" + cargo check -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare + cargo check -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly + cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin + ``` + + Expected: all response-egress unit tests and compile-fail doctests pass. + +- [x] **Step 6: Commit the core completion primitive** + + ```bash + git add crates/edgezero-core/src/response_egress.rs crates/edgezero-core/src/app.rs \ + crates/edgezero-core/src/lib.rs crates/edgezero-macros/tests/app_macro.rs \ + crates/edgezero-adapter-*/src/response.rs \ + crates/edgezero-adapter-axum/src/connection.rs + git commit -m "feat(core): add response-scoped egress completion" + ``` + +### Task 2: Carry completion through ingress and add explicit abort + +**Files:** +- Modify: `crates/edgezero-core/src/ingress.rs` +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-core/src/router.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Test: colocated tests in those files + +- [x] **Step 1: Write red admission ownership tests** + + Add table-driven tests for `Admit`, `ReadBodyBeforeFallback`, `Refuse`, and `Abort`. Prove the + exact completion reaches normal handler success, handler error, canonical 404/405, exact-cap + fallback, overflow, timeout, admitted conversion error, and detached refusal. Prove a dropped + pre-begin envelope releases the captured resource without invoking its callback. + +- [x] **Step 2: Write red abort tests at the core boundary** + + Assert `Abort` yields `IngressAdmissionOutcome::Aborted`, then + `IngressBeginOutcome::Aborted`, and finally `IngressDispatchOutcome::Aborted`; body, middleware, + handler, completion, observer, and response counters must remain zero. + +- [x] **Step 3: Run focused tests and verify the variants fail to compile** + + Run: + + ```bash + cargo test -p edgezero-core --lib ingress::tests + cargo test -p edgezero-core --lib app::tests + ``` + + Expected: compile failures for the new completion fields and abort/result variants. + +- [x] **Step 4: Hard-cut the admission API** + + Implement these shapes without compatibility constructors: + + ```rust + pub enum AdmissionDecision { + Admit { + completion: ResponseEgressCompletion, + grant: IngressGrant, + read_deadline: Deadline, + }, + ReadBodyBeforeFallback { + completion: ResponseEgressCompletion, + grant: IngressGrant, + max_body_bytes: usize, + read_deadline: Deadline, + on_exceeded: BufferedIngressResponse, + on_timeout: BufferedIngressResponse, + }, + Refuse { + completion: ResponseEgressCompletion, + response: Response, + }, + Abort, + } + ``` + + Make `IngressAdmissionOutcome::Admitted` carry `AdmittedIngress` plus completion, make + `PreparedIngress` own completion beside the router token/ingress proof. Define refusal and abort + explicitly at the validated boundary: + + ```rust + pub enum IngressAdmissionOutcome { + Admitted { + completion: ResponseEgressCompletion, + ingress: AdmittedIngress, + }, + Refused { + completion: ResponseEgressCompletion, + response: Response, + }, + Aborted, + } + ``` + + Add `Aborted` to the begin outcome. The default policy supplies + `ResponseEgressCompletion::empty()`. + +- [x] **Step 5: Retain completion across response replacement** + + Add `IngressDispatchOutcome::{Response, Aborted}`. Change `App::dispatch_ingress` to return it. + In `dispatch_admitted`, split the completion from `PreparedIngress` before awaiting router + dispatch, retain it while the router consumes `AdmittedIngress`, and move it into the resulting + envelope regardless of the selected response. Make refusal egress use detached policy/no-op + observer plus the refusal completion. This original phase used an empty completion for + pre-admission errors; the later detached-egress completion factory supersedes that behavior. + +- [x] **Step 6: Migrate all core admission constructors and response-inspection tests** + + Update core/router tests to provide explicit empty completions where no resource is under test. + Replace every envelope `into_response()` use with a begin-and-settle helper. Keep + `PreparedResponseEgress::into_response()` because the attempt already owns completion at that + point. + +- [x] **Step 7: Run all core tests** + + Run: + + ```bash + cargo test -p edgezero-core + ``` + + Expected: all core tests pass, including exact completion and abort counters. + +- [x] **Step 8: Commit the ingress ownership cut** + + ```bash + git add crates/edgezero-core/src/app.rs crates/edgezero-core/src/ingress.rs \ + crates/edgezero-core/src/router.rs crates/edgezero-core/src/lib.rs + git commit -m "feat(core): carry egress completion through ingress" + ``` + +### Task 3: Map abort to the Axum connection boundary + +**Files:** +- Modify: `crates/edgezero-adapter-axum/src/service.rs` +- Modify: `crates/edgezero-adapter-axum/src/connection.rs` +- Test: colocated tests in both files +- Test: `crates/edgezero-adapter-axum/tests/contract.rs` + +- [x] **Step 1: Write a red raw-socket abort test** + + Configure admission to return `Abort`, send a request with a body over a real loopback HTTP/1 + socket, and assert EOF/reset with zero response bytes. Counters must prove no body poll, + middleware/handler call, completion, or observer invocation. + +- [x] **Step 2: Run the focused Axum tests and verify the request currently becomes a response** + + Run: + + ```bash + cargo test -p edgezero-adapter-axum admission_abort -- --nocapture + ``` + + Expected: failure because the service has no abort error path. + +- [x] **Step 3: Implement a private Hyper service abort error** + + Change `dispatch_request` and `AxumConnectionService::call` to return + `Result, AxumIngressAbort>`. Map only + `IngressBeginOutcome::Aborted` to this error; all ordinary EdgeZero failures remain bounded HTTP + responses. Extend connection classification with `ConnectionExit::AdmissionAborted` by + downcasting the Hyper error chain, then drop the connection without constructing response head + or body. + +- [x] **Step 4: Migrate Axum tests away from envelope extraction** + + Update service/contract fixtures for mandatory completion fields and begin-and-settle any + response envelope they inspect. + +- [x] **Step 5: Run the Axum suite** + + Run: + + ```bash + cargo test -p edgezero-adapter-axum + ``` + + Expected: all Axum unit, contract, and raw-socket tests pass. + +- [x] **Step 6: Commit the Axum abort boundary** + + ```bash + git add crates/edgezero-adapter-axum/src/service.rs \ + crates/edgezero-adapter-axum/src/connection.rs \ + crates/edgezero-adapter-axum/tests/contract.rs + git commit -m "feat(axum): close connections on ingress abort" + ``` + +### Task 4: Map abort across Cloudflare, Fastly, and Spin + +**Files:** +- Modify: `crates/edgezero-adapter-cloudflare/src/request.rs` +- Modify: `crates/edgezero-adapter-fastly/src/request.rs` +- Modify: `crates/edgezero-adapter-spin/src/request.rs` +- Test: `crates/edgezero-adapter-cloudflare/tests/contract.rs` +- Test: `crates/edgezero-adapter-fastly/tests/contract.rs` +- Test: `crates/edgezero-adapter-spin/tests/contract.rs` + +- [x] **Step 1: Write red adapter abort contract tests** + + Reuse each adapter's injected source/delivery seam. For an aborting app, assert source + construction and delivery closures are never called, no envelope exists, and the adapter returns + a fixed category-only platform error. Keep Cloudflare/Fastly/Spin transport reset claims out of + local tests. + +- [x] **Step 2: Run the three focused suites and verify they fail** + + Run: + + ```bash + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner \ + cargo test -p edgezero-adapter-cloudflare --no-default-features \ + --features cloudflare,test-utils --target wasm32-unknown-unknown --test contract admission_abort + CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" \ + cargo test -p edgezero-adapter-fastly --no-default-features \ + --features fastly,test-utils --target wasm32-wasip1 --test contract admission_abort + CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run -W component-model-async=y -S p3=y -S http=y" \ + cargo test -p edgezero-adapter-spin --no-default-features \ + --features spin,test-utils --target wasm32-wasip2 --test contract admission_abort + ``` + + Expected: failures because unknown begin outcomes are currently rendered as detached internal + responses. + +- [x] **Step 3: Implement non-response abort propagation** + + Cloudflare and Spin return their fixed host error directly before `make_source` or `deliver`. + Fastly adds an abort branch to its private `DispatchOutcome` and returns a fixed + `fastly::Error`/top-level error before finalize or delivery. Remove wildcard branches that convert + the known abort variant into an internal HTTP response; retain a future-unknown fail-closed arm + only where `#[non_exhaustive]` requires it. + +- [x] **Step 4: Migrate contract fixtures to completion ownership** + + Add explicit empty completions to ordinary decisions and replace every + `ResponseEgressEnvelope::into_response()` call with begin-and-settle test helpers. Add one + response-producing test per adapter proving a refusal completion reaches terminal egress once. + +- [x] **Step 5: Run adapter host and target checks** + + Run: + + ```bash + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner \ + scripts/run_test_nonzero.sh admission_abort cargo test -p edgezero-adapter-cloudflare \ + --no-default-features --features cloudflare,test-utils \ + --target wasm32-unknown-unknown --test contract + CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" \ + scripts/run_test_nonzero.sh admission_abort cargo test -p edgezero-adapter-fastly \ + --no-default-features --features fastly,test-utils --target wasm32-wasip1 --test contract + CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run -W component-model-async=y -S p3=y -S http=y" \ + scripts/run_test_nonzero.sh admission_abort cargo test -p edgezero-adapter-spin \ + --no-default-features --features spin,test-utils --target wasm32-wasip2 --test contract + cargo check -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare + cargo check -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly + cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin + ``` + + Expected: all supported host tests and WASM checks pass. + +- [x] **Step 6: Commit provider abort propagation** + + ```bash + git add crates/edgezero-adapter-cloudflare crates/edgezero-adapter-fastly \ + crates/edgezero-adapter-spin + git commit -m "feat(adapters): propagate ingress abort without a response" + ``` + +### Task 5: Hard-cut Hooks and macro expansion to fallible assembly + +**Files:** +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-macros/src/app.rs` +- Test: `crates/edgezero-macros/tests/app_macro.rs` +- Modify: `crates/edgezero-adapter-axum/src/dev_server.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/lib.rs` +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` +- Modify: `crates/edgezero-adapter-spin/src/lib.rs` +- Test: adapter-local host-testable build seams plus target contract tests + +- [x] **Step 1: Write red core and macro propagation tests** + + Add a failing configure callback returning a typed `EdgeError`. Assert both hand-written Hooks + and macro-generated Hooks return the same error from `App::build::` and never expose a partially + configured `App`. + +- [x] **Step 2: Run core/macro tests and verify callback results are currently discarded** + + Run: + + ```bash + cargo test -p edgezero-core --lib app::tests::build_app + cargo test -p edgezero-macros --test app_macro + ``` + + Expected: compile failure because `configure` and application assembly are infallible. + +- [x] **Step 3: Implement the fallible trait and macro signatures** + + Hard-cut to: + + ```rust + impl App { + pub fn build(platform: PlatformMetadata) -> Result { + let mut app = Self::with_name_and_platform(A::routes(), A::name(), platform); + A::configure(&mut app)?; + Ok(app) + } + } + + pub trait Hooks { + fn configure(_app: &mut App) -> Result<(), EdgeError> { + Ok(()) + } + } + ``` + + Make `app!` emit the hook methods and return the configured callback result. Do not add an + overridable builder, callback coercion, or panic fallback. + +- [x] **Step 4: Write red adapter-boundary tests** + + Add one small adapter-local `build_app_for_dispatch()` seam used by the production entrypoint + before any platform request work. Use a `FailingHooks` implementation and a dispatch/bind/receive + closure counter to prove failure prevents the next boundary. Axum's seam is host-tested and + proves no listener bind. Cloudflare, Fastly, and Spin exercise their platform error mapping in + target contract tests. Cloudflare logs only `EdgeError::kind()` and returns exactly + `worker::Error::RustError("application configuration failed")`. + +- [x] **Step 5: Propagate build failure at each entrypoint** + + Axum and Spin retain the `EdgeError` source under fixed `anyhow` context. Cloudflare maps through + a private helper that logs stable kind only and emits the fixed public worker error. Fastly + changes its app runners and generated/demo main return type if necessary so the top-level error + retains the `EdgeError` source under fixed `"application configuration failed"` context. Build + before the boundary specified in the source specs. + +- [x] **Step 6: Run core, macro, and adapter entrypoint tests** + + Run: + + ```bash + cargo test -p edgezero-core + cargo test -p edgezero-macros + cargo test -p edgezero-adapter-axum failing_configuration + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner \ + scripts/run_test_nonzero.sh failing_configuration cargo test \ + -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils \ + --target wasm32-unknown-unknown --test contract + CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" \ + scripts/run_test_nonzero.sh failing_configuration cargo test \ + -p edgezero-adapter-fastly --no-default-features --features fastly,test-utils \ + --target wasm32-wasip1 --test contract + CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run -W component-model-async=y -S p3=y -S http=y" \ + scripts/run_test_nonzero.sh failing_configuration cargo test \ + -p edgezero-adapter-spin --no-default-features --features spin,test-utils \ + --target wasm32-wasip2 --test contract + ``` + + Expected: all package tests pass with no infallible build call sites. + +- [x] **Step 7: Commit fallible application assembly** + + ```bash + git add crates/edgezero-core/src/app.rs crates/edgezero-macros \ + crates/edgezero-adapter-axum/src/dev_server.rs \ + crates/edgezero-adapter-cloudflare/src/lib.rs \ + crates/edgezero-adapter-fastly/src/lib.rs \ + crates/edgezero-adapter-spin/src/lib.rs + git commit -m "feat(app): make application assembly fallible" + ``` + +### Task 6: Align capabilities, demo, generator, templates, and guides + +**Files:** +- Modify: `crates/edgezero-core/src/manifest.rs` +- Modify: `crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/cli.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` +- Modify: `examples/app-demo/crates/app-demo-adapter-fastly/src/main.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/lib.rs.hbs` +- Modify: `crates/edgezero-adapter-fastly/src/templates/src/main.rs.hbs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `docs/guide/handlers.md` +- Modify: `docs/guide/capabilities.md` +- Modify: adapter guides as required by changed entrypoint errors +- Modify: `scripts/check_outbound_docs_contract.mjs` +- Modify: `scripts/check_outbound_legacy_api.sh` +- Test: colocated manifest, CLI capability, generator, demo lifecycle, and docs-contract tests + +- [x] **Step 1: Write red capability and generator assertions** + + Add `Capability::IngressAdmissionAbort` with the string `ingress-admission-abort`. Assert Axum is + `Native`; Cloudflare, Fastly, and Spin are `BestEffort`. Add generator assertions for fallible + `configure_app`, explicit empty completions, fallible `build_app`, and the Fastly return type. + +- [x] **Step 2: Run the focused tests and verify stale artifacts fail** + + Run: + + ```bash + cargo test -p edgezero-core manifest::tests + cargo test -p edgezero-cli generator + node scripts/check_outbound_docs_contract.mjs + ``` + + Expected: failures until capability matrices, generated code, and docs are aligned. + +- [x] **Step 3: Migrate the demo and core template** + + Make `configure_app` return `Result<(), EdgeError>`, add `Ok(())`, and give every decision an + explicit `ResponseEgressCompletion`. Demonstrate a response-owned permit/drop probe without a + global response-id map. Update every demo/template `App::build::` call to handle `Result`. + +- [x] **Step 4: Migrate adapter templates and generated fixtures** + + Update Fastly's template/demo main if its runner now returns `anyhow::Result<()>`; update other + adapter templates only where signatures changed. Regenerate or update snapshots/assertions and + prove a newly generated project contains no removed API. + +- [x] **Step 5: Publish and enforce the capability contract** + + Add the abort capability row to the public guide and checker. Document that Axum's owned HTTP/1 + close is Native while the provider adapters remain BestEffort pending deployed evidence. Update + handler/configuration examples for `Result` and direct completion ownership. Extend the legacy + scan to reject infallible configure/build signatures, `ResponseEgressEnvelope::into_response`, + and any completion-in-response-extension carrier. + +- [x] **Step 6: Run demo, generator, docs, and capability suites** + + Run: + + ```bash + cargo test -p edgezero-cli + scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test --offline \ + --locked -p edgezero-cli --test generated_project_builds + cargo test --manifest-path examples/app-demo/Cargo.toml -p app-demo-core + cargo check --manifest-path examples/app-demo/Cargo.toml --workspace + npm --prefix docs run format + npm --prefix docs run lint + node scripts/check_outbound_docs_contract.mjs + bash scripts/check_outbound_legacy_api.sh + ``` + + Expected: all generated/demo code and documentation checks pass. + +- [x] **Step 7: Commit the consumer migration** + + ```bash + git add crates/edgezero-core/src/manifest.rs crates/edgezero-adapter-*/src/cli.rs \ + crates/edgezero-cli examples/app-demo docs/guide scripts + git commit -m "docs: align consumers with ingress lifecycle hard cut" + ``` + +### Task 7: Comprehensive verification, self-review, and PR update + +**Files:** +- Review: all files changed by Tasks 1-6 +- Modify if needed: PR #275 description + +- [x] **Step 1: Audit for stale and bypassing APIs** + + Run: + + ```bash + rg -n "fn configure\([^)]*&mut App[^)]*\) \{|fn build_app\(\) -> App|ResponseEgressEnvelope.*into_response|AdmissionDecision::(Admit|Refuse|ReadBodyBeforeFallback)" crates examples docs/guide + rg -n "ResponseEgressCompletion" crates examples docs/guide + ``` + + Inspect every result. The only response-extension lifecycle value may be + `ResponseEgressDeadline`; completion must follow direct ownership. + +- [x] **Step 2: Run formatting and full native CI gates** + + Run: + + ```bash + cargo fmt --all -- --check + cargo clippy --workspace --all-targets --all-features -- -D warnings + cargo test --workspace --all-targets + cargo check --workspace --all-targets --features "fastly cloudflare spin" + ``` + + Expected: zero warnings and zero failures. + +- [x] **Step 3: Run all WASM and excluded-workspace gates** + + Run: + + ```bash + cargo check -p edgezero-adapter-fastly --target wasm32-wasip1 --features fastly + cargo check -p edgezero-adapter-cloudflare --target wasm32-unknown-unknown --features cloudflare + cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin + cargo test --manifest-path examples/app-demo/Cargo.toml --workspace + cargo check --manifest-path examples/app-demo/Cargo.toml --workspace + ``` + + Expected: all target and demo gates pass. + +- [x] **Step 4: Re-run docs and repository contract gates** + + Run: + + ```bash + npm --prefix docs run format + npm --prefix docs run lint + node scripts/check_outbound_docs_contract.mjs + bash scripts/check_outbound_legacy_api.sh + git diff --check + ``` + + Expected: all commands exit zero. + +- [x] **Step 5: Perform a final code-review pass** + + Review ownership/drop paths, callback panic ordering, abort zero-read behavior, fixed public error + messages, adapter capability claims, generated code, and every `App::build::` call. Remove dead + branches and imports introduced by the hard cut; do not retain compatibility aliases. + +- [x] **Step 6: Commit any verification fixes and push PR #275** + + ```bash + git add -A + git commit -m "test: close lifecycle hard-cut coverage" + git push origin docs/outbound-http-spec + ``` + + Skip the final commit only if verification produced no changes. Update PR #275's description to + call out direct response completion ownership, non-response admission abort, fallible app + assembly, capability evidence, and the intentionally breaking migration. diff --git a/docs/superpowers/plans/2026-09-19-response-egress-composition-detached-deadline.md b/docs/superpowers/plans/2026-09-19-response-egress-composition-detached-deadline.md new file mode 100644 index 000000000..ab00c308f --- /dev/null +++ b/docs/superpowers/plans/2026-09-19-response-egress-composition-detached-deadline.md @@ -0,0 +1,327 @@ +# Response Egress Composition and Detached Deadline Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow multiple application-owned response resources to share one terminal event and require every detached normalized-error response to carry an absolute application deadline. + +**Architecture:** `ResponseEgressCompletion::join` composes two non-clone completion owners while preserving deterministic callback order and independent panic isolation on unwind-capable targets; panic-abort targets may terminate before the second callback. `DetachedResponseEgressDecision::Send` becomes a named-field variant containing both the completion and a mandatory `Deadline`; `App` inserts that deadline into the existing `ResponseEgressDeadline` extension path before constructing detached egress. The change is a hard cut across core, adapters, the reference app, generated projects, specifications, and contract guards. + +**Tech Stack:** Rust 2024, `edgezero-core`, Axum, Cloudflare Workers, Fastly Compute, Fermyon Spin, Cargo test/Clippy, shell and Node.js contract checks, VitePress documentation. + +--- + +### Task 1: Pin the application-neutral contracts + +**Files:** +- Modify: `docs/superpowers/specs/2026-08-22-inbound-body-design.md` +- Modify: `docs/superpowers/specs/2026-09-08-response-egress-design.md` +- Modify: `docs/guide/handlers.md` +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` + +- [x] **Step 1: Specify completion composition** + + Add `ResponseEgressCompletion::join(self, other) -> Self`. Define left-to-right callback order, the same borrowed terminal report for both callbacks, independent panic isolation on unwind-capable targets, at-most-once execution, and release of both captured resources when the joined completion is abandoned. State explicitly that panic-abort targets may terminate before the second callback runs. + +- [x] **Step 2: Specify the hard-cut detached decision** + + Replace the tuple variant with: + + ```rust + #[non_exhaustive] + pub enum DetachedResponseEgressDecision { + Abort, + Send { + completion: ResponseEgressCompletion, + deadline: Deadline, + }, + } + ``` + + State that the deadline is absolute in the request's injected monotonic-clock domain, is mandatory for every normalized-error `Send` decision, and cannot be extended by the default response-egress policy. Admission-selected detached responses retain their existing response-owned deadline path and are outside this factory contract. `Abort` continues to create no response, attempt, completion callback, observer call, handler call, middleware call, or body poll. + +- [x] **Step 3: Specify relative deadline construction and default behavior** + + Add `DetachedResponseEgressHead::write_deadline_after(Duration) -> Deadline`, clamped to `DEADLINE_FAR_FUTURE` and failing closed to `request_start` on arithmetic overflow. The default decision sends with an empty completion and `DEFAULT_RESPONSE_WRITE_BUDGET` measured from captured request start. + +- [x] **Step 4: Correct public staging terminology** + + Replace the two `deploy-staged` phrases in the outbound specification with `deploy --staging`. Retain “staged version” only where it describes Fastly's provider lifecycle; the public EdgeZero action and flag remain `DeployStaging` and `--staging`. + +- [x] **Step 5: Run documentation formatting** + + Run: `cd docs && npm run format -- --check` + + Expected: PASS. + +### Task 2: Add completion composition test-first + +**Files:** +- Modify: `crates/edgezero-core/src/response_egress.rs` + +- [x] **Step 1: Write failing composition tests** + + Add focused tests proving: + + - joined callbacks both receive the same report and run once in left-to-right order; + - on unwind-capable test targets, a panic in the left callback does not suppress the right callback; + - dropping a joined completion before terminal egress releases both captured resources exactly once; + - calling the enclosing attempt's terminal path more than once cannot rerun either callback. + +- [x] **Step 2: Run the focused tests and observe RED** + + Run: `cargo test -p edgezero-core response_egress_completion_join` + + Expected: FAIL because `ResponseEgressCompletion::join` does not exist. + +- [x] **Step 3: Implement the minimal composition primitive** + + Add complete public rustdoc covering deterministic order, same-report delivery, abandonment, and the unwind-versus-panic-abort qualification. Implement an `#[inline]` consuming method equivalent to: + + ```rust + #[must_use] + #[inline] + pub fn join(mut self, mut other: Self) -> Self { + Self::new(move |report| { + self.complete(report); + other.complete(report); + }) + } + ``` + + Reuse `complete` for each child so each callback retains its own panic boundary on unwind-capable targets. Do not add cloneability, shared mutable callback registries, or a compatibility wrapper. + +- [x] **Step 4: Run focused and core tests** + + Run: `cargo test -p edgezero-core response_egress_completion_join` + + Run: `cargo test -p edgezero-core` + + Expected: PASS. + +### Task 3: Require detached response deadlines test-first + +**Files:** +- Modify: `crates/edgezero-core/src/response_egress.rs` +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-core/src/lib.rs` only if public re-exports require adjustment + +- [x] **Step 1: Write failing head/deadline tests** + + Add tests proving `write_deadline_after` is anchored to `request_start`, clamps durations to `DEADLINE_FAR_FUTURE`, and fails closed to `request_start` on overflow. + +- [x] **Step 2: Write failing detached-send tests** + + Add tests proving: + + - `Send { completion, deadline }` exposes the selected deadline to `ResponseEgressEnvelope::begin`; + - the resulting write policy uses the earlier of the detached application deadline and the default response policy; + - an already-expired detached deadline remains expired; + - `Abort` still creates no response and invokes no completion; + - the default factory selects a finite deadline from captured request start. + +- [x] **Step 3: Run focused tests and observe RED** + + Run: `cargo test -p edgezero-core detached_ingress_error` + + Run: `cargo test -p edgezero-core detached_response_egress_head` + + Expected: FAIL on the missing named fields/helper/deadline propagation. + +- [x] **Step 4: Implement the hard-cut decision and helper** + + Change `Send(ResponseEgressCompletion)` to the named-field variant. Implement `write_deadline_after` with `#[must_use]`, `#[inline]`, and public rustdoc covering request-start anchoring, `DEADLINE_FAR_FUTURE` clamping, and fail-closed overflow, using the same bounded arithmetic pattern as `IngressHead::read_deadline_after`. + +- [x] **Step 5: Thread the deadline through detached egress** + + In `App::detached_ingress_error_egress`, render the bounded response, insert `ResponseEgressDeadline::new(deadline)` into its extensions, and build the existing detached envelope with the supplied completion. Keep the default no-op observer and default policy; `ResponseEgressEnvelope::begin` already clamps the policy to the application deadline. + +- [x] **Step 6: Update the default decision factory** + + Return `Send { completion: ResponseEgressCompletion::empty(), deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET) }`. + +- [x] **Step 7: Run focused and core tests** + + Run: `cargo test -p edgezero-core detached_ingress_error` + + Run: `cargo test -p edgezero-core response_egress` + + Run: `cargo test -p edgezero-core` + + Expected: PASS. + +### Task 4: Migrate adapters and prove detached behavior + +**Files:** +- Modify: `crates/edgezero-adapter-axum/src/service.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` + +- [x] **Step 1: Migrate all factories to named fields** + + Every `Send`-producing test factory returns `Send { completion, deadline: head.write_deadline_after(...) }`. Retain explicit `Abort` coverage. Keep adapter production dispatch unchanged because it already receives the fully prepared `ResponseEgressEnvelope`. + +- [x] **Step 2: Extend the native deadline proof** + + Add an Axum contract assertion showing an expired detached deadline reaches the response-egress conversion boundary as expired and still preserves exactly-once completion. Keep the existing raw-socket zero-byte abort proof. + +- [x] **Step 3: Run focused adapter tests** + + Run: + + ```bash + scripts/run_test_nonzero.sh normalized_ingress_error_returns_typed_response_without_application_observation cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils + scripts/run_test_nonzero.sh admission_policy_error_decision_send_uses_detached_completion cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils + cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --test contract --no-run + cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features fastly,test-utils --target wasm32-wasip1 --test contract --no-run + cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features spin,test-utils --target wasm32-wasip2 --test contract --no-run + ``` + + Expected: the two native tests execute and pass; each provider contract test target compiles. Provider execution occurs with the pinned runners in Task 6. + +- [x] **Step 4: Run adapter crate tests** + + Run: `cargo test -p edgezero-adapter-axum --all-targets` + + Run: `cargo test -p edgezero-adapter-cloudflare --all-targets --features test-utils` + + Run: `cargo test -p edgezero-adapter-fastly --all-targets --features "cli test-utils"` + + Run: `cargo test -p edgezero-adapter-spin --all-targets --features test-utils` + + Expected: PASS. + +### Task 5: Align demo, templates, generator, and guards + +**Files:** +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/lib.rs.hbs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `scripts/check_outbound_legacy_api.sh` +- Modify: `scripts/check_outbound_docs_contract.mjs` + +- [x] **Step 1: Write failing generator, demo, and guard assertions** + + Require generated source to contain the named `completion` and `deadline` fields, the exact `head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET)` expression, and `.join(`. Add a demo lifecycle test proving both composed completion effects occur exactly once. Extend the legacy checker to reject `DetachedResponseEgressDecision::Send(`, `DeployStaged`, `deploy_staged`, `deploy-staged`, and `--staged` in active public/code surfaces while excluding historical plans and legitimate Fastly provider-state prose. + +- [x] **Step 2: Run the focused checks and observe RED** + + Run: `cargo test -p edgezero-cli generate_new_scaffolds_workspace_layout` + + Run: `cd examples/app-demo && cargo test composed_response_completion` + + Run: `bash scripts/check_outbound_legacy_api.sh` + + Expected: FAIL until the template, demo, and stale specification text are migrated. + +- [x] **Step 3: Migrate the reference app and template** + + Return `Send { completion, deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET) }`. Add a concise example composing the static completion with a late-bound completion using `join` without introducing application-specific terminology, and make the new demo lifecycle test pass. + +- [x] **Step 4: Strengthen the documentation contract checker** + + Require the named detached decision fields, mandatory deadline semantics, composition semantics, unchanged abort behavior, and the existing provider capability caveats. + +- [x] **Step 5: Run demo, generator, and contract checks** + + Run: `cargo test -p edgezero-cli generate_new_scaffolds_workspace_layout` + + Run: `cd examples/app-demo && cargo test --workspace --all-targets` + + Run: `bash scripts/check_outbound_legacy_api.sh` + + Run: `node scripts/check_outbound_docs_contract.mjs` + + Expected: PASS. + +### Task 6: Complete cross-target verification and publish + +**Files:** +- Modify only files required by failures caused by this hard cut. + +- [x] **Step 1: Run native workspace gates** + + Run: `cargo fmt --all -- --check` + + Run: `cargo test --workspace --all-targets` + + Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings` + + Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin"` + + Run: + + ```bash + bash scripts/check_adapter_feature_matrix.sh axum native + bash scripts/check_adapter_feature_matrix.sh cloudflare native + bash scripts/check_adapter_feature_matrix.sh fastly native + bash scripts/check_adapter_feature_matrix.sh spin native + ``` + + Expected: PASS. + +- [x] **Step 2: Run strict WASM Clippy and feature-matrix gates** + + Run: + + ```bash + cargo clippy -p edgezero-adapter-cloudflare --features cloudflare --target wasm32-unknown-unknown --all-targets -- -D warnings + cargo clippy -p edgezero-adapter-cloudflare --features "cloudflare test-utils" --target wasm32-unknown-unknown --all-targets -- -D warnings + cargo clippy -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --all-targets -- -D warnings + cargo clippy -p edgezero-adapter-fastly --features "fastly cli" --target wasm32-wasip1 --all-targets -- -D warnings + cargo clippy -p edgezero-adapter-fastly --features "fastly test-utils" --target wasm32-wasip1 --all-targets -- -D warnings + cargo clippy -p edgezero-adapter-fastly --features "fastly cli test-utils" --target wasm32-wasip1 --all-targets -- -D warnings + cargo clippy -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --all-targets -- -D warnings + cargo clippy -p edgezero-adapter-spin --features "spin test-utils" --target wasm32-wasip2 --all-targets -- -D warnings + bash scripts/check_adapter_feature_matrix.sh cloudflare wasm32-unknown-unknown + bash scripts/check_adapter_feature_matrix.sh fastly wasm32-wasip1 + bash scripts/check_adapter_feature_matrix.sh spin wasm32-wasip2 + ``` + + Expected: PASS. + +- [x] **Step 3: Run runner-backed WASM tests** + + With the pinned runners installed, run: + + ```bash + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner scripts/run_test_nonzero.sh dispatch_runs_router_and_returns_response cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --test contract + CARGO_TARGET_WASM32_WASIP1_RUNNER='viceroy run' scripts/run_test_nonzero.sh dispatch_runs_router_and_returns_response cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features fastly,test-utils --target wasm32-wasip1 --test contract + CARGO_TARGET_WASM32_WASIP1_RUNNER='viceroy run' scripts/run_test_nonzero.sh closed_lifecycle_orders_hooks_and_returns_state_after_delivery cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features fastly,test-utils --target wasm32-wasip1 --test contract + CARGO_TARGET_WASM32_WASIP2_RUNNER='wasmtime run -W component-model-async=y -S p3=y -S http=y' scripts/run_test_nonzero.sh router_dispatches_get_and_returns_response cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features spin,test-utils --target wasm32-wasip2 --test contract + CARGO_TARGET_WASM32_WASIP2_RUNNER='wasmtime run -W component-model-async=y -S p3=y -S http=y' scripts/run_test_nonzero.sh spin_error_code_table_is_exhaustive cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner scripts/run_test_nonzero.sh streamed_upload_eof_yields_before_final_precedence cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --lib + (cd crates/edgezero-adapter-fastly && CARGO_TARGET_WASM32_WASIP1_RUNNER='viceroy run' ../../scripts/run_test_nonzero.sh backend_creation_error_table_is_exhaustive cargo test --offline --locked --no-default-features --features fastly,test-utils --lib) + ``` + + Expected: PASS. If a pinned runner is unavailable locally, the corresponding hosted CI job must pass on the exact pushed head before completion. + +- [x] **Step 4: Run docs, demo, and generated-project gates** + + Run: + + ```bash + RUSTDOCFLAGS='-D warnings' cargo doc --offline --locked --workspace --all-features --no-deps + scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test --offline --locked -p edgezero-cli --test generated_project_builds + bash scripts/check_outbound_legacy_api.sh + node scripts/check_outbound_docs_contract.mjs + (cd docs && npm run lint && npm run format && npm run build) + (cd examples/app-demo && cargo fmt --all -- --check) + (cd examples/app-demo && cargo test --locked --workspace --all-targets) + (cd examples/app-demo && cargo clippy --workspace --all-targets --all-features -- -D warnings) + (cd examples/app-demo && cargo check --locked -p app-demo-adapter-cloudflare --target wasm32-unknown-unknown --no-default-features --features cloudflare) + (cd examples/app-demo && cargo check --locked -p app-demo-adapter-fastly --target wasm32-wasip1 --no-default-features --features fastly) + (cd examples/app-demo && cargo check --locked -p app-demo-adapter-spin --target wasm32-wasip2 --no-default-features --features spin) + ``` + + Expected: PASS. + +- [x] **Step 5: Review the complete diff** + + Verify no tuple `Send` constructor, compatibility shim, legacy public staging identifier, application-specific terminology, unrelated refactor, or unbounded detached response remains. + +- [x] **Step 6: Commit and push** + + Commit with an application-neutral message, push to the existing PR #275 branch, and wait for the hosted check matrix on the exact pushed head. + + Expected: every required check passes; the PR remains mergeable, subject only to repository review policy. diff --git a/docs/superpowers/plans/2026-09-26-platform-memory-ceiling.md b/docs/superpowers/plans/2026-09-26-platform-memory-ceiling.md new file mode 100644 index 000000000..d72a2c384 --- /dev/null +++ b/docs/superpowers/plans/2026-09-26-platform-memory-ceiling.md @@ -0,0 +1,113 @@ +# Platform Memory Ceiling Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +> **Historical status:** The original memory-ceiling work was completed on 2026-09-26. +> +> **Retrospective revision (2026-09-29):** The plan was superseded by the +> [platform resource metadata design](../specs/2026-09-28-platform-resource-metadata-design.md), +> and its API references were updated to the current `PlatformFact` and `PlatformMetadata` +> contract so they cannot be mistaken for migration guidance. The checked items below are a +> retrospective final-state map of the original task areas, not an execution log and not a claim +> that those exact types or tests existed on 2026-09-26. Repository history remains the record of +> the original implementation. See the +> [Unreleased changelog](../../../CHANGELOG.md#platform-resource-metadata) for the hard-cut +> migration table. + +**Retrospective final-state goal:** Expose target memory ceilings, accounting scope, and provenance through both CLI adapter metadata and runtime application configuration. + +**Retrospective final-state architecture:** `edgezero-core` owns an extensible platform descriptor containing explicit known +or unknown memory, inbound population, and host-ingress accounting facts. Runtime adapters pass +that descriptor into application construction before the configuration callback runs, while CLI +adapter implementations delegate to the same target constants. Generic Spin remains unknown unless +an entrypoint explicitly selects a hosted or runtime-configured descriptor. + +**Tech Stack:** Rust, EdgeZero adapter registry, proc macros, Cargo tests, Markdown contract checks. + +--- + +### Task 1: Core platform metadata + +**Files:** +- Create: `crates/edgezero-core/src/platform.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-macros/src/app.rs` + +- [x] Add failing core tests for `MemoryCeiling`, fact-level provenance, `PlatformMetadata`, and visibility inside `Hooks::configure`. +- [x] Run the focused core tests and confirm the missing API failures. +- [x] Add non-exhaustive platform metadata types with const constructors and accessors; provenance is carried by `PlatformFact::Known`; immediately run the focused core tests until green. +- [x] Add `App::platform()` and hard-cut core-owned `App::build::(platform)` as the single construction path; immediately run the focused app tests until green. +- [x] Update macro-generated `Hooks` implementations so platform metadata is installed before `configure`; immediately run macro and generated-code tests until green. + +### Task 2: Canonical adapter metadata + +**Files:** +- Modify: `crates/edgezero-adapter/src/registry.rs` +- Modify: `crates/edgezero-adapter-axum/src/lib.rs` +- Modify: `crates/edgezero-adapter-axum/src/cli.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/lib.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/cli.rs` +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` +- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` +- Modify: `crates/edgezero-adapter-spin/src/lib.rs` +- Modify: `crates/edgezero-adapter-spin/src/cli.rs` + +- [x] Add a failing registry test proving the inherited CLI default contains three explicit unknown facts; run it and confirm the missing API failure. +- [x] Add failing Axum tests proving CLI and runtime memory, population, and host-accounting facts are unknown with the operator-configured reason; run them and confirm failure before implementation. +- [x] Add failing Cloudflare tests proving `128_000_000` primary bytes, no separate stack, per-instance scope, platform-limit provenance, and provider-unpublished population/accounting facts; run them and confirm failure before implementation. +- [x] Add failing Fastly tests proving `128_000_000` primary bytes, a separate `1_000_000`-byte stack, per-execution scope, a population of one with platform-limit provenance, and provider-unpublished host accounting; run them and confirm failure before implementation. +- [x] Add failing Spin tests proving generic CLI/runtime facts are runtime-configured unknowns and the explicit Akamai profile is `128 * 1024 * 1024` bytes per execution with a population of one and hosted-default provenance; prove the explicit descriptor is visible inside `configure`. +- [x] Add default `Adapter::platform_metadata()` returning fully unknown metadata; run the registry test until green. +- [x] Add Axum's canonical unknown metadata and CLI override; run Axum tests until green. +- [x] Add Cloudflare's canonical platform constant and CLI override; run Cloudflare tests until green. +- [x] Add Fastly's canonical platform constant and CLI override; run Fastly tests until green. +- [x] Add Spin's canonical unknown metadata, CLI override, explicit runtime entrypoint, and Akamai-hosted descriptor; run Spin tests until green. + +### Task 3: Runtime propagation + +**Files:** +- Modify: `crates/edgezero-adapter-axum/src/dev_server.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/lib.rs` +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` +- Modify: `crates/edgezero-adapter-spin/src/lib.rs` +- Test: adapter contract/unit test modules adjacent to these entrypoints + +- [x] Add a failing Axum runtime test, require explicit platform metadata during construction, and run Axum tests until green. +- [x] Add a failing Cloudflare runtime test, require explicit platform metadata during construction, and run Cloudflare tests until green. +- [x] Add a failing Fastly runtime test, require explicit platform metadata during construction, and run Fastly tests until green. +- [x] Add failing generic and explicit-profile Spin runtime tests, require explicit platform metadata during construction, and run Spin tests until green. + +### Task 4: Documentation and drift checks + +**Files:** +- Modify: `docs/guide/capabilities.md` +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` +- Modify: `scripts/check_outbound_docs_contract.mjs` +- Modify: adapter/CLI integration tests as needed + +- [x] Add a platform-resource table that distinguishes exact bytes/units, separate stack, scope, population, host-ingress accounting, provenance, and unknown reasons. +- [x] Document that generic Spin is runtime-configured and that the Akamai hosted profile is explicit. +- [x] Add checker assertions for the table and a Rust integration test that derives every documented resource cell from the canonical constants. +- [x] Run the docs contract and focused registry tests. + +### Task 5: Full verification and delivery + +**Files:** +- Verify: workspace and example workspaces + +- [x] Run `cargo fmt --all -- --check`. +- [x] Run `cargo clippy --workspace --all-targets --all-features -- -D warnings`. +- [x] Run `cargo test --workspace --all-targets`. +- [x] Run `cargo check --workspace --all-targets --features "fastly cloudflare spin"`. +- [x] Run `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`. +- [x] Run `scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test --offline --locked -p edgezero-cli --test generated_project_builds`. +- [x] Run `cargo test --locked --workspace --all-targets` from `examples/app-demo`. +- [x] From `examples/app-demo`, run `cargo check --locked -p app-demo-adapter-cloudflare --target wasm32-unknown-unknown --no-default-features --features cloudflare`. +- [x] From `examples/app-demo`, run `cargo check --locked -p app-demo-adapter-fastly --target wasm32-wasip1 --no-default-features --features fastly`. +- [x] From `examples/app-demo`, run `cargo check --locked -p app-demo-adapter-spin --target wasm32-wasip2 --no-default-features --features spin`. +- [x] Run `bash scripts/check_adapter_feature_matrix.sh native` for Axum, Cloudflare, Fastly, and Spin. +- [x] Run `bash scripts/check_adapter_feature_matrix.sh cloudflare wasm32-unknown-unknown`, `bash scripts/check_adapter_feature_matrix.sh fastly wasm32-wasip1`, and `bash scripts/check_adapter_feature_matrix.sh spin wasm32-wasip2`. +- [x] From `docs`, run `npm run lint`, `npm run format`, and `npm run build`. +- [x] Review the diff for unrelated changes and forbidden downstream-specific wording. +- [x] Commit and push PR 275. diff --git a/docs/superpowers/plans/2026-09-28-platform-resource-metadata.md b/docs/superpowers/plans/2026-09-28-platform-resource-metadata.md new file mode 100644 index 000000000..a02d96870 --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-platform-resource-metadata.md @@ -0,0 +1,137 @@ +# Platform Resource Metadata Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish typed platform memory facts and provide fail-closed, scope-aware memory-envelope validation while clarifying outbound batch observation cutoffs. + +**Architecture:** `edgezero-core` owns typed known/unknown resource facts, canonical memory-envelope arithmetic, and validation outcomes. Runtime and CLI adapters delegate to one target constant; application admission remains application-owned. Documentation and executable drift tests keep provider claims, demo code, and generated templates aligned. + +**Tech Stack:** Rust 1.95, Cargo workspace tests, WASM adapter builds, Markdown/VitePress, Node.js contract scripts. + +--- + +### Task 1: Core resource facts + +**Files:** +- Modify: `crates/edgezero-core/src/platform.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Modify: `crates/edgezero-core/src/app.rs` +- Modify: `crates/edgezero-macros/tests/app_macro.rs` + +- [x] Add `platform_fact_distinguishes_known_and_unknown_metadata` and `memory_envelope_accessors_preserve_declared_shape` in `platform.rs`. Use the desired public constructors and accessors so the tests fail to compile while the API is absent. +- [x] Run `cargo test --offline --locked -p edgezero-core platform_fact_distinguishes_known_and_unknown_metadata` and confirm the missing-type failure. +- [x] Add `PlatformFact`, `PlatformResourceSource`, `PlatformUnknownReason`, `InboundRequestPopulationBound`, `HostIngressMemoryAccounting`, and scope-tagged `MemoryEnvelope` with const constructors/accessors. Hard-cut `MemoryCeiling::new` to remove provenance and move it into `PlatformFact::Known`. +- [x] Run the two focused tests and `cargo test --offline --locked -p edgezero-core platform` until green. +- [x] Update app and macro tests to construct three explicit platform facts; run `cargo test --offline --locked -p edgezero-core app::tests` and `cargo test --offline --locked -p edgezero-macros --test app_macro` until green. + +### Task 2: Fail-closed memory validation + +**Files:** +- Modify: `crates/edgezero-core/src/platform.rs` +- Modify: `crates/edgezero-core/src/lib.rs` + +- [x] Add failing tests named `validation_fits_per_execution_with_counted_host_memory`, `validation_fits_per_instance_with_host_memory_outside_ceiling`, `validation_reports_primary_or_stack_excess`, `validation_unknown_ceiling_is_indeterminate`, `validation_unknown_population_is_indeterminate`, `validation_unknown_host_accounting_is_indeterminate`, `validation_missing_published_stack_requirement_is_indeterminate`, `validation_known_minimum_excess_precedes_unknown_facts`, `validation_rejects_scope_mismatch`, `validation_rejects_primary_arithmetic_overflow`, `validation_rejects_unknown_population_lower_bound_overflow`, `validation_rejects_stack_shape_mismatch`, and `validation_rejects_non_unit_per_execution_population`. +- [x] Make zero population unrepresentable by requiring `NonZeroU32` in `InboundRequestPopulationBound::new`; test the constructor signature and round-trip a value of one rather than adding a runtime zero branch. +- [x] Run `cargo test --offline --locked -p edgezero-core platform::tests::validation_` and confirm failure because validation types and methods are absent. +- [x] Implement `MemoryEnvelopeValidation::{Fits, Exceeds, Indeterminate}`, typed indeterminate reasons, `MemoryEnvelopeValidationError`, and `PlatformMetadata::validate_memory_envelope`. +- [x] For unknown per-instance population, compute `fixed + per_request * 1` with checked arithmetic only as a lower-bound overflow proof; otherwise return `Indeterminate`. Check a separate stack allowance independently and require an explicit stack requirement before returning `Fits`. +- [x] Run `cargo test --offline --locked -p edgezero-core platform::tests::validation_` and `cargo test --offline --locked -p edgezero-core` until green. + +### Task 3: Canonical adapter metadata hard cut + +**Files:** +- Modify: `crates/edgezero-adapter/src/registry.rs` +- Modify: `crates/edgezero-adapter-axum/src/lib.rs` +- Modify: `crates/edgezero-adapter-axum/src/cli.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/lib.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/cli.rs` +- Modify: `crates/edgezero-adapter-fastly/src/lib.rs` +- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` +- Modify: `crates/edgezero-adapter-spin/src/lib.rs` +- Modify: `crates/edgezero-adapter-spin/src/cli.rs` + +- [x] Add `adapter_platform_metadata_default_is_unknown` in the registry; add `adapter_platform_metadata_matches_runtime_metadata` in Axum, Cloudflare, and Fastly; add `adapter_platform_metadata_distinguishes_generic_and_hosted_profiles` in Spin. Assert every fact and reason, including one request per execution for Fastly and the hosted Spin profile, and provider-unpublished Cloudflare population/framing facts. +- [x] Run `cargo test --offline --locked -p edgezero-adapter adapter_platform_metadata_default_is_unknown` and confirm it fails against the old trait. Run each adapter test with `scripts/run_test_nonzero.sh cargo test --offline --locked -p edgezero-adapter- --no-default-features --features cli --lib ` and confirm it fails against the retired memory-only adapter metadata method or the old constants. +- [x] Replace the memory-only adapter metadata method with `platform_metadata()` and no compatibility alias. Update each in-tree CLI adapter to return its runtime constant. +- [x] Hard-cut every adapter `PlatformMetadata::new` and `MemoryCeiling::new` caller to the three-fact model. +- [x] Run `cargo test --offline --locked -p edgezero-adapter`, then each adapter's platform metadata tests, until green. + +### Task 4: Batch observation cutoff contract + +**Files:** +- Modify: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-core/src/context.rs` +- Modify: `crates/edgezero-core/src/time.rs` +- Modify: `crates/edgezero-adapter-axum/src/outbound.rs` +- Modify: `crates/edgezero-adapter-axum/tests/contract.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound.rs` +- Modify: `crates/edgezero-adapter-cloudflare/tests/contract.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound.rs` +- Modify: `crates/edgezero-adapter-fastly/tests/contract.rs` +- Modify: `crates/edgezero-adapter-spin/src/outbound.rs` +- Modify: `crates/edgezero-adapter-spin/tests/contract.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs` +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` +- Modify: `docs/guide/proxying.md` +- Modify: `scripts/check_outbound_docs_contract.mjs` + +- [x] Run the existing early-observation-cutoff adapter contract as a passing characterization test; do not claim it is a TDD red test. +- [x] Add a failing docs/source contract named `observationCutoffContract` that requires the public Rustdoc warning and rejects stale public/example parameter names that use bare `cutoff`. +- [x] Run `node scripts/check_outbound_docs_contract.mjs` and confirm the new contract fails for missing wording. +- [x] Rename parameters and example variables to `observation_cutoff` across the exact files above. Keep `start_batch_until`, `send_all_until`, and `BudgetSource::BatchCutoff`; add no debug assertion. +- [x] Run the docs contract, core outbound tests, and all four focused adapter contract tests until green. + +### Task 5: Documentation, migration, demo, and generated-project drift + +**Files:** +- Create: `CHANGELOG.md` +- Modify: `docs/guide/adapters/overview.md` +- Modify: `docs/guide/capabilities.md` +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` +- Modify: `docs/superpowers/plans/2026-09-26-platform-memory-ceiling.md` +- Modify: `scripts/check_outbound_docs_contract.mjs` +- Modify: `scripts/check_outbound_legacy_api.sh` +- Modify: `crates/edgezero-cli/tests/platform_memory_docs.rs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `crates/edgezero-cli/src/templates/core/src/lib.rs.hbs` +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs` + +- [x] Rewrite `platform_memory_docs.rs` first to derive every documented memory, population, accounting, scope, and provenance cell from adapter constants. Run `cargo test --offline --locked -p edgezero-cli --test platform_memory_docs` and confirm it fails against the old table. +- [x] Extend the legacy checker to reject the retired memory provenance type, memory-only adapter metadata method, former one-argument platform-metadata constructor, former four-argument memory-ceiling constructor, compatibility aliases, and stale batch parameter/example wording. Explicitly allowlist only `CHANGELOG.md` and the focused platform-resource design, and add checker self-tests proving those forms still fail in every other active surface. Run it and confirm failure before migration. +- [x] Update the capability guide, normative outbound spec, completed platform-memory plan, demo, and generator template. State that unknown provider facts prevent complete validation and that the outbound six-connection limit is not inbound population evidence. +- [x] Add the exact old-to-new migration table from the focused design to `CHANGELOG.md`; make the adapter overview link to that table without repeating legacy signatures. +- [x] Run `cargo test --offline --locked -p edgezero-cli --test platform_memory_docs`, generator unit tests, `bash scripts/check_outbound_legacy_api.sh`, and `node scripts/check_outbound_docs_contract.mjs` until green. +- [x] Run the Task 5 stale-API scan for the retired provenance type, memory-only trait method, optional-return accessor, and hidden clock-pairing constructor; confirm no live stale API remains outside the two allowlisted migration records. + +### Task 6: Review, verification, and delivery + +**Files:** +- Review: all modified files + +- [x] Run `cargo fmt --all -- --check`. +- [x] Run `cargo clippy --offline --locked --workspace --all-targets --all-features -- -D warnings`. +- [x] Run `cargo test --offline --locked --workspace --all-targets`. +- [x] Run `cargo check --offline --locked --workspace --all-targets --features "fastly cloudflare spin"`. +- [x] Run `cargo check --offline --locked -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`. +- [x] Run `cargo test --offline --locked -p edgezero-adapter-fastly --all-targets --features cli`, `cargo clippy --offline --locked -p edgezero-adapter-fastly --features cli --all-targets -- -D warnings`, and `cargo clippy --offline --locked -p edgezero-adapter-fastly --no-default-features --lib -- -D warnings`. +- [x] Run `cargo run --offline --locked -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates` and `cargo test --offline --locked -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config`. +- [x] Run `bash scripts/check_adapter_feature_matrix.sh axum native`, then the same command for `cloudflare`, `fastly`, and `spin` with `native`. +- [x] Run `bash scripts/check_adapter_feature_matrix.sh cloudflare wasm32-unknown-unknown`, `bash scripts/check_adapter_feature_matrix.sh fastly wasm32-wasip1`, and `bash scripts/check_adapter_feature_matrix.sh spin wasm32-wasip2`. +- [x] Run `scripts/run_test_nonzero.sh adapter_capability_matrix_matches_contracts cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features cli --lib adapter_capability_matrix_matches_contracts`, then the same command for `cloudflare`, `fastly`, and `spin`. +- [x] Run `scripts/run_test_nonzero.sh batch_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test contract`, the corresponding Cloudflare command with `test-utils`, the Fastly `batch_preflight_rejects_streamed_slots_without_poisoning_siblings` command with `test-utils`, and the Spin `batch_preflight_precedence_and_indices` command with `test-utils`. +- [x] Run `cargo clippy --offline --locked -p edgezero-adapter-axum --no-default-features --features axum --all-targets -- -D warnings` and the same command with `--features axum,test-utils`. +- [x] Run `cargo clippy --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare --target wasm32-unknown-unknown --all-targets -- -D warnings` and the same command with `--features cloudflare,test-utils`. +- [x] Run `cargo clippy --offline --locked -p edgezero-adapter-fastly --no-default-features --features fastly --target wasm32-wasip1 --all-targets -- -D warnings`, then repeat with `fastly,cli`, `fastly,test-utils`, and `fastly,cli,test-utils`. +- [x] Run `cargo clippy --offline --locked -p edgezero-adapter-spin --no-default-features --features spin --target wasm32-wasip2 --all-targets -- -D warnings` and the same command with `--features spin,test-utils`. +- [x] Run `CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner scripts/run_test_nonzero.sh dispatch_runs_router_and_returns_response cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --test contract` and `CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner scripts/run_test_nonzero.sh streamed_upload_eof_yields_before_final_precedence cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features cloudflare,test-utils --target wasm32-unknown-unknown --lib`. +- [x] Run `CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" scripts/run_test_nonzero.sh dispatch_runs_router_and_returns_response cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features fastly,test-utils --target wasm32-wasip1 --test contract` and the same command with sentinel `closed_lifecycle_orders_hooks_and_returns_state_after_delivery`. +- [x] From `crates/edgezero-adapter-fastly`, run `CARGO_TARGET_WASM32_WASIP1_RUNNER="viceroy run" ../../scripts/run_test_nonzero.sh backend_creation_error_table_is_exhaustive cargo test --offline --locked --no-default-features --features fastly,test-utils --lib`. +- [x] Run `CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run -W component-model-async=y -S p3=y -S http=y" scripts/run_test_nonzero.sh router_dispatches_get_and_returns_response cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features spin,test-utils --target wasm32-wasip2 --test contract` and `CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run -W component-model-async=y -S p3=y -S http=y" scripts/run_test_nonzero.sh spin_error_code_table_is_exhaustive cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features spin,test-utils --target wasm32-wasip2 --test sdk_resources`. +- [x] Run `scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test --offline --locked -p edgezero-cli --test generated_project_builds`. +- [x] From `examples/app-demo`, run `cargo fmt --all -- --check`, `cargo clippy --offline --locked --workspace --all-targets --all-features -- -D warnings`, and `cargo test --offline --locked --workspace --all-targets`. +- [x] From `examples/app-demo`, run `cargo check --offline --locked -p app-demo-adapter-cloudflare --target wasm32-unknown-unknown --no-default-features --features cloudflare`, `cargo check --offline --locked -p app-demo-adapter-fastly --target wasm32-wasip1 --no-default-features --features fastly`, and `cargo check --offline --locked -p app-demo-adapter-spin --target wasm32-wasip2 --no-default-features --features spin`. +- [x] Run `RUSTDOCFLAGS="-D warnings" cargo doc --offline --locked --workspace --all-features --no-deps`. +- [x] From `docs`, run `npm ci`, `npm run lint`, `npm run format`, and `npm run build`. +- [x] Run `bash scripts/check_no_placeholder_pins.sh`, `bash scripts/check_no_legacy_typed_reads.sh`, `bash scripts/check_outbound_legacy_api.sh`, `node scripts/check_outbound_docs_contract.mjs`, `node scripts/check_brotli_dependency_contract.mjs`, and `node scripts/check_serde_json_map_contract.mjs`. +- [x] Review `git diff --check`, the full diff, and a case-insensitive prohibited consumer-specific terminology scan across `CHANGELOG.md`, `crates`, `examples`, `docs`, and `scripts` for unrelated changes, stale APIs, and prohibited wording. +- [x] Commit with a consumer-neutral message, push PR 275, and wait for all hosted checks. diff --git a/docs/superpowers/plans/2026-09-29-outbound-content-encoding.md b/docs/superpowers/plans/2026-09-29-outbound-content-encoding.md new file mode 100644 index 000000000..03f6c6e54 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-outbound-content-encoding.md @@ -0,0 +1,108 @@ +# Outbound Content-Encoding Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Decode standards-compliant HTTP `deflate` responses and expose typed reasons for every raw content-encoding passthrough outcome. + +**Architecture:** Keep classification and codec policy in `edgezero-core`; adapters continue to own native transport, deadline, and cancellation wrappers. Hard-migrate the public classifier to an owned typed result, generalize the existing decoder-memory control across Brotli and flate-family decoders, and preserve raw headers only for typed passthrough outcomes. + +**Tech Stack:** Rust 1.95, `http`, `async-compression`, `compression-codecs`, `flate2`/`miniz_oxide`, futures streams, four EdgeZero runtime adapters. + +--- + +### Task 1: Freeze the classifier and error contracts + +**Files:** +- Modify: `crates/edgezero-core/src/compression.rs` +- Modify: `crates/edgezero-core/src/error.rs` +- Modify: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-core/src/response_egress_framing.rs` +- Modify: `crates/edgezero-core/src/lib.rs` + +- [x] Add failing classifier tests for bare `deflate`, valid unknown tokens, repeated fields, valid comma lists, invalid list elements, parameters, empty/OWS-only values, non-UTF-8 bytes, and case folding. +- [x] Run `cargo test -p edgezero-core compression::tests::content_encoding_classifier_covers_every_visible_shape`; verify compilation fails because `Deflate` and `PassthroughReason` do not exist. +- [x] Hard-cut `ContentEncoding` to add `Deflate` and `Passthrough(PassthroughReason)`; validate RFC token bytes and preserve lowercase unknown tokens. Borrow the now-non-`Copy` classification in `enforce_payload_content_length` and classify `Deflate` as compressed wire length. +- [x] Add failing `BadGatewayDecodeReason::Deflate` taxonomy/category tests, run them red, then add the variant and stable `bad_gateway_decode_deflate` egress category. +- [x] Re-export `PassthroughReason` and run the focused classifier/taxonomy tests green. +- [x] Run the full core compression suite after Task 2 supplies the new decoder API. + +### Task 2: Add audited zlib-wrapped deflate decoding + +**Files:** +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Modify: `examples/app-demo/Cargo.lock` +- Modify: `crates/edgezero-core/Cargo.toml` +- Modify: `crates/edgezero-core/src/compression.rs` +- Modify: `crates/edgezero-core/src/outbound.rs` +- Modify: `crates/edgezero-core/src/lib.rs` +- Create: `docs/audits/2026-09-29-flate-decoder-memory-accounting.md` +- Create: `scripts/flate_dependency_contract.json` +- Create: `scripts/check_flate_dependency_contract.mjs` +- Modify: `.github/workflows/test.yml` +- Modify: `scripts/run_tests.sh` + +- [x] Add failing stream tests for zlib-wrapped round-trip, raw RFC 1951 rejection, invalid/truncated input, trailing bytes, typed late source-error preservation, source release, and gzip/deflate decoder-memory refusal before body polling. +- [x] Run the focused deflate test and verify compilation fails because the shared decoder, fixed charge, and generalized gzip signature do not exist. +- [x] Enable the `zlib` codecs, exact-pin the audited flate dependency graph, and update both lockfiles. +- [x] Derive and document a conservative fixed flate decoder charge from pinned `async-compression`, `compression-codecs`, `flate2`, and `miniz_oxide`; add a compiled regression covering `InflateState`, codec wrappers, and the EdgeZero output buffer. +- [x] Hard-rename `DEFAULT_MAX_BROTLI_DECODER_BYTES`, the request field/parts field, and builder to `DEFAULT_MAX_DECODER_BYTES` / `max_decoder_bytes`; enforce it before constructing gzip/deflate decoders and against the existing Brotli charge. +- [x] Implement `decode_deflate_stream` with zlib framing, terminal resource release, read-ahead/trailing and concatenated-stream rejection, native-EOF validation, and typed `Decode(Deflate)` failures. Add a pending-after-codec-EOF test proving the decoder does not report terminal success early. +- [x] Add a separate flate dependency contract with exact package/version/source/checksum coverage and direct-normal-dependency assertions for both the root and excluded demo workspaces; wire it after both locked fetches in CI and the local test runner. +- [x] Run `cargo test -p edgezero-core compression`, `cargo test -p edgezero-core outbound::tests`, and `node scripts/check_flate_dependency_contract.mjs`; expect all to pass. + +### Task 3: Integrate every adapter + +**Files:** +- Modify: `crates/edgezero-adapter-axum/src/outbound.rs` +- Modify: `crates/edgezero-adapter-cloudflare/src/outbound.rs` +- Modify: `crates/edgezero-adapter-fastly/src/outbound.rs` +- Modify: `crates/edgezero-adapter-spin/src/outbound.rs` +- Modify: `crates/edgezero-adapter-axum/tests/contract.rs` +- Modify: in-module conversion tests in the Cloudflare, Fastly, and Spin outbound modules + +- [x] Add adapter coverage for zlib-wrapped deflate decoding, `content-encoding`/`content-length` stripping, decoded-cap enforcement, and typed raw-preservation classification. Shared core tests cover codec edge cases; Axum covers the native conversion path, and each adapter compiles against the same branch. +- [x] Update each adapter to consume the borrowed non-`Copy` classification, enforce the generalized decoder cap, and route `Deflate` through the shared decoder. +- [x] Run the workspace adapter test suite and feature compilation gates; all existing adapter tests pass. + +### Task 4: Migrate generated and example applications + +**Files:** +- Modify: `crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs` +- Modify: `crates/edgezero-cli/src/generator.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/handlers.rs` +- Modify: associated generator and demo tests + +- [x] Add generator/demo assertions for `max_decoder_bytes` and the removal of `max_brotli_decoder_bytes`. +- [x] Hard-migrate templates and demo code to the generalized control. +- [x] Run the workspace CLI/demo tests and generated-project compilation gates. + +### Task 5: Align normative documentation + +**Files:** +- Modify: `docs/superpowers/specs/2026-05-21-outbound-http-design.md` +- Modify: `docs/superpowers/plans/2026-09-06-outbound-http-phase2-body-response-limits.md` +- Modify: `docs/guide/capabilities.md` +- Modify: `docs/guide/architecture.md` +- Modify: `docs/guide/adapters/overview.md` +- Modify: `docs/guide/proxying.md` +- Modify: `docs/guide/streaming.md` +- Modify: `CHANGELOG.md` +- Modify: `scripts/check_outbound_docs_contract.mjs` +- Modify: `scripts/check_outbound_legacy_api.sh` + +- [x] Add docs-contract assertions for the new API/table/error/limit names and removal of stale `deflate`-passthrough wording. +- [x] Update the public API, portable encoding table, pipeline, limits, error taxonomy, test matrix, dependency evidence, public guides, and phase plans to include typed passthrough and zlib-wrapped deflate. +- [x] Record the hard migration and reject the removed bare variant/builder in legacy checks. +- [x] Run the docs and legacy contract scripts. + +### Task 6: Full verification and delivery + +- [x] Run `cargo fmt --all -- --check`. +- [x] Run `cargo test --workspace --all-targets`. +- [x] Run `cargo clippy --workspace --all-targets --all-features -- -D warnings`. +- [x] Run `cargo check --workspace --all-targets --features "fastly cloudflare spin"`. +- [x] Run the exact Cloudflare, Fastly, and Spin WASM clippy/check commands from `.github/workflows/format.yml`. +- [x] Run the excluded demo workspace tests and checks. +- [x] Review `git diff --check`, the complete diff, and generated documentation consistency. +- [ ] Commit, push PR 275, and verify hosted checks. diff --git a/docs/superpowers/plans/2026-10-05-logging-ownership-boot-diagnostics.md b/docs/superpowers/plans/2026-10-05-logging-ownership-boot-diagnostics.md new file mode 100644 index 000000000..2b0dd7d5c --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-logging-ownership-boot-diagnostics.md @@ -0,0 +1,59 @@ +# Logging Ownership and Boot Diagnostics Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans to implement this approved plan with test-first changes and verification checkpoints. + +**Goal:** Share log-level resolution and preserve boot warnings without raising ordinary logging verbosity. + +**Architecture:** Use the existing `log` facade, one pure core resolver, and a fixed boot target. Reuse backend target filtering, initialize logging before application configuration, and defer configuration-read diagnostics as bounded state. + +**Tech Stack:** Rust, log, fern/log-fastly, simple_logger, Cargo, WASM contract tests. + +--- + +## Task 1: Shared Policy and Ownership + +Files: `crates/edgezero-core/src/{logging,lib,app}.rs`. + +- [x] Add resolver and boot-target tests; run `cargo test -p edgezero-core logging` and observe the missing-feature failure. +- [x] Implement `resolve_logging_level(&EnvConfig) -> LevelFilter`, `BOOT_LOG_TARGET`, and `BOOT_LOG_LEVEL`; document application filter/initialization ownership. +- [x] Rerun core tests and verify the resolver leaves the global maximum unchanged. + +## Task 2: Runtime Filtering and Startup Order + +Files: Fastly `src/{logger,lib}.rs`, Axum `src/dev_server.rs`, Cloudflare/Spin `src/lib.rs`, adapter contract tests. + +- [x] Add failing filter/order tests before changing runtime initialization. +- [x] Share level parsing; preserve boot warnings at every Fastly filtering layer and in Axum target filtering. +- [x] Initialize logging before application configuration and defer early setup warnings with bounded state. Hard-cut Fastly resolution to `FastlyRuntimeConfig` with explicit one-shot emission. +- [x] Preserve application-owned logging state and explicitly retain Cloudflare/Spin no-op initialization. +- [x] Run affected adapter tests and WASM compilation/lint gates. + +## Task 3: Demo, Generator, and Documentation + +Files: CLI `src/{main,demo_server,adapter}.rs`, CLI tests/templates/README, adapter/configuration guides, `CHANGELOG.md`. + +- [x] Add a failing bundled-demo logger-selection regression; avoid preinstalling the CLI backend for `demo`. Retain the capability gate using Axum's reusable logger-first preflight runner. +- [x] Update ownership/filter examples, no-op platform limitations, and boot `Warn` versus ordinary `Off` semantics. +- [x] Keep generated entrypoints free of duplicated logging implementation. +- [x] Run CLI/generator and demo checks. The bundled logging contract, excluded demo workspace tests, and full generated native/WASM workspace check pass. + +## Task 4: Verification and Delivery + +- [x] Review the complete diff with a read-only reviewer and resolve findings. Fastly and Axum/CLI reviewers found no production defect; harden SDK capture, public one-shot emission, conflicting-logger startup, and actual demo boot-warning assertions. Keep provider startup guarantees source-reviewed rather than claiming deployed evidence. +- [x] Run workspace tests, formatting, strict workspace clippy, feature checks, three WASM targets, docs lint/format, and generated/demo checks. + +Delivery record: [PR #275](https://github.com/stackpop/edgezero/pull/275) commit history and hosted check results. + +## Verification Evidence + +- Required native workspace test gate: pass, including 713 core tests and the new startup probes. +- Strict all-feature workspace clippy, Fastly CLI-only/no-feature clippy, and feature compilation: pass. +- Cloudflare, Fastly, and Spin WASM clippy with runtime/test-utils features: pass. +- Excluded app-demo tests, strict clippy, formatting, and all three WASM builds: pass. +- Full generated-workspace native/strict-clippy/core-test/WASM check: pass. +- Bundled demo CI sentinel: pass; runtime `Off` retains a real boot warning and terminal stderr. +- Strict public Rustdoc, docs lint/format/build, outbound docs, codec pins, and JSON-map audits: pass. + +Native `cargo test --all-features` is not the repository's test gate: it cannot link Fastly +runtime host imports on macOS. Runtime features are compile/lint checked and exercised by +provider-hosted CI. This change does not claim new deployed logger evidence. diff --git a/docs/superpowers/specs/2026-05-21-outbound-http-design.md b/docs/superpowers/specs/2026-05-21-outbound-http-design.md new file mode 100644 index 000000000..a15cc1e4f --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-outbound-http-design.md @@ -0,0 +1,7260 @@ +# EdgeZero Outbound HTTP — Design Spec + +> **Status:** Normative design extended with the completion-driven batch, cache-bypass, authority-override, and closed Fastly lifecycle contracts. The earlier Phases 1a-7, review hardening, and owned response-egress integration are implemented on PR 275; the 2026-09-15 hard cut is tracked by the consumer-alignment implementation plan. Response-egress capabilities remain `BestEffort` pending deployed proof of end-client completion and finite provider teardown. · **Date:** 2026-09-15 +> **Branch:** `docs/outbound-http-spec` · **Audience:** EdgeZero maintainers +> **Driving pattern:** fan-out HTTP workloads — N outbound requests under a shared wall-clock deadline, terminal results observed in completion order while preserving input indices. The spec is written against this pattern as a portable substrate; it deliberately does not name a specific consumer. +> **Historical target baseline:** [`stackpop/edgezero` PR #269](https://github.com/stackpop/edgezero/pull/269) (`feature/extensible-cli`, rev `b4c80e9`) — **now merged into `main`** (squash-merged as `e483723`). Relevant baseline changes were the `edgezero_cli::adapter::execute(..)` shell-or-registry dispatcher, expanded runtime `AdapterAction` variants, the then-current Spin SDK 6 / wasip2 integration, the contributor-only `demo` command replacing `dev`, and the app-demo integration crate. The current implementation pins Spin SDK 7.0.0. Non-outbound store/config lifecycle changes remain outside this design. +> **Current checkout (post-#269 and staging-deploy work):** the CLI surface includes `Command::{Build, Serve, Deploy, Auth, Provision, Config, Demo, New}`; `Action` / `AdapterAction` additionally include `DeployStaging`, `EmitVersion`, `Healthcheck`, and `Rollback`; and adapter dispatch has both `execute(..)` and `execute_capture(..)` entry points. `dev` is gone. This outbound spec gates construction/deployment of the current runtime: `build` / `serve` / `deploy` / `deploy --staging` through both dispatch entry points, plus `demo` before Axum starts. Operational auth/version/health/rollback actions and provisioning/config/store lifecycle policy are exempt or belong to their owning specifications (§3.5.3). +> **Where rebase claims live (authoritative surfaces):** §3.5.3 build-enforcement, §3.5.2 `Adapter` trait shape, §5.4 capability tests, and the §7 `edgezero-cli` migration bullet. The §3.5.3 + §7 active text is authoritative. + +## 1. Overview + +### 1.1 Goal + +Make EdgeZero a production-safe substrate for **outbound HTTP fan-out**: an app must be +able to issue many independent target requests, use concurrent complete exchanges where the +platform supports them, enforce per-request and whole-fan-out batch deadlines, keep memory +predictable, and run the *same handler source* unchanged on Axum, Cloudflare Workers, Fastly +Compute, and Spin. + +"Predictable memory" here means: a documented, bounded cost per buffered outbound request +and response, plus an explicit batch-level memory model the app controls (§3.4.4). +It does **not** mean EdgeZero imposes a global allocation ceiling. + +### 1.2 Context + +At the historical implementation baseline, applications proxied a single outbound request +through `ProxyClient` / `ProxyHandle`. The gaps this design set out to close were: + +- A first-class, **independently constructed** outbound request type. +- **Attempt-before-harvest fan-out.** The baseline Fastly client called + `pending_request.wait()` inside a single `send()`, so any `join_all` of `send()` calls ran + strictly serially. The implemented Fastly contract dispatches every eligible request before + harvest without promising simultaneous transport overlap. +- A **portable deadline** primitive. +- **Bounded buffering** helpers with clean error mapping. +- A way for an app to **declare required capabilities** and fail the build early. + +### 1.3 Non-goals + +- No consumer-specific target logic in EdgeZero. +- EdgeZero does not own application target policy or allowlists. It exposes + `OutboundRequest::uri()` so apps enforce their own policy; it never blocks a request itself. +- EdgeZero does not add an application loop counter, `Via` policy, or self-reference rejection. + Those depend on deployment identity and trust boundaries and remain application policy; the + reference proxy example must not imply that `PROXY_HEADER` is a loop-prevention mechanism. +- No new direct dependency on `tokio`, `reqwest`, `fastly`, `worker`, or `spin-sdk` in + application/library crates or in `edgezero-core`. Those stay inside adapter crates. +- No general-purpose "timeout any future" combinator in this spec — see §3.3.5. +- Inbound admission, request-start stamping, request-body read deadlines, and raw request + framing rejection are owned by the + [inbound-body design](2026-08-22-inbound-body-design.md), not by outbound HTTP. Outbound + implementation may start independently; it must not claim those ingress guarantees. +- The lifetime after a core `Response` reaches a platform adapter is a separate + downstream-response concern owned by the + [response-egress design](2026-09-08-response-egress-design.md) and its + [implementation plan](../plans/2026-09-08-response-egress-implementation.md). The adapters now + own that lifecycle through their strongest host boundary, but it remains distinct from the + outbound request deadline and is conservatively classified `BestEffort`. §3.3.3 defines the + precise boundary of the outbound request deadline. + +### 1.4 Decisions locked before / during review + +- **No backward compatibility.** `ProxyClient` is renamed and reshaped in place; + `app-demo`, scaffolding templates, and docs are migrated. No deprecated + aliases. +- **One adapter-owned buffered batch engine.** `start_batch_until` is the only + adapter-facing fan-out primitive for buffered request bodies and buffered responses. + `OutboundBatch::next` exposes indexed terminal results in observed completion order; + `HttpClient::send_all_until` is an ordered collector over that same driver, not a second + orchestration path. The hard cut removes `send_all`. Input validation and result identity + are uniform, while cross-slot timing and teardown quality remain capability-qualified. + Axum, Cloudflare, and Spin drive complete exchanges concurrently. Fastly issues sequential + guest dispatches before selecting pending requests and retains its documented serial body + drain and finite-teardown limitations. Streamed-response fan-out remains non-portable; + applications use single `send` calls for streamed responses. +- **Transport policy is typed.** `OutboundRequest` carries a portable cache policy and an + optional validated HTTP authority override. The URI always owns the connection target, + SNI, and certificate identity. An authority override only changes the outgoing HTTP + authority. An adapter unable to preserve that separation rejects the request before body + polling or provider dispatch. +- **Fastly custom entrypoints remain closed over egress.** The advanced Fastly runner allows + bounded request-preparation and response-finalization hooks while EdgeZero retains ingress, + framing, streaming, deadline, transmission, and completion ownership. The retired + request-extension-only runner is not retained as a compatibility alias. +- **Unified body.** Outbound request and response bodies use the existing core `Body` + type and may be **buffered (default)** or **streamed (opt-in)**. Streaming + proxy-forwarding is preserved — it is not dropped (review finding / residual risk). +- **Deliverables:** this design plus phased implementation plans. Phases 1a-7 and review + hardening were subsequently implemented on PR 275; the status block above names the + remaining response-egress certification boundary. + +## 2. Historical implementation baseline + +This table records the code state from which the outbound implementation began. It is retained +for design provenance and does not describe the current PR head; the status block above is the +current implementation summary. + +| Concern | Baseline | File | +| --- | --- | --- | +| Outbound trait | `ProxyClient::send(ProxyRequest) -> Result` | `crates/edgezero-core/src/proxy.rs:16` | +| Handle | `ProxyHandle` (`Arc`), `RequestContext::proxy_handle()` | `proxy.rs:21`, `context.rs:97` | +| Request type | `ProxyRequest::new(method, uri)`; `ProxyRequest::from_request` (streaming) | `proxy.rs:138`, `proxy.rs:100` | +| Body | `Body { Once(Bytes), Stream(..) }`; `Body::into_bytes_bounded(max)` exists | `body.rs:14`, `body.rs:76` | +| Errors | Phase 1a has typed 502/504 errors; response-limit 502 remains Phase 2. `EdgeError` is `#[non_exhaustive]` | `crates/edgezero-core/src/error.rs` | +| Deadlines | Phase 1a has `Deadline` and constants; `DispatchBudget` / `dispatch_budget` remain Phase 1b | `crates/edgezero-core/src/time.rs` | +| Fastly send | `send_async_streaming()` then `pending_request.wait()` — serializes | `crates/edgezero-adapter-fastly/src/proxy.rs:30` | +| Fastly backend name | host with only `.`/`:` sanitized | `crates/edgezero-adapter-fastly/src/proxy.rs:110` | +| Manifest | no capability declaration or outbound host plumbing | `crates/edgezero-core/src/manifest.rs` | +| Adapter trait | `execute` / `name` plus existing non-outbound lifecycle hooks; no capability metadata | `crates/edgezero-adapter/src/registry.rs` | +| Contract tests | exist for Cloudflare/Fastly/Spin; **Axum has none** | `crates/edgezero-adapter-*/tests/contract.rs` | +| Scaffold templates | emit proxy code | `crates/edgezero-cli/.../handlers.rs.hbs`, `spin.toml.hbs:13` | +| Public docs | document `ProxyService`/`ProxyRequest` | `docs/guide/proxying.md`, `docs/guide/handlers.md`, `docs/guide/architecture.md`, `docs/guide/what-is-edgezero.md`, `docs/guide/adapters/*` | + +## 3. Design + +> **⚠️ Code blocks in this spec are ILLUSTRATIVE, and many predate the strict lint +> gate.** The workspace denies `clippy::restriction` and warns `clippy::pedantic` under +> `-D warnings` (root `Cargo.toml`). Any snippet copied into an implementation **must** +> be brought to that gate before it will compile in CI. The recurring offenders, with +> their required forms (the Phase 1a plan carries the full list and a verified-green +> example): +> - **`missing_inline_in_public_items`** → every public fn needs `#[inline]`. +> - **`min_ident_chars`** → no single-char idents (`d`→`duration`, `e`→`err`, `m`→`manifest`). +> - **`arithmetic_side_effects`** → no bare `+`/`-`/`*`/`/`; use `checked_*` / `saturating_*` / `try_from`. +> - **`as_conversions`** → no `as` casts; use `From`/`TryFrom`/`u64::from`. +> - **`expect_used` / `unwrap_used`** → forbidden in production (allowed in `#[cfg(test)]`); use `?`/`ok_or`. +> - **`arbitrary_source_item_ordering`** → module items, struct fields, enum variants, and impl fns alphabetical (or a documented `#[expect(..)]`). +> - **`duration_suboptimal_units`** → `Duration::from_hours(168)`, `from_mins(1)` — not `from_secs(7*24*60*60)` / `from_secs(60)`. +> +> Where a snippet below still shows a bare-`d` closure param, `x as u64`, or +> `a + b`, read it as shorthand for the lint-clean form — do not copy it verbatim. + +### 3.1 Outbound HTTP client abstraction + +`crates/edgezero-core/src/proxy.rs` is renamed to `crates/edgezero-core/src/outbound.rs`. +Bodies use the **existing core `Body`** type (`Once(Bytes)` | `Stream(..)`), so a request +or response may be buffered or streamed. Buffered is the default; +streaming is an explicit opt-in that preserves proxy-forwarding. + +#### 3.1.1 Adapter-facing trait — two required methods + +```rust +// crates/edgezero-core/src/outbound.rs + +/// One terminal result produced by a buffered batch slot. +#[derive(Debug)] +#[non_exhaustive] +pub struct OutboundSlotResult { + pub elapsed: Duration, + pub outcome: Result, +} + +/// One terminal batch result paired with its original input index. +#[derive(Debug)] +#[non_exhaustive] +pub struct OutboundBatchItem { + pub index: usize, + pub result: OutboundSlotResult, +} + +/// Ordered collection of the batch state at completion or observation cutoff. +#[derive(Debug)] +#[non_exhaustive] +pub struct OutboundBatchResults { + /// Index-aligned with the input. `None` is valid only with `Cutoff` termination. + pub slots: Vec>, + pub termination: OutboundBatchTermination, +} + +/// A batch-level driver failure and the terminal slots collected before it occurred. +#[derive(Debug, Error)] +#[non_exhaustive] +pub struct OutboundBatchFailure { + pub error: EdgeError, + /// Index-aligned with the input; unresolved slots remain `None`. + pub slots: Vec>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum OutboundBatchTermination { + /// Every input slot produced exactly one terminal result and the driver reached clean EOF. + Completed, + /// The observation cutoff won while one or more slots remained unresolved. + Cutoff, +} + +/// One observable step from an outbound batch. +/// +/// After the final `Item`, callers poll once more to receive `Finished(Completed)`; +/// completion is not inferred from the number of items observed. +/// The final poll waits for driver EOF and still validates trailing events or finalization failures. +#[derive(Debug)] +#[non_exhaustive] +pub enum OutboundBatchNext { + Finished(OutboundBatchTermination), + Item(OutboundBatchItem), +} + +/// Public adapter-to-core batch driver protocol. +#[derive(Debug)] +#[non_exhaustive] +pub enum OutboundBatchDriverEvent { + Cutoff, + Failed(EdgeError), + Item(OutboundBatchItem), +} + +/// Adapter-owned completion driver. The transport and pending handles remain opaque. +pub struct OutboundBatch { /* private */ } + +impl OutboundBatch { + /// Stops observation, applies strongest-available teardown, and returns unresolved indices. + /// The caller still owes every returned slot an outcome because `EdgeZero` observed no + /// terminal result for those indices. + pub fn cancel(self) -> Vec; + + /// Collects terminal items into the original input order until completion or observation cutoff. + pub async fn collect(self) -> Result; + + /// Builds an already-cut-off batch without polling adapter transport work. + pub fn cutoff(slot_count: usize) -> Self; + + /// Builds a batch from an adapter-owned event stream. + pub fn from_driver(slot_count: usize, stream: StreamValue) -> Self + where + StreamValue: Stream + 'static; + + /// Shared future-based driver. Inputs retain original indices and terminal samples. + /// Drain immediately ready futures before delivering the first item; introduce no timer. + pub fn from_futures( + slot_count: usize, + batch_started_at: MonotonicInstant, + observation_cutoff: Deadline, + completed: Vec<(usize, MonotonicInstant, Result)>, + pending_futures: Pending, + ) -> Self + where + F: Future)> + + 'static, + Pending: IntoIterator; + + /// Returns the next observed terminal item or the explicit termination reason. + /// Cancelling this future does not consume or lose a ready item. + pub async fn next(&mut self) -> Result; +} + +#[async_trait(?Send)] +pub trait OutboundHttpClient: Send + Sync { + /// Send a single request. Accepts streamed request bodies — this is the API + /// for streaming proxy-forwarding (one inbound → one outbound). + /// + /// **`Buffered` mode:** `Ok(resp)` means the full exchange completed — + /// headers AND the response body buffered within the deadline and the + /// final collection cap, plus any configured encoded/decoded/resource caps. + /// `Err(_)` is returned for transport failure + /// (DNS/TLS/connect), deadline expiry, or over-cap. + /// + /// **`Streamed` mode:** `Ok(resp)` means headers completed. Body-phase + /// failures surface later, when the caller consumes `resp.into_body()`: + /// - **Read errors / decompression failures / deadline expiry** during + /// chunk reads come from the deadline-aware stream wrapper + /// as `Err(EdgeError::..)` chunks. + /// - A configured **decoded-output cap** wraps effective identity and EdgeZero-decoded + /// gzip/deflate/Brotli output in both response modes, but never raw passthrough. It can yield + /// `ResponseLimitReason::DecodedBody` from the returned stream. A later bounded helper + /// (`OutboundResponse::into_bytes_bounded(max)`, `into_bytes_bounded_until`, or + /// `json_bounded[_until]`) owns a distinct final collection cap and reports + /// `BufferedBody`. Independently configured response-resource controls still apply: + /// header limits can fail `send` before it returns; encoded-byte, Brotli-window, and + /// decoder-heap limits can yield a typed error from the returned stream; and + /// `max_chunk_bytes` shapes emitted items (§3.4.5). + /// - Raw passthrough bypasses `max_decoded_response_bytes` on every adapter because + /// EdgeZero did not produce decoded output. Every standard adapter preserves the resulting + /// `Body::Stream` lazily through its owned response-egress coordinator; there is no implicit + /// downstream whole-body collection cap. The `lazy-streamed-response-passthrough` capability + /// is `Native` on Axum and Cloudflare. It remains `BestEffort` on Fastly and Spin pending + /// end-to-end target evidence, not because either buffers the complete stream. + /// If the caller has *already started writing the downstream response + /// headers* (e.g. a proxy-forward via `into_response` that the platform + /// coordinator has begun sending), HTTP no longer allows a status change. + /// The adapter response coordinator then requests the strongest platform-supported + /// downstream-body abort and logs only a fixed typed category for the originating + /// `EdgeError`; provider/application messages and request data are never interpolated. + /// Clients observe an + /// incomplete response rather than a synthetic 502/504. Exact wire behavior (for example, + /// a connection close or stream reset) is platform/protocol behavior and must be + /// characterized, not universally asserted. The separate response-egress contract in + /// §1.3 owns write deadlines and exactly-once completion. + async fn send(&self, req: OutboundRequest) -> Result; + + /// Attempt every eligible request before waiting for transport completion. Axum, + /// Cloudflare, and Spin drive complete exchanges concurrently. Fastly performs sequential + /// guest dispatch before selecting pending requests, so simultaneous transport overlap is not + /// guaranteed. + /// + /// Each yielded item carries the index of its originating request and that slot's terminal + /// elapsed time. `start_batch_until` + /// captures one `batch_started_at` monotonic instant as its first operation. Every slot, + /// including a slot rejected during preflight, measures from that same instant through + /// the instant its own outcome becomes terminal. Elapsed time therefore includes + /// EdgeZero validation, adapter preparation, provider queueing, upload, response headers, + /// Buffered body drain, and any platform-imposed delayed observation. It is not a pure + /// wire RTT. Each terminal slot samples the clock independently; the batch's eventual + /// final collection time is never copied into every slot. Monotonic subtraction is checked. A + /// backwards injected clock replaces that slot's outcome with an internal invariant error + /// and uses `elapsed = Duration::ZERO` as a fallback. Zero is also a valid elapsed value + /// when start and terminal samples are equal at the clock's resolution; callers distinguish + /// the clock-fault case by its internal outcome, not by elapsed alone. + /// An `Ok` outcome is contractually terminal and on time. `elapsed` is metadata, not an + /// independent lateness detector: callers must not sample their clock after the whole batch + /// returns to reinterpret an earlier success. The API intentionally exposes neither an + /// absolute terminal instant nor the internal `batch_started_at` instant. + /// + /// **Input handling is isolated per slot**: a `bad_request` for + /// one preflight failure never changes another slot's input shape, and one + /// slot's `Ok`/`Err` type never mutates another's. Cross-slot *timing* is + /// **not uniformly isolated** — see the `outbound-batch-slot-isolation` capability + /// (footnote 4): on Axum/CF/Spin it is `Native` (concurrent complete + /// exchanges), but on Fastly it is `BestEffort` because dispatch is + /// sequential, cold backend registration can block later issuance, + /// background request uploads can leave their own slots unresolved, and + /// each selected response body is drained synchronously before the next pending-handle + /// selection. A selected slot can therefore delay later observation, and a later-observed slot + /// whose own budget would have covered it can still return + /// `gateway_timeout`. Apps that require the stricter cross-slot timing + /// guarantee declare the capability required and get a hard build failure + /// on Fastly. `start_batch_until(vec![], observation_cutoff)` produces an already-complete batch. + /// + /// **Memory model — CORE-OWNED retained payload only.** This formula bounds the + /// buffers EdgeZero core holds; it deliberately EXCLUDES (a) adapter-side upload + /// staging copies (e.g. a `chunk.to_vec()` handed to a platform write path) and + /// (b) opaque host/runtime buffering (the Fastly/CF/Spin host may retain its own + /// copy of in-flight bytes). Worst-case core-owned retained buffer for + /// one `send_all_until` is `Σᵢ request_bodyᵢ.len + Σᵢ max_response_bytesᵢ` + /// (per-slot caps). Transient core overhead during a buffered drain adds up to + /// one in-flight chunk per actively-draining slot (the + /// `current_chunk.len()` term from §3.4.4); the full core-owned bound is therefore + /// `Σᵢ request_bodyᵢ.len + Σᵢ max_response_bytesᵢ + Σⱼ + /// current_chunkⱼ.len()` where j ranges over slots currently in a drain + /// step. Actual process RSS can exceed this by the excluded adapter/host terms. EdgeZero does NOT impose a global cap on N — apps are + /// responsible for bounding the number of requests passed in. Fastly attempts + /// to dispatch every slot before harvest, and every slot whose sequential + /// dispatch returns is then in flight at the host. Cold dynamic-backend creation + /// can delay a later dispatch; an unbounded background upload can leave its own slot + /// unresolved, but does not block later `send_async` calls after it has returned or prevent + /// another handle in the active selection group from being selected. A `max_concurrency` knob + /// would not repair those + /// platform gaps, so bound N at the application layer + /// (typically the fan-out batch's target count). + /// + /// **Request bodies MUST be buffered (`Body::Once`).** A `Body::Stream` + /// request body yields an indexed item whose outcome is + /// `Err(EdgeError::bad_request("batch requires buffered request bodies; use send for a + /// streamed upload"))`, + /// identically on every adapter. This rule removes the unbounded + /// **source-pull** problem from portable fan-out. It does NOT bound Fastly's + /// guest-to-origin write of a non-empty `Body::Once`; that separate + /// cross-slot limitation is owned by `outbound-batch-slot-isolation` footnote 4. + /// + /// **Response mode MUST be Buffered.** A request whose `response_mode` + /// is `Streamed` (via `stream_response`) yields an indexed item whose outcome is + /// `Err(EdgeError::bad_request("batch requires buffered responses; + /// use send for a streamed response"))`, identically on every adapter. + /// Reason: the portable batch transfers only terminal, fully buffered slots. A streamed + /// response would transfer a live provider body out of the adapter-owned cancellation and + /// observation-cutoff state machine. There is no portable concurrent + /// body-consumption primitive in the batch driver to fix this (Fastly has no + /// guest reactor). Apps that want streamed responses use + /// single `send` and orchestrate concurrency themselves on the three + /// reactor-bearing adapters: join N complete per-request async tasks, each + /// awaiting `send` and immediately consuming its response through the + /// **app-facing consuming accessor `into_body -> Body`** before that task + /// completes. Do not join header-only sends and then start body consumption: + /// that recreates the same fast-response deadline hazard. Each task drives + /// its body while siblings may still be waiting for headers. + /// `into_parts(..)` exists too but is labelled adapter-facing because it + /// returns the (request method, status, headers, body) tuple that response converters + /// need; pure orchestration paths just want the body. This rule keeps + /// `outbound-batch-slot-isolation`'s `Native` claim on Axum/CF/Spin honest — + /// the cross-slot body-lifetime problem is removed by construction rather + /// than papered over. + /// + /// **"Identical" scope.** The trait contract guarantees identical + /// **input handling**: same preflight, same index alignment, same + /// per-slot Ok/Err shape. The *cross-slot timing behaviour* is **not** + /// uniform — see the `outbound-batch-slot-isolation` capability. + /// On Axum/CF/Spin the private completion driver fans out complete exchanges and a + /// slot's result reflects what it would have produced in isolation. + /// On Fastly, cold backend registration can delay later dispatch. A synchronously drained + /// selected response body can delay later result observation; a background request upload + /// leaves its own slot unresolved but does not block later `send_async` calls once the earlier + /// call has returned. A later-observed slot can therefore return `gateway_timeout` even + /// when its own `budget.deadline` would have covered it in isolation. Apps that require cross-slot + /// isolation declare the capability required and get a hard build + /// failure on Fastly. + /// + /// Per-slot `Ok`/`Err` semantics: since preflight rejects streamed bodies AND + /// streamed responses, every surviving slot is Buffered on both sides, so the + /// per-slot outcome shape matches `send`'s **Buffered-mode** semantics — `Ok(resp)` + /// means the full exchange completed within the deadline and the body fits + /// within `max_response_bytes`; `Err(_)` is transport / deadline / over-cap. + /// Streamed-mode `Ok`-means-headers-only does not apply here because there are + /// no streamed slots. + fn start_batch_until( + &self, + reqs: Vec, + observation_cutoff: Deadline, + ) -> OutboundBatch; +} +``` + +Both `send` and `start_batch_until` are required on the trait. Each adapter implements both and +shares its single-request preparation and buffered response processing with its batch driver. +`next()` yields every observed terminal item at most once and then returns +`Finished(Completed)` or `Finished(Cutoff)`. If its future is dropped before it returns, no item is +consumed. The observation cutoff wins equality; once the adapter clock is at or past that cutoff, +no newly observed success is yielded. Adapter drivers signal cutoff termination explicitly. Stream EOF is +valid only after all input indices have emitted; premature driver EOF, a duplicate or out-of-range +index, or polling after an invariant failure returns `EdgeError::Internal` rather than fabricating +timeout-like unresolved slots. Dropping the batch is equivalent to `cancel()` with the returned +unresolved-index report discarded. Cancellation stops observation and triggers +strongest-available teardown, but does not claim that already-issued network side effects were +undone. + +Core reports normal completion only at driver EOF after every index has resolved. Trailing +`Failed`, duplicate or out-of-range index events remain failures even after the last slot item; +ordered collection retains every result already observed. A driver cutoff with no unresolved +slots is an invariant failure. Zero-slot drivers follow the same validation protocol; +`OutboundBatch::cutoff(0)` supplies an empty driver and completes normally. Custom drivers must +finish after their final item rather than leave finalization pending indefinitely. + +#### 3.1.2 App-facing handle + +```rust +/// Cloneable handle stored in request extensions and handed to handlers. +/// This is the only outbound *client/handle* type application code touches; +/// handlers also build `OutboundRequest` and read `OutboundResponse`. +#[derive(Clone)] +pub struct HttpClient { + inner: Arc, +} + +impl HttpClient { + pub fn new(client: Arc) -> Self; + pub async fn send(&self, req: OutboundRequest) -> Result; + pub fn start_batch_until( + &self, + reqs: Vec, + observation_cutoff: Deadline, + ) -> OutboundBatch; + pub async fn send_all_until( + &self, + reqs: Vec, + observation_cutoff: Deadline, + ) -> Result; + pub fn with_client(client: C) -> Self; +} +``` + +`HttpClient::send_all_until` is exactly +`self.start_batch_until(reqs, observation_cutoff).collect().await`; it is not a second adapter operation. +On success, its `slots` vector always has the input length. A slot rejected during preflight is +`Some(OutboundSlotResult { outcome: Err(..), .. })`. `None` is legal only when `termination` is +`OutboundBatchTermination::Cutoff` and means that the slot had not become terminal before the +observation cutoff. `Completed` guarantees every slot is `Some`. An explicit adapter failure, +premature driver EOF, or invalid index is not a successful collection and is never `Cutoff`. +`OutboundBatchFailure.error` retains the precise `EdgeError`, while `slots` retains every terminal +result collected before the failure. Converting `OutboundBatchFailure` into `EdgeError` explicitly +discards those partial slots. The hard cut retains no deadline-free `send_all` alias. + +Obtained from the context: + +```rust +// crates/edgezero-core/src/context.rs — replaces proxy_handle +// The accessor is valid both before and after the inbound-body restructuring: as an +// inherent method it reads `self.request.extensions()` directly and does not require a +// new public `RequestContext::extensions()` API. +impl RequestContext { + pub fn http_client(&self) -> Option { + self.request.extensions().get::().cloned() + } +} +``` + +#### 3.1.3 Request and response types + +```rust +pub struct OutboundRequest { + body: Body, // buffered or streamed + cache_policy: OutboundCachePolicy, // PlatformDefault (default) | Bypass + deadline: Option, // shared absolute cap; copy one value into every target request, do not recompute per request (see §3.3.2) + headers: HeaderMap, + host_authority_override: Option, // validated wire authority only + max_decoder_bytes: u64, // source-audited decoder-state charge cap; default 32 MiB + max_brotli_window_bits: u8, // advertised-window cap; default 24, valid 10..=30 + max_chunk_bytes: Option, // opt-in Streamed output rechunker + max_decoded_response_bytes: Option, // identity/EdgeZero-decoded output only + max_encoded_response_bytes: Option, // pre-decode guest-visible body cap + max_request_body_bytes: u64, // cap for buffered or streamed body bytes (default 8 MiB) + max_response_header_bytes: Option, // adapter-visible upstream names + values before normalization + max_response_header_count: Option, // adapter-visible upstream entries before normalization + method: Method, + response_mode: ResponseMode, // Buffered { max_bytes } (default) | Streamed + timeout: Option, // per-request budget + uri: Uri, // validated + canonicalized; see below +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum OutboundCachePolicy { + #[default] + PlatformDefault, + Bypass, +} + +// **All OUTBOUND public byte caps and byte-accounting counters are `u64`, NOT `usize`.** +// This invariant is intentionally scoped to the outbound APIs introduced or changed here. +// The +// crate compiles to `wasm32` on three of the four adapters, where `usize` is **32-bit**: +// a cap or a wire `Content-Length` above 4 GiB is not merely wrap-prone, it is +// **unrepresentable** as `usize`, which would silently break the "portable across all +// four adapters" claim on exactly the targets that matter. `u64` gives one ceiling +// (16 EiB) on every target. The buffered `Bytes` a drain produces is still `usize`-length +// (bounded by available guest memory), but the *cap* and the *running total* it is +// compared against are `u64`, so the comparison and the arithmetic cannot wrap or +// truncate. `Content-Length` is parsed as `u64`; for an **effective identity** response +// (no `content-encoding`, or exactly one bare `identity` value) it equals the decompressed +// size, so it can be compared against the +// `u64` decoded cap for an early over-cap reject BEFORE buffering. For a **compressed** +// response the wire `Content-Length` is the compressed size and does NOT bound the decoded +// size, so it cannot early-reject against either the decoded-output or final-buffer cap. When the optional +// `max_encoded_response_bytes` cap is set, that same length can reject against the encoded +// cap before the body read (§3.4.5). Conversions use `u64::from` / `TryFrom`, never `as` +// (denied lint). + +/// How the adapter delivers the response body. Default is `Buffered`. +pub enum ResponseMode { + /// Adapter reads the full delivered body within the deadline, enforcing a final + /// collection/allocation cap regardless of whether the bytes are identity, decoded, or + /// raw passthrough. `OutboundResponse.body` is `Body::Once`. This is independent of + /// `max_decoded_response_bytes`: choose a larger `max_bytes` to permit a large raw + /// passthrough while retaining a smaller decoded-output cap. + Buffered { max_bytes: u64 }, // default max_bytes = DEFAULT_MAX_RESPONSE_BYTES + /// Adapter returns headers; `OutboundResponse.body` is `Body::Stream`. The + /// caller buffers later (e.g. `into_bytes_bounded`) or passes the body through. + Streamed, +} + +impl OutboundRequest { + /// Constructors validate **and canonicalize** the URI once, before app policy or + /// adapter conversion. The canonical serialized URI is the target identity that + /// `uri()` exposes and every adapter sends: + /// + /// - Scheme must be `http` or `https` (plain `http` is permitted — + /// required for loopback contract tests). Other schemes → + /// `Err(EdgeError::bad_request("outbound URI scheme must be http or + /// https"))`. + /// - An authority must be present. Missing authority → + /// `Err(EdgeError::bad_request("outbound URI must be absolute with + /// authority"))`. + /// - **Userinfo is rejected.** `https://user:pass@example.com` → + /// `Err(EdgeError::bad_request("outbound URI must not contain + /// userinfo; pass credentials via the `authorization` header"))`. + /// This keeps the Fastly backend Host override unambiguous and + /// stops accidental credential leakage. Before WHATWG parsing, every constructor inspects + /// the serialized URI authority and rejects a literal `@`, including empty userinfo such as + /// `https://@example.com/`; this applies equally to `new`, `from_parts`, `get`, and `post`. + /// String constructors additionally reject every raw `\\` byte. Backslash is a special-URL separator that WHATWG parsing can + /// normalize into `/`; rejecting it avoids a second, security-sensitive authority parser + /// and prevents forms such as `https:\\@example.com/`, `https:/\\@example.com/`, and + /// `https:\\/\\@example.com/` from hiding empty userinfo during normalization. The + /// constructor then locates the raw authority only after the literal `://` delimiter and + /// before the next `/`, `?`, or `#`, and rejects any literal `@`, including + /// `https://@example.com/`. A percent-encoded `%40` in path/query data is not an authority + /// delimiter and remains allowed. Tests pin all four slash/backslash separator shapes and + /// verify rejection occurs before `url::Url` is invoked. + /// - **Fragments are rejected at the string-input boundary.** + /// `OutboundRequest::get("https://x/p#anchor")` and `::post(..)` parse + /// the input as a string *first* (they take `impl AsRef` — see + /// below) and reject a `#` before `http::Uri` ever sees it, with + /// `Err(EdgeError::bad_request("outbound URI must not contain a + /// fragment"))`. A Uri-typed input has already passed through + /// `http::Uri`; `new` and `from_parts` canonicalize its serialized value but cannot + /// recover syntax the caller's parser discarded. Raw strings should therefore use + /// `get`/`post`. + /// - Canonicalization uses the pinned WHATWG `url::Url` parser, then converts + /// `Url::as_str()` to `http::Uri`. This adds `url` as a direct workspace/core + /// dependency in Phase 1b; adapters do not reparse or rebuild the URI. It normalizes + /// scheme/host case, IDNA and numeric host spellings, default ports, and dot segments, + /// and applies the pinned parser's path/query percent-encoding rules before an app + /// allowlist sees the target. + /// - **Default ports are normalized away.** A URL parsed from + /// `https://example.com:443` is rewritten so `uri.port` returns + /// `None`; `http://example.com:80` likewise. This means + /// `https://example.com` and `https://example.com:443` produce + /// identical `OutboundRequest`s — same `resolved_port` in the + /// Fastly identity, same Host override, one dynamic backend. Explicit + /// non-default ports (`:8443`, `:3000`) are preserved verbatim. + /// - **Scheme and host are lowercased.** `https://EXAMPLE.com`, + /// `HTTPS://example.com`, and `https://example.com` are the same + /// origin. The canonicalization rewrites the stored URI to lowercase + /// so `OutboundRequest::uri` always reports the lowercase form, + /// and downstream consumers (including Fastly backend identity in §4.3, + /// app-level allowlist checks, Spin `allowed_outbound_hosts` + /// matching) compare against one canonical spelling. Userinfo and fragments are + /// rejected above. Path and query are not promised verbatim: WHATWG serialization may + /// remove dot segments or normalize percent-encoding. The canonical serialized result, + /// not the caller's original spelling, is the security and wire identity. + /// + /// Every adapter passes this stored serialization to its SDK without independently + /// joining path/query components. Tier 2 and live-host tests include dot segments, + /// percent-encoded delimiters, numeric IPv4 aliases, IDNA, empty paths, and query + /// characters; an adapter/runtime that changes the effective target after this boundary + /// fails the portable URI contract rather than silently weakening app policy. + pub fn new(method: Method, uri: Uri) -> Result; + /// `get` and `post` take `impl AsRef` (not `TryInto`) so the raw + /// string is available for fragment detection *before* `http::Uri` + /// truncates at `#`. The impl checks for `#` in the input bytes, then + /// parses with `url::Url`, converts the canonical serialization to `Uri`, then runs + /// the remaining + /// canonicalization. `&str`, `String`, and any `AsRef` work; an + /// already-built `Uri` goes through `OutboundRequest::new` (which cannot + /// recover fragments because the `Uri` has already lost them — see + /// "Fragments are rejected at the string-input boundary" above). + pub fn get(uri: impl AsRef) -> Result; + pub fn post(uri: impl AsRef) -> Result; + + /// Forward an inbound request to a new target. Preserves method and body + /// (which may stream). Headers are normalized for proxy forwarding — + /// the rules live in core so adapters cannot diverge: + /// + /// - hop-by-hop headers are stripped: `connection`, `keep-alive`, + /// `proxy-connection`, `proxy-authenticate`, `proxy-authorization`, `te`, `trailer`, + /// `transfer-encoding`, `upgrade` (RFC 7230), plus every header + /// named in the inbound `connection` header value; + /// - `host` is **dropped** from the headers. The only supported way to select a wire + /// authority is `host_authority_override(..)` below; otherwise adapters derive it from + /// the canonical URI. The accessor encodes the rules: explicit port preserved when the URI + /// carries a non-default port (`https://example.com:8443` → + /// `Host: example.com:8443`); port stripped when default + /// (`https://example.com` → `Host: example.com`); IPv6 hosts + /// bracketed. **Adapters MUST use `host_authority()` for the outgoing authority**; no part + /// of the pipeline reads + /// `host` from `req.headers`. `normalize_for_dispatch` re-strips + /// `host` defensively as a safety net for callers that reached past + /// `header(..)` via `headers_mut`; + /// - `content-length` is dropped — the adapter sets it from the new body + /// for `Body::Once`, or omits it (relying on chunked transfer) for + /// `Body::Stream`. + /// + /// All other headers are preserved verbatim. Validates `uri` per `new`. + pub fn from_request(request: Request, uri: Uri) -> Result; + + /// Fallible: header name/value construction from arbitrary inputs can + /// fail. The signature takes `impl AsRef<[u8]>` for both name and value + /// — **not** `TryInto` / `TryInto`. The standard + /// `TryFrom<&str> for HeaderValue` path is built on + /// `HeaderValue::from_str`, which rejects every byte outside visible + /// ASCII and would refuse a valid non-ASCII UTF-8 header + /// (`x-app-display-name: café`) before EdgeZero's own UTF-8 rule could + /// run. By taking bytes directly: + /// + /// 1. `HeaderName::from_bytes(name.as_ref)` — strict name check (HTTP + /// grammar). + /// 2. `std::str::from_utf8(value.as_ref).is_err` → reject with + /// `EdgeError::bad_request("header value is not valid UTF-8: ")` + /// (the EdgeZero rule). + /// 3. `HeaderValue::from_bytes(value.as_ref)` — applies the **HTTP + /// header-value byte rule** (visible ASCII + obs-text; rejects + /// control bytes like `\n`, `\0` that would enable header injection). + /// Combined with step 2, the values that survive are exactly the ones + /// that are **both** valid UTF-8 **and** valid HTTP header bytes — a + /// valid-UTF-8 string containing a forbidden control byte is still + /// rejected, which is intended security behaviour. Two distinct error + /// messages distinguish the cause (forbidden-bytes vs invalid-UTF-8). + /// + /// Works for `&str`, `String`, `&[u8]`, `Vec`, and `HeaderName` / + /// `HeaderValue` (both `AsRef<[u8]>`). + pub fn header(self, name: N, value: V) -> Result + where + N: AsRef<[u8]>, + V: AsRef<[u8]>; + /// Escape hatch for callers holding already-validated + /// `HeaderName`/`HeaderValue` (or building from `from_request`). The + /// returned `HeaderMap` is not validated here — non-UTF-8 values and + /// stray hop-by-hop / framing headers (`host`, `content-length`, + /// `transfer-encoding`) are caught by the adapter's + /// `normalize_for_dispatch` sweep before the request is issued. + pub fn headers_mut(&mut self) -> &mut HeaderMap; + + /// Set the body from `Body` or a buffered conversion. `Bytes`, `Vec`, + /// `&[u8]`, `&str`, and `String` convert to `Body::Once`. A raw `Stream` + /// does not implement `Into`; callers wrap typed streams with + /// `Body::from_stream` and arbitrary external streams with + /// `Body::from_external_stream`. + pub fn body(self, body: impl Into) -> Self; + /// Serialize `value` as JSON and set the request body to the resulting + /// bytes. Sets `content-type: application/json` only if the request has + /// no `content-type` yet — a caller-set value is preserved unchanged. + /// `content-length` is left to the adapter (it is recomputed from the + /// serialized body for `Body::Once` and omitted for `Body::Stream`). + /// Serialization failure yields `Err(EdgeError::internal(..))`. + pub fn json(self, value: &T) -> Result; + + pub fn timeout(self, duration: Duration) -> Self; + pub fn deadline(self, deadline: Deadline) -> Self; + pub fn cache_policy(self, policy: OutboundCachePolicy) -> Self; + /// Sets a validated DNS name, IPv4 address, or bracketed IPv6 literal with an optional + /// port. Schemes, userinfo, paths, queries, fragments, whitespace, controls, malformed + /// brackets, and invalid ports are rejected as `bad_request`. + pub fn host_authority_override(self, authority: &str) -> Result; + pub fn max_decoder_bytes(self, max: u64) -> Self; + pub fn max_brotli_window_bits(self, bits: u8) -> Self; // 10..=30 at dispatch + pub fn max_chunk_bytes(self, max: NonZeroU64) -> Self; // Streamed emitted-item size + pub fn max_decoded_response_bytes(self, max: u64) -> Self; + pub fn max_encoded_response_bytes(self, max: u64) -> Self; + pub fn max_response_header_bytes(self, max: u64) -> Self; + pub fn max_response_header_count(self, max: u64) -> Self; + pub fn max_response_bytes(self, max: u64) -> Self; // selects Buffered { max }; last mode setter wins + pub fn stream_response(self) -> Self; // selects Streamed; last mode setter wins + + // These two methods select mutually exclusive `ResponseMode` variants. They do not compose: + // `max_response_bytes(..).stream_response()` is an uncapped streamed response, while + // `stream_response().max_response_bytes(..)` is a buffered response with the supplied cap. + + // Zero is an intentional deny-all value for every `u64` cap. It is not a malformed + // configuration: an empty body can satisfy a zero body cap, while the first visible byte or + // header entry fails with the cap's typed reason. `max_chunk_bytes` alone requires + // `NonZeroU64` because a zero-sized output item cannot make stream progress. + + /// Cap on the **request** body for both `Body::Once` and `Body::Stream`. + /// EdgeZero's core `Body::Stream` is `LocalBoxStream` + /// (WASM-friendly, not `Send + 'static`), so adapters cannot hand it + /// directly to a SDK that requires `Send` streams (notably reqwest + /// without its `stream` feature). The contract is therefore: every request + /// body is **bounded** by this cap on every adapter; adapters + /// MAY pass the stream through to the platform natively (Fastly's + /// `send_async_streaming`, Spin's WASI outgoing body) or buffer to + /// `Bytes` within the cap before dispatch (Axum, Cloudflare). Over-cap + /// during drain → `bad_request` (400) — a client-side misuse. + /// Default `DEFAULT_OUTBOUND_REQUEST_BODY_BYTES = 8 MiB`. + pub fn max_request_body_bytes(self, max: u64) -> Self; + + pub fn method(&self) -> &Method; + pub fn uri(&self) -> &Uri; // canonical target apps inspect for their allowlist + pub fn headers(&self) -> &HeaderMap; + + // ---- Canonicalized URI accessors (adapter-facing, non-consuming) ---- + // + // These five accessors are the **single canonical source** of the + // host/port/SNI/cert-host split that every adapter needs. They are + // derived from `self.uri` after the canonicalization rules + // have rejected **userinfo and fragments**, validated the port, and applied the pinned + // WHATWG canonicalization. Path and query do not + // appear in these accessors because none of them are host/port/SNI/cert + // values, but they remain accessible via `self.uri` for the wire-level + // request line. **Adapters MUST consume these accessors rather than + // re-deriving from `uri`** for the host/port/SNI/cert split — both to + // share the canonicalization logic and so the Fastly identity hash + // sees a single canonical form. They are also the values + // tested by the Tier 1 half of the five-value row. + // + // **Manifest `[capabilities.outbound].hosts` entries are a separate + // grammar** — those entries are host-authority-only + // declarations, so the manifest-host validator **rejects** path / query + // / fragment / userinfo on the manifest side. That validator and the + // request-URI canonicalization rules above share the userinfo / fragment + // reject and the lowercase-scheme/host pass, but diverge on path/query: + // request URIs canonicalize them; manifest host entries reject them. The + // two rule sets must not be conflated. + + /// Connection target — always `":"`, with the port resolved + /// (default ports filled in: `http` → 80, `https` → 443). IPv6 hosts + /// are bracketed (`[::1]:443`). This is what Fastly's + /// `Backend::builder(name..)` expects and what Spin uses for its + /// `allowed_outbound_hosts` rendering when the source had no explicit + /// port. Stable across canonicalization (same value whether the input + /// was `https://example.com` or `https://example.com:443`). + pub fn backend_target(&self) -> String; + + /// Authority for the outgoing HTTP `Host`/`:authority`. Returns the validated explicit + /// override when present; otherwise derives from the canonical URI and carries the port + /// **only when it is non-default** for the scheme: + /// `https://example.com:8443` → `"example.com:8443"`; + /// `https://example.com` → `"example.com"`. IPv6 hosts are bracketed. + /// This is what Fastly's `.override_host(..)`, Axum's `Host`, and Cloudflare's `Host` + /// conversion consume. Spin rejects an explicit override before body polling because WASI + /// HTTP cannot preserve a distinct URI connection authority and wire authority. + pub fn host_authority(&self) -> &str; + + /// Canonical host only, with no port and no IPv6 brackets. Unlike + /// `sni_hostname`, this returns IP literals too. Fastly uses this value in + /// `BackendIdentity`; adapters MUST NOT recover it by reparsing `uri` or + /// splitting `backend_target`. + pub fn host_name(&self) -> &str; + + /// SNI hostname — what an HTTPS adapter passes to its TLS stack's + /// SNI setter (Fastly's `.sni_hostname(..)`, Spin/CF's underlying + /// TLS config, etc.). Port-stripped, bracket-stripped for IPv6. + /// **Returns `None` for IP-literal hosts** (IPv4 and IPv6) + /// RFC 6066, which forbids SNI for IP literals. Adapters call + /// the TLS-stack SNI setter only when this returns `Some`; for `None` + /// the SNI extension is omitted from the ClientHello. **Adapters + /// MUST NOT fall back to `uri.host` for SNI** — `None` here + /// means "send no SNI," not "derive it yourself." The cert verification + /// host is `cert_host` below, not this accessor. + pub fn sni_hostname(&self) -> Option<&str>; + + /// Certificate-verification host — what an HTTPS adapter passes to + /// its TLS stack's certificate-verification setter (Fastly's + /// `.check_certificate(..)`, Spin/CF's underlying TLS verifier). + /// **Always present for HTTPS, always port-stripped, always + /// bracket-stripped for IPv6.** Unlike SNI, certificate verification + /// is meaningful for IP literals too — verification will check the + /// presented certificate's SAN against the IP literal (e.g. `127.0.0.1`, + /// `::1`). Returns `None` only for non-HTTPS schemes (i.e. `http`), + /// where the accessor is not used by the adapter. **This is the + /// single canonical source for `.check_certificate(..)` arguments + /// across every adapter**; adapters MUST NOT call `uri.host` and + /// post-process — they call `cert_host` and pass it through. + /// + /// Concrete examples: + /// - `https://example.com` / `https://example.com:443` → `Some("example.com")` + /// - `https://example.com:8443` → `Some("example.com")` (port stripped — cert is not port-qualified) + /// - `https://127.0.0.1` → `Some("127.0.0.1")` + /// - `https://[::1]` / `https://[::1]:443` → `Some("::1")` (brackets stripped) + /// - `http://example.com` → `None` + pub fn cert_host(&self) -> Option<&str>; + + // ---- Adapter-facing inspection (non-consuming) ---- + /// Cheap non-consuming check used by batch preflight: if `true`, + /// the slot is rejected with `bad_request` + /// *before* `send_one` is invoked, so the streamed-upload path is never + /// reached from `start_batch_until`. `send` (single-request) handles `Body::Stream` + /// directly per its trait contract. + pub fn is_stream_body(&self) -> bool; + + /// Cheap non-consuming check used by batch preflight: if `true` + /// (i.e. `response_mode == Streamed`), the slot is rejected with + /// `bad_request` before `send_one` is invoked. `send` (single-request) + /// handles streamed responses directly. + pub fn is_stream_response(&self) -> bool; + + // ---- Adapter-facing disassembly / reassembly ---- + /// Consume the request into its constituent parts. Adapters call this + /// inside `send` / `start_batch_until` after `normalize_for_dispatch` has run, + /// to hand the components to the platform SDK. + pub fn into_parts(self) -> OutboundRequestParts; + /// Round-trip constructor for adapters that need to destructure, mutate + /// a single field, and reassemble (rare — most adapter paths consume). + /// All fields are pub on `OutboundRequestParts`, so this is just a + /// disciplined re-wrap and applies the same invariants as + /// `new`/`get`/`post` (URI validation re-runs). + pub fn from_parts(parts: OutboundRequestParts) -> Result; +} + +/// The one shared stream shape used by `Body` and every transport-independent outbound +/// wrapper. Returning this concrete erased type lets raw, gzip, Brotli, limit, and +/// rechunk branches compose without incompatible opaque `impl Stream` return types. +pub type BodyStream = LocalBoxStream<'static, Result>; + +/// The public streamed-body error surface is exact: adapters, decoders, and +/// other EdgeZero-owned producers can carry typed 502/504/over-cap failures +/// without converting them through `anyhow::Error`. +pub enum Body { + Once(Bytes), + Stream(BodyStream), +} + +// Public ownership paths are exact and stable: +// - `edgezero_core::body::BodyStream` and the root re-export +// `edgezero_core::BodyStream` own the shared body stream type. +// - `edgezero_core::compression::{BROTLI_DECODER_FIXED_CHARGE_BYTES, ContentEncoding, +// brotli_decoder_memory_charge, classify_content_encoding, decode_brotli_stream, +// decode_gzip_stream}` own codec policy. +// - `edgezero_core::outbound::{ResponseHeaderLimiter, collect_response_stream, +// enforce_payload_content_length, limit_decoded_stream, limit_encoded_stream, +// normalize_response_headers, rechunk_stream}` own outbound response policy. +// Every listed item is also re-exported from `edgezero_core`; adapter crates import the +// root path and do not depend on private module layout. + +impl Body { + /// Explicit compatibility boundary for arbitrary external stream errors. + /// Every source error is converted to `anyhow::Error` and then wrapped as + /// `EdgeError::internal`; this constructor never attempts to recover a typed + /// `EdgeError` hidden inside the external error. + pub fn from_external_stream(stream: S) -> Self + where + S: Stream> + 'static, + anyhow::Error: From; + + /// Construct a fallible stream whose errors are already classified. This is + /// the required constructor for every in-tree adapter, decoder, and deadline + /// wrapper that can emit an `EdgeError`. + pub fn from_stream(stream: S) -> Self + where + S: Stream> + 'static; + + /// Drain a buffered or streamed body with pre-append checked accounting. A typed + /// `EdgeError` yielded by `Body::Stream` is returned unchanged; it is never wrapped as + /// `internal`. Foreign/ingress errors have already been sanitized by + /// `from_external_stream` before entering this method, so preserving the item here does + /// not expose an unclassified external error. + pub async fn into_bytes_bounded(self, max_size: usize) -> Result; + + /// Returns the exact typed stream for `Body::Stream`, or `None` for + /// `Body::Once`. + pub fn into_stream(self) -> Option; + + /// Construct an infallible byte stream. + pub fn stream(stream: S) -> Self + where + S: Stream + 'static; +} + +impl From for Body { + fn from(value: Bytes) -> Self { Body::Once(value) } +} +// The existing `From>`, `From<&[u8]>`, `From<&str>`, and +// `From` implementations remain buffered conversions. + +// The separate constructors are intentional. Stable Rust cannot provide one +// generic `from_stream` that preserves `E = EdgeError` but maps every +// other `E` to `internal` without overlapping implementations/specialization. +// In-tree code MUST NOT pass an `EdgeError` stream through +// `from_external_stream`, because doing so would erase its status and kind. +// Conversely, platform inbound bodies and every other foreign producer MUST classify or +// sanitize its error before constructing `Body`: use `from_external_stream` when the +// existing behavior is an opaque internal failure, or map deliberately to a typed +// `EdgeError` before `from_stream` when that boundary owns a public classification. Tests +// cover exact propagation through `into_bytes_bounded` and sanitization of a foreign error. + +// Shared transport-independent response pipeline. These helpers are public because +// adapters are separate crates. Every branch accepts and returns the same `BodyStream`, +// preserving exact typed source errors and making the pipeline skeleton buildable. +// All four adapters compose payload policy through `edgezero_core::outbound`: +#[derive(Clone, Copy, Debug)] +pub struct ResponseBodyPolicy { + pub max_brotli_window_bits: u8, + pub max_chunk_bytes: Option, + pub max_decoded_response_bytes: Option, + pub max_decoder_bytes: u64, + pub max_encoded_response_bytes: Option, + pub response_mode: ResponseMode, +} +pub fn apply_response_body_policy( + native: BodyStream, + headers: &mut HeaderMap, + policy: ResponseBodyPolicy, +) -> Result; +// Checks declared lengths without polling, then composes encoded limits, decoding and header +// rewriting, decoded limits (not passthrough), and rechunking, in that order. Adapter-specific +// bodyless handling, transport reads, timers, cancellation, and final collection stay outside. +// `edgezero_core::compression` items, in module order: +pub const BROTLI_DECODER_FIXED_CHARGE_BYTES: u64 = 16_777_216; +pub const FLATE_DECODER_FIXED_CHARGE_BYTES: u64 = 1_048_576; // 1 MiB; see the flate memory audit +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ContentEncoding { + /// One bare, case-insensitive `br` field. + Brotli, + /// One bare, case-insensitive `deflate` field. The body uses RFC 1950 zlib framing. + Deflate, + /// One bare, case-insensitive `gzip` field. + Gzip, + /// No visible `content-encoding`, or one bare `identity` field. + Identity, + /// A coding EdgeZero deliberately preserves as raw bytes. + Passthrough(PassthroughReason), +} +#[non_exhaustive] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PassthroughReason { + /// Invalid token syntax, empty elements, parameters, or non-UTF-8 bytes. + Malformed, + /// More than one field, or one valid comma-separated coding list. + Stacked, + /// One valid but unsupported coding token, normalized to ASCII lowercase. + Unsupported(String), +} +// A direct call with bits outside 10..=30 is invalid caller policy and returns BadRequest; +// checked accounting overflow retains ResponseLimitReason::DecoderMemory. +pub fn brotli_decoder_memory_charge(window_bits: u8) -> Result; +pub fn classify_content_encoding(headers: &HeaderMap) -> ContentEncoding; +/// The returned state machine reads and validates the fixed-size stream prefix before it +/// constructs the decoder, so polling it inside the adapter wrapper keeps prefix reads, +/// decoder allocation, and all output under one absolute deadline/cancellation owner. +pub fn decode_brotli_stream( + stream: BodyStream, + max_window_bits: u8, + max_decoder_bytes: u64, +) -> BodyStream; +pub fn decode_deflate_stream(stream: BodyStream, max_decoder_bytes: u64) -> BodyStream; +pub fn decode_gzip_stream(stream: BodyStream, max_decoder_bytes: u64) -> BodyStream; + +// `edgezero_core::outbound` items, in module order: +pub fn finish_batch_item( + index: usize, + started_at: MonotonicInstant, + completed_at: MonotonicInstant, + observation_cutoff: Deadline, + outcome: Result, +) -> Option; +pub async fn collect_response_stream(stream: BodyStream, max: u64) -> Result; +pub fn limit_decoded_stream(stream: BodyStream, max: Option) -> BodyStream; +pub fn limit_encoded_stream(stream: BodyStream, max: Option) -> BodyStream; +pub fn rechunk_stream(stream: BodyStream, max: Option) -> BodyStream; + +/// Cumulative adapter-visible upstream field-section accounting retained from the first +/// exposed informational block through final headers and exposed trailers, before EdgeZero +/// normalization, plus synthetic fields inserted by EdgeZero after normalization. +pub struct ResponseHeaderLimiter { /* private checked-u64 counters and limits */ } +impl ResponseHeaderLimiter { + pub fn new(max_bytes: Option, max_count: Option) -> Self; + pub fn observe(&mut self, headers: &HeaderMap) -> Result<(), EdgeError>; +} + +/// Disassembled form of an `OutboundRequest`. Adapter-facing only. +#[non_exhaustive] +pub struct OutboundRequestParts { + pub body: Body, + pub cache_policy: OutboundCachePolicy, + pub deadline: Option, + pub headers: HeaderMap, + pub host_authority_override: Option, + pub max_decoder_bytes: u64, + pub max_brotli_window_bits: u8, + pub max_chunk_bytes: Option, + pub max_decoded_response_bytes: Option, + pub max_encoded_response_bytes: Option, + pub max_request_body_bytes: u64, // applies to Body::Once and Body::Stream (u64 — see cap note) + pub max_response_header_bytes: Option, + pub max_response_header_count: Option, + pub method: Method, + pub response_mode: ResponseMode, + pub timeout: Option, + pub uri: Uri, +} + +// `into_parts` and `from_parts` move every field above without defaulting or dropping +// one. In particular, adapter request conversion must retain the response-resource +// settings until response metadata/body processing. Adapters that consume the request at +// dispatch copy the settings they need after the native send into their pending-response +// state; they do not reconstruct them from `ResponseMode`. + +**Cache policy.** `PlatformDefault` leaves the adapter/provider cache behavior unchanged. +`Bypass` means do not satisfy the request from, store its response in, or revalidate through an +adapter-managed intermediary response cache. It does not synthesize `cache-control` or any other +origin-visible request header. Fastly maps it to request pass; Cloudflare maps it to Fetch +`NoStore`; Axum and Spin have no adapter-managed intermediary response cache, so the requested +semantics are already satisfied without transport mutation. Redirect hops are new requests and +must copy the policy deliberately. + +**Authority separation.** The canonical URI remains the only source for DNS/backend target, TLS +SNI, and certificate verification. `host_authority_override` controls only HTTP `Host` or +`:authority` and is included in any adapter transport cache/backend identity that would otherwise +reuse a conflicting wire authority. Axum and Fastly provide Native separation. Cloudflare is +BestEffort until a deployed, host-observed probe proves Fetch retained the requested `Host` under +the pinned compatibility date and SDK. Spin returns `bad_request` before body polling because +WASI HTTP exposes one authority field for both routing and HTTP authority; it must never silently +retarget the connection. + +/// Body policy from the authoritative raw-response metadata pass. Adapters must act on +/// `ResponseHeadNormalization::body_disposition` before inspecting content encoding/length +/// or constructing `OutboundResponse`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResponseBodyDisposition { + FramingBodyless, // HEAD, 1xx, 204, or 304: settle/drop the platform handle; never decode + Payload, // normal payload path: inspect encoding, decode, and enforce the cap + ResetContent { + // Captured before the helper rewrites the downstream `content-length` to zero. + declared_body: bool, + }, // 205: apply the bounded settle/abort protocol in §3.4.1 +} + +/// Why original Content-Type evidence cannot certify a retained textual singleton. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentTypeIneligibility { + Missing, + Modified, + MultipleValues, + NonText, + Removed, +} + +/// Original field multiplicity/UTF-8 evidence, not MIME validation or debug safety. +/// No trimming, comma splitting, raw-header retention, or body copying. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentTypeProvenance { + Ineligible(ContentTypeIneligibility), + SingleTextUnchanged, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResponseHeadNormalization { + pub body_disposition: ResponseBodyDisposition, + pub content_type_provenance: ContentTypeProvenance, +} + +/// Parse the already-normalized payload-bearing `content-length` and apply every +/// sound pre-poll size decision. Adapters call this only for `Payload`: bodyless and +/// 205 settlement is owned by `ResponseBodyDisposition`. `max_buffered_bytes` is `Some` +/// only for Buffered mode; the independent decoded cap may also be present in Streamed. +/// Malformed, comma-list, or conflicting lengths are protocol 502. The encoded cap applies +/// to every coding. Effective identity compares decoded and Buffered caps too; raw +/// passthrough compares the Buffered cap but never the decoded cap; gzip/deflate/Brotli-to-decode +/// cannot soundly compare wire length with either output cap. +pub fn enforce_payload_content_length( + headers: &HeaderMap, + encoding: &ContentEncoding, + max_buffered_bytes: Option, + max_decoded_bytes: Option, + max_encoded_bytes: Option, +) -> Result<(), EdgeError>; + +pub fn normalize_response_headers( + request_method: &Method, + status: StatusCode, + headers: &mut HeaderMap, +) -> Result; + +pub struct OutboundResponse { + body: Body, // Once in Buffered mode, Stream in Streamed mode + content_type_provenance: ContentTypeProvenance, // immutable original evidence + headers: HeaderMap, + headers_mutated: bool, // separate conservative invalidation flag + monotonic_clock: MonotonicClock, // paired app/client clock for bounded-until helpers + request_method: Method, // retained for HEAD/bodyless response semantics + status: StatusCode, +} + +impl OutboundResponse { + /// Adapter-facing post-conversion constructor. Before calling this, the adapter MUST run + /// `normalize_response_headers` on the raw response metadata, settle any bodyless response, + /// and only then inspect `content-encoding` / `content-length`, decode, and cap. The headers + /// supplied here are therefore already safe for app-facing `headers()` / `into_body()`: + /// hop-by-hop fields and every `connection` nomination are gone; decompression has removed + /// `content-encoding` / `content-length`; and non-UTF-8 header filtering has run. `Body::Once` is + /// used in Buffered mode after the adapter has drained and capped; `Body::Stream` is wrapped + /// with the decoded-output deadline guard in Streamed mode. + /// The caller must provide the outbound client's retained application clock. There is no + /// process-default fallback because deferred body operations must remain in that clock domain. + /// `content_type_provenance` is mandatory pre-filter evidence from the original normalization + /// pass, never inferred from these normalized headers. Use `Unknown` when original bytes or + /// field boundaries are unavailable; Cloudflare MUST always supply `Unknown`. + pub fn new( + request_method: Method, + status: StatusCode, + headers: HeaderMap, + body: Body, + monotonic_clock: MonotonicClock, + content_type_provenance: ContentTypeProvenance, + ) -> Self; + + /// Adapter-facing destructure. Mirrors `OutboundRequest::into_parts`; the retained + /// method is required by response converters for HEAD/bodyless normalization. The + /// fifth element carries the effective provenance, including mutation invalidation. This method + /// does not run the final defensive response-header normalization performed by + /// `into_response`; application boundaries that need that pass call `into_response`. + pub fn into_parts(self) -> (Method, StatusCode, HeaderMap, Body, ContentTypeProvenance); + + /// Adapter-facing mutation point. Merely obtaining this mutable reference marks the headers + /// dirty: an originally eligible accessor result becomes `Ineligible(Modified)`, even without + /// a Content-Type edit. The private original enum stays immutable; ineligible/unknown evidence + /// cannot be upgraded. Apply normal adapter body/header policy before construction instead. + pub fn headers_mut(&mut self) -> &mut HeaderMap; + + // ---- App-facing accessors ---- + pub fn status(&self) -> StatusCode; + pub fn is_success(&self) -> bool; // 2xx + pub fn headers(&self) -> &HeaderMap; + pub fn content_type_provenance(&self) -> ContentTypeProvenance; + pub fn body(&self) -> &Body; + + /// **App-facing consuming accessor** for the response body — the orchestration + /// path for streamed responses recommended by `send`'s rustdoc. + /// Returns the underlying `Body` so app code can iterate `Body::Stream` chunks + /// directly (the wrapper installed at response construction time still + /// enforces `dispatch_budget(req).deadline`) or extract the + /// `Body::Once` `Bytes` if the adapter buffered. This is distinct from the + /// adapter-facing `into_parts(self) -> (Method, StatusCode, HeaderMap, Body, ContentTypeProvenance)` + /// destructure used inside response converters; apps that need just the + /// body for streaming orchestration call `into_body` and drop the rest. + /// On `Streamed` mode with single `send`, this is the canonical orchestration + /// path: on Axum/CF/Spin, join per-request async tasks that each await + /// `send` and immediately iterate that response's `into_body` stream. + /// Body consumption is inside each task, not deferred until all sends + /// have returned headers. `send_all_until` remains buffered-only. + pub fn into_body(self) -> Body; + + /// Buffer the delivered body with a final collection cap. Works for both `Once` + /// and `Stream`. Over-cap yields + /// `Err(EdgeError::response_too_large_with_reason(.., + /// ResponseLimitReason::BufferedBody))` (distinct kind, 502 — NOT + /// `bad_gateway`; §3.4.1). + /// + /// This is NOT a thin wrapper over `Body::into_bytes_bounded` — that + /// helper maps over-limit to `bad_request` (400), correct for inbound + /// bodies but wrong for an over-large upstream response. This method + /// performs its own bounded drain (pre-append checked accounting) + /// and maps over-cap to `response_too_large` with reason `BufferedBody` + /// (distinct kind, 502 — §3.4.1; consistent with the top of this doc, + /// NOT `bad_gateway`). + /// For effective identity or EdgeZero-decoded gzip/deflate/Brotli, the request's independent + /// `max_decoded_response_bytes` policy has already wrapped the response stream. For raw + /// passthrough, this helper counts the delivered encoded bytes and does not invent a + /// decoded interpretation. + /// + /// **Effective-budget deadline is already honoured on a streamed body.** + /// Adapters wrap `Streamed` response bodies with a deadline-aware stream bounded by + /// `dispatch_budget(req).deadline` — which is non-`None` even for + /// timeout-only and no-deadline requests (the synthetic 30 s ceiling) — + /// so a detected expiry yields a `gateway_timeout` error chunk and this + /// drain returns 504. Axum/Cloudflare provide timer-backed cancellation; + /// Spin and Fastly retain the BestEffort host gaps documented in §3.5.2. + /// There is no need to thread the deadline through manually — call + /// `into_bytes_bounded_until(max, deadline)` only when you want to + /// **cooperatively narrow** the failure timing on top of the request + /// budget (see the precise bound and caveat below). + pub async fn into_bytes_bounded(self, max: u64) -> Result; + + /// As `into_bytes_bounded`, but additionally bounded by a `Deadline` + /// that the caller passes per drain. **The helper is a *cooperative* + /// post-read / EOF validator, not a timer-backed race.** The bound it + /// provides is *exactly* "the first paired-clock expiry check that observes + /// expiry returns `gateway_timeout`," where the check sites are + /// enumerated below. A read that is already blocked when the deadline + /// passes does **not** get preempted by this helper — it returns when + /// the underlying source returns (chunk, EOF, or wrapper-emitted error + /// chunk past the request budget), and the helper's *next* check (or + /// post-return check for `Body::Once`) is what fires. Real-time + /// preemption is the *wrapper's* job (the adapter installs a + /// deadline-aware stream bounded by `dispatch_budget(req).deadline` at + /// response construction time); the helper only catches the + /// **tighter `until`** case at yield boundaries. + /// + /// Concretely, if the wrapper still has 500 ms and the caller passes + /// `until_deadline = now + 100 ms`, and a body read happens to block + /// for the full 500 ms, the helper does **not** return at 100 ms — it + /// observes the expired `until` at the 500 ms post-read check and + /// returns `gateway_timeout`. The bound the helper provides is "first + /// expiry check at or after `until_deadline`," not wall-clock = `until`. + /// Apps that need wall-clock preemption tighter than the request budget + /// must either lower `dispatch_budget(req).deadline` (set + /// `.deadline(min(req_deadline, app_inner_deadline))` on the builder) + /// or split the work into a smaller request. + /// + /// Works on both `Body::Once` and `Body::Stream`: + /// + /// - **`Body::Once` (already buffered)**: the helper checks + /// `until_deadline.is_expired_at(monotonic_clock.now())` **at entry**, before doing anything + /// else, and returns `gateway_timeout` if expired. Otherwise it + /// checks the buffered length against `max` — under cap → `Ok(bytes)`; + /// over cap → `response_too_large` (distinct kind, 502; NOT `bad_gateway` + /// — see "Oversize is a distinct outcome", §3.4.1). **Precedence: expired deadline beats + /// over-cap** (an over-cap error after the deadline has expired is + /// masked by the deadline check, since the caller's `until` rolled + /// the result regardless of cap behaviour). This entry-time check + /// makes single `send` + `Body::Once` callers see consistent + /// `gateway_timeout` semantics whether their response arrived + /// already-buffered or streamed. + /// - **`Body::Stream`**: the helper checks + /// `until_deadline.is_expired_at(monotonic_clock.now())` **both before issuing each blocking body read and again after it + /// returns** — including the EOF read. Returns + /// `Err(EdgeError::gateway_timeout(..))` (504) on the first expired + /// check. + /// + /// **Enforcement composes layer-wise without sharing state.** The + /// adapter wrapper installed at response construction time enforces + /// the request's `dispatch_budget(req).deadline` by yielding + /// `Err(EdgeError::gateway_timeout_caused(.., budget.cause))` chunks past *that* deadline + ///; this helper enforces `until_deadline` cooperatively at + /// the four check sites enumerated above (entry for `Body::Once`; + /// before and after each underlying read including EOF for + /// `Body::Stream`). **"Whichever fires first" is at yield boundaries + /// only**: the wrapper's error chunk is timer-selected on Axum / CF / Spin + /// (with Spin's host teardown still BestEffort) and cooperatively detected + /// on Fastly; the + /// helper's `until_deadline` fires at the next check site. If the + /// caller's `until_deadline` is tighter and the next underlying read + /// returns promptly, the helper fires first; if the next underlying + /// read blocks past `until` but within the wrapper's budget, the helper + /// still fires (post-read check) and the helper's bound is "read + /// latency + at most one extra check," not zero. There is no shared + /// "effective deadline" stored on `OutboundResponse`; it retains the paired clock, + /// request method, status, headers, body, original Content-Type evidence, and a separate + /// header-dirty flag. There is no `min(..)` computation in the helper. + /// Apps that need a single combined check with **timer-backed + /// preemption** of the tighter deadline pass + /// `min(req_deadline, app_inner_deadline)` to `.deadline(..)` on the + /// `OutboundRequest` builder instead of layering here — that pushes + /// the tighter deadline into the wrapper. Adapter support remains exactly + /// as classified by `outbound-deadlines` in §3.5.2. + /// + /// **Enforcement is layered.** The helper itself is cooperative on every + /// adapter — its before-and-after-read `is_expired` check cannot + /// preempt a read in progress. Real-time enforcement of the request + /// budget comes from the adapter wrapping streamed response bodies at + /// construction time: + /// + /// - **Axum, Cloudflare, Spin** — the adapter wraps the response body + /// with a deadline-aware stream using its platform timer (tokio / + /// `worker::Delay` / wasi monotonic-clock), bounded by + /// `dispatch_budget(req).deadline`. That deadline is non-`None` for + /// every request (synthetic 30 s ceiling when `req.deadline` was + /// absent), so the wrapping is unconditional — *not* "only when + /// `req.deadline.is_some`." Each chunk read is bounded by the + /// request's effective deadline, so a peer that stalls mid-stream + /// produces an error chunk at that deadline rather than blocking. + /// `into_bytes_bounded_until`'s helper-side `is_expired` check on + /// the caller-supplied `until_deadline` is what catches the + /// *tighter* `until` case (e.g. the wrapper has 500 ms left but the + /// caller passed a 100 ms `until`) **at the next yield boundary**, + /// not in real time. If a read happens to block for the full 500 ms, + /// the helper returns at 500 ms with `gateway_timeout` (post-read + /// check observed expiry), not at 100 ms. Use + /// `min(req_deadline, app_inner_deadline)` on the builder for + /// timer-backed preemption. + /// - **Fastly** — no guest async timer, but the adapter still + /// wraps the streamed response body with a **cooperative + /// deadline-aware stream** that checks `budget.deadline.is_expired()` + /// **both before issuing the underlying body read and again after it + /// returns** (including the read that discovers EOF) and + /// emits a `gateway_timeout` error chunk past the deadline instead + /// of `Ok(chunk)` or stream-end. This makes `into_bytes_bounded`, + /// `into_response` passthrough, and any other consumer of the + /// wrapped body honour the deadline uniformly — the deadline does + /// not depend on whether the caller chose this helper specifically. + /// Bounded-cooperative semantics apply: a stream that yields one + /// chunk and then stalls returns control on the host's + /// between-bytes-timeout, so worst-case overshoot per chunk + /// gap is one between-bytes-timeout interval — never unbounded. + /// + /// The real-vs-bounded distinction matches the `outbound-deadlines` + /// capability matrix. Decompression-cap and 502-mapping behavior matches + /// `into_bytes_bounded`. Because this helper receives only a `Deadline`, expiry + /// introduced by its `deadline` argument uses `BudgetSource::Unspecified`. + /// An adapter-installed deadline wrapper instead retains and reports the + /// `DispatchBudget::cause` that selected the request budget. + pub async fn into_bytes_bounded_until( + self, + max: u64, + deadline: Deadline, + ) -> Result; + /// JSON-decode the already-buffered body. Requires `Body::Once`; on a + /// `Body::Stream` returns `Err(EdgeError::bad_gateway_with_reason("response body + /// not buffered; use json_bounded(max) or json_bounded_until(max, + /// deadline)", BadGatewayReason::Protocol))`. Malformed JSON uses reason + /// `Decode(Json)` — an upstream returning unparseable JSON is a 502 outcome, not a 400. + pub fn json(&self) -> Result; + + /// Buffer (with a final collection cap) then JSON-decode in one step. + /// Consuming convenience for the `Streamed` mode: equivalent to + /// `into_bytes_bounded(max).await` + `serde_json::from_slice`, with + /// malformed JSON mapping to `bad_gateway` reason `Decode(Json)` (502). + pub async fn json_bounded(self, max: u64) + -> Result; + + /// As `json_bounded`, additionally bounded by a caller-supplied + /// `Deadline`. **The caller-supplied deadline is enforced + /// cooperatively by `into_bytes_bounded_until`** — that is, at the + /// yield boundaries enumerated in that helper's rustdoc (entry for + /// `Body::Once`; before and after each underlying read including EOF + /// for `Body::Stream`). A read already blocked when `deadline` passes + /// does **not** get preempted by this helper; it returns when the + /// underlying source returns, and the next check fires. **Real-time + /// enforcement is the wrapper's job** — Axum / CF / Spin install a + /// timer-selected deadline-aware stream bounded by + /// `dispatch_budget(req).deadline` at response construction time + /// so expiry becomes a typed 504. Spin's host teardown is still cooperative + /// and BestEffort (footnote 8). Fastly detects expiry cooperatively on the + /// body phase; its capability remains BestEffort because cold dispatch and + /// upload-write gaps are unbounded (footnotes 1–2). + /// The `deadline` argument here only adds the cooperative + /// post-read tighten; it does not get its own wrapper. Apps that need + /// timer-backed preemption of a deadline tighter than the request + /// budget set `.deadline(min(req_deadline, app_inner_deadline))` on + /// the `OutboundRequest` builder so the tighter deadline lands in the + /// wrapper. Malformed JSON maps to `bad_gateway` reason `Decode(Json)` (502). + pub async fn json_bounded_until( + self, + max: u64, + deadline: Deadline, + ) -> Result; + /// Pass the response through as a core `Response` (keeps a streamed body lazy). + /// Infallible in safe use: like the other terminal methods it takes `self` by + /// move, so double-consumption of the body is prevented at compile time. The + /// `Result` carries exactly two error classes on adapters with + /// `outbound-header-fidelity = Native`: `Err(EdgeError::internal(..))` for an + /// adapter-invariant violation, and `Err(EdgeError::bad_gateway_with_reason(.., + /// BadGatewayReason::Protocol))` for a malformed `connection` value introduced after + /// construction through adapter-facing mutation. + /// Malformed visible nomination syntax is rejected on every adapter; a raw non-UTF-8 + /// value is additionally detectable on Native-fidelity adapters. Cloudflare receives + /// only post-workerd strings, so it cannot detect the raw-byte case; its documented + /// `BestEffort` behavior is scoped by `outbound-header-fidelity` (§3.5.2). No other + /// network/status condition produces an error here. + /// + /// **RESPONSE-SIDE hop-by-hop normalization is re-applied here as an idempotent defense + /// (symmetric with the request side, §3.1.4).** The authoritative pass already ran on + /// raw response metadata before bodyless/decode/cap decisions. This final pass protects + /// against a later adapter-side `headers_mut()` mutation. A proxied UPSTREAM response + /// can carry hop-by-hop headers + /// that MUST NOT be forwarded downstream: `into_response` strips `connection`, + /// `keep-alive`, `proxy-connection`, `proxy-authenticate`, `proxy-authorization`, `te`, `trailer`, + /// `transfer-encoding`, `upgrade`, **AND every header NOMINATED by the response's own + /// `connection` value** — so an upstream `Connection: x-private` + `X-Private: secret` + /// cannot leak `X-Private` to the downstream client. Both passes use one core helper, + /// `outbound::normalize_response_headers(..)` (the response twin of + /// `normalize_for_dispatch`), so every adapter and passthrough goes through the same + /// stripping. On Axum/Fastly/Spin, the `connection` header is resolved + /// **fail-closed** exactly as on the request side: a non-UTF-8 value is rejected as + /// `bad_gateway` reason `Protocol`, never silently dropped. On every adapter, a visible + /// nomination that is not an RFC field-name token uses that same reason; implementations + /// do not partially honor a malformed list. Cloudflare applies the same stripping to + /// the strings visible after workerd processing but cannot detect malformed raw bytes or + /// recover original non-`set-cookie` field boundaries. §5.4 pins both the portable visible + /// header behavior and the stronger `outbound-header-fidelity` contract. + /// Uses `ResponseHeadNormalization::body_disposition` for final body suppression and carries + /// effective `ContentTypeProvenance` in the resulting response's extensions. The final pass + /// may downgrade an eligible result, but never upgrades original ineligible/unknown evidence. + /// This extension is historical evidence at the outbound conversion boundary only; later + /// ordinary `http::Response` edits are the application's responsibility. + pub fn into_response(self) -> Result; +} +``` + +The complete builder surface — `new`/`get`/`post`/`from_request`/`header`/`headers_mut`/ +`body`/`json`/`timeout`/`deadline`/`max_decoder_bytes`/ +`max_brotli_window_bits`/`max_chunk_bytes`/`max_decoded_response_bytes`/ +`max_encoded_response_bytes`/`max_request_body_bytes`/`max_response_header_bytes`/ +`max_response_header_count`/`max_response_bytes`/`stream_response`. Every fallible step +returns `EdgeError`, so handler code uses `?` uniformly. + +#### 3.1.4 Adapter behaviour contract — redirects and header encoding + +These rules define two explicit levels. The portable baseline on all four adapters strips +visible hop-by-hop headers and visible `connection` nominations and preserves repeated +`set-cookie`. The fixed portable strip set includes the de facto `proxy-connection` field in +addition to the standard hop-by-hop fields. The stronger **`outbound-header-fidelity`** +capability covers both directions: +outbound request field lines reach the platform transport without EdgeZero coalescing, and +raw response-header octets plus original field-line boundaries reach normalization. This +includes fail-closed malformed `connection`, malformed/repeated `content-encoding` +disposition, and repeated non-`set-cookie` field-line preservation. Axum/Fastly/Spin are +`Native`; Cloudflare is `BestEffort`, so a `required` declaration hard-fails there. +Cloudflare's Fetch `Headers` may semantically combine repeated request values, and response +processing sees post-workerd strings and comma-joined non-`set-cookie` values. It cannot +claim original request field-line preservation, malformed raw `connection` → 502, or +malformed raw `content-encoding` → forced passthrough. + +**Redirects: not followed automatically.** A 3xx upstream response is delivered to the +app as `Ok(OutboundResponse)` with the 3xx status and the `Location` header preserved. +EdgeZero never silently follows a redirect on the app's behalf. This is a security +property: an app that allowlists `https://trusted.example` and checks `req.uri()` before +sending can never be diverted to `https://attacker.example` by an upstream 302, because +following the redirect requires the app to issue a fresh `OutboundRequest` — at which +point its allowlist runs again. Per-adapter mechanics: + +| Adapter | How to disable auto-redirect | +| --- | --- | +| Axum | `reqwest::ClientBuilder::redirect(reqwest::redirect::Policy::none())` | +| Cloudflare | `worker::RequestInit { redirect: worker::RequestRedirect::Manual, .. }` (the enum, **not** the string `"manual"`) | +| Spin (WASI) | the hand-built `wasi:http` request (§4.4) does not auto-follow — no opt-out needed | +| Fastly | `fastly` does not auto-follow — no opt-out needed | + +Apps that want to follow a redirect read `resp.headers().get("location")`, run their +allowlist against the new URI, and issue a new request. + +**Header value encoding: UTF-8.** EdgeZero requires every outbound and inbound-of-outbound +header value to be valid UTF-8. The rationale is **portability, not a WASI limitation**: +WASI `http` `fields` values are `list`, so WASI *can* carry arbitrary bytes — but +Cloudflare Workers models headers as JS strings, and other adapters' header types +(`reqwest`'s `HeaderValue`, etc.) do not uniformly round-trip arbitrary bytes. A single +valid-UTF-8 rule is the portable intersection — uniform behaviour beats per-adapter +lossiness for headers that matter. (The check is additionally an HTTP-validity check: a + UTF-8 string still bearing a forbidden control byte like `\n`/`\0` is rejected — + §3.1.3 `header(..)`.) + +- *Outbound request headers.* `OutboundRequest::header(..)` constructs the + `HeaderValue` via `HeaderValue::from_bytes(value.as_ref())`, **not** + `HeaderValue::from_str` — the latter rejects every byte outside visible ASCII and + would refuse a perfectly valid non-ASCII UTF-8 header like + `x-app-display-name: café` before EdgeZero's UTF-8 rule runs. The builder's + `V: AsRef<[u8]>` bound means `value.as_ref() -> &[u8]` works uniformly for `&str`, + `String`, `&[u8]`, `Vec`, `HeaderName`, and `HeaderValue`. + `HeaderValue::from_bytes` accepts the **HTTP header-value byte set** (visible + ASCII + obs-text, with control bytes like `\n`/`\0` rejected to prevent header + injection); EdgeZero then layers its own UTF-8 check via + `std::str::from_utf8(value.as_ref()).is_ok()`. The accepted set is therefore + **valid UTF-8 *and* valid HTTP header-value bytes**, not "all valid UTF-8" — an + HTTP-invalid byte (`\n`, `\0`) inside a UTF-8-valid string still rejects, and + that's intended security behaviour. Two distinct failure messages: + `Err(EdgeError::bad_request("header value contains forbidden bytes: "))` + for the HTTP-validity reject, `Err(EdgeError::bad_request("header value is not + valid UTF-8: "))` for the UTF-8 reject. Loud and at construction time. + Repeated values in `HeaderMap` are appended separately at every adapter boundary. + Axum/Fastly/Spin preserve those entries as distinct transport fields and satisfy the + Native capability. Cloudflare also calls `Headers::append` for every entry, preserving + list semantics and `set-cookie` behavior where Fetch permits it, but workerd owns final + request serialization and may combine non-`set-cookie` lines. That is the request-side + half of its documented BestEffort result; no code or test may promote semantic equality + to original field-line fidelity. +- *Outbound response headers on `outbound-header-fidelity = Native` adapters.* If an upstream response carries non-UTF-8 header values, + **each individual value** is checked (`std::str::from_utf8` on the raw byte slice from + the platform SDK) — invalid values are dropped, valid sibling values for the same + header name are preserved. Multi-value headers like `set-cookie` therefore keep + every valid entry even if one duplicate is invalid. The adapter emits a `log::warn!` + naming each dropped header. + **TWO headers are exempt from this silent drop, because dropping one field line would + change how the REMAINING lines are interpreted** (the same smuggling shape in both + cases): (a) **`connection`** — resolved fail-closed by the authoritative raw-response + normalizer before body disposition or decoding (a non-UTF-8 value → `bad_gateway`), else + a nominated header could smuggle past hop-by-hop removal; + and (b) **`content-encoding`** — an invalid value **forces the stacked/passthrough + branch** (§3.4.1): the value is not decoded and `content-encoding`/`content-length` are + **preserved**, never stripped. Otherwise `content-encoding: gzip` + a second, invalid + `content-encoding` line would drop to an apparent *single* `gzip`, and the converter + would decode a body that is still one layer compressed while stripping the very headers + that revealed it. §5.4 pins a row **crossing** the two rules (repeated field lines where + one is non-UTF-8). The rest of the response is delivered normally so a + malformed exotic header cannot poison an otherwise valid fan-out batch response. + Cloudflare cannot execute this raw-byte branch. It applies normalization only to the + post-workerd strings it receives; this is the documented `BestEffort` deviation of + `outbound-header-fidelity`, not part of the all-adapter baseline. +- ***Cloudflare degradation — precisely scoped (verified against `worker` 0.8.5 + + workerd source).*** Earlier drafts said CF simply "cannot do multi-value headers." + That is **too pessimistic and wrong for `set-cookie`**. The actual split: + + - **`set-cookie` IS preserved on Cloudflare.** workerd's `getDisplayedHeaders` + special-cases it: with the `httpHeadersGetSetCookie` compatibility flag on, each + `set-cookie` is yielded as its **own** `entries()` tuple. **This repo already + qualifies** — `wrangler.toml.hbs` pins `compatibility_date = "2023-05-01"`, and the + flag enables at `2023-03-01`. The historical `proxy.rs` implementation collapsed + these values with `HeaderMap::insert`; the outbound implementation now appends each + value in both conversion directions. (`worker`'s own `http`-feature conversion does + the same.) The regression contract pins multiple `set-cookie` values so this + EdgeZero-owned behavior cannot silently regress. + - **Repeated *non*-`set-cookie` response headers are irrecoverably comma-joined.** workerd + joins same-name values (`kj::strArray(values, ", ")`) in both `entries()` and + `get()`, and its `getAll()` **throws `TypeError` for any name except + `set-cookie`**. There is **no API** to recover the original separate field lines — + two `x-foo` headers arrive as `x-foo: a, b`. Per RFC 9110 §5.3 that is semantically + equivalent for list-valued fields (exactly why the standard special-cases + `Set-Cookie`), but it is **not byte-faithful**. This is the real, narrow CF + limitation. + - **Raw bytes are unreachable; invalid UTF-8 is lost upstream of EdgeZero.** Header + values cross `v8::String::NewFromUtf8` before the guest sees them, arriving as Rust + `String`. So on CF the adapter **cannot detect** whether an upstream value was + invalid UTF-8, and the "drop the invalid value, keep valid siblings" rule degrades + to "whatever the runtime already decided". Axum, Fastly, and Spin expose raw bytes + and honour the rule as written. + + > **⚠️ `get_all` landmine.** `worker-sys`'s `getAll` binding has **no `catch`**, and + > workerd throws `TypeError` for any name other than `set-cookie`. A + > `get_all("x-foo")` therefore **unwinds across the wasm boundary** rather than + > returning `Err`. **Only ever call `get_all("set-cookie")`.** + + Worker 0.8.5 binds this Workers-only `Headers.getAll()` method without a catch boundary; + ordinary browser WASM runners do not implement it. Therefore real SDK response conversion, + the `get_all("set-cookie")` call, and repeated-Set-Cookie preservation execute only in the + pinned workerd/deployed fixture. Browser WASM tests are limited to portable Web APIs, raw + fetch-option construction, and bridge compilation; a browser pass cannot establish the + Workers response-header contract. + + Narrowing the portable contract to ASCII-only was **rejected** — it would degrade the + three adapters that *can* do this correctly. The §5.4 request/response repeated-field and + non-ASCII rows are asserted on Axum / Fastly / Spin and are **best-effort on + Cloudflare**. + +**Method and request-body portability.** `OutboundRequest` accepts an arbitrary +`Method`, but the platforms do not. Cloudflare's `fetch` restricts the method set and +**forbids a body on `GET`/`HEAD`**. The historical adapter silently coerced unsupported +methods to `GET`, which could issue a `DELETE` as a `GET`; the shared preflight below +replaced that behavior. +The portable contract: + +- **Supported methods** (all four adapters): `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, + `DELETE`, `OPTIONS`. These are guaranteed to reach the upstream with the method + intact. +- **Custom / extension methods** are **not portable**. An `OutboundRequest` carrying a + method outside the list above is rejected at **preflight** with + `Err(EdgeError::bad_request("method is not portable; supported: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"))` + — on **every** adapter, so the failure is uniform rather than "works on Fastly, + silently becomes GET on Cloudflare". +- **`GET`/`HEAD` with a non-empty body** is rejected at preflight with + `Err(EdgeError::bad_request("GET/HEAD request must not carry a body"))` on every + adapter (CF's `fetch` forbids it; the others would happily send it, so EdgeZero + normalises to the strictest platform). +- **No silent coercion, ever.** An adapter MUST NOT rewrite the method to make a + request sendable. Preflight rejects; it never downgrades. + +**Enforcement point: one core validator, run at DISPATCH — not at construction.** +Validating at construction is **not enforceable**: `OutboundRequest::body(..)` is an +infallible chainable setter (`pub fn body(self, body: impl Into) -> Self`), so a +`GET` that passed a construction-time check can acquire a body immediately afterwards +(`OutboundRequest::get(url)?.body(payload)`) and reach the wire unchecked. The rules +above are therefore enforced by a **single core function**: + +```rust +// edgezero-core/src/outbound.rs — the ONLY place these rules live. +// PUBLIC, not pub(crate): the four adapters are SEPARATE crates and must call it. +// (A pub(crate) fn is unreachable from edgezero-adapter-{axum,cloudflare,fastly,spin}; +// verified — a pub(crate) validator fails to compile at the adapter call site.) +pub fn validate_for_dispatch(req: &OutboundRequest) -> Result<(), EdgeError>; +``` + +It is called **exactly once per request before body polling or platform request +construction**, from **both** paths: every adapter's `send`, immediately after its required +method-entry monotonic snapshot, and `start_batch_until`'s per-slot preflight after the one shared +batch snapshot. The snapshot is the only operation that precedes validation, so request +preparation time consumes the original budget (§3.3.3). Neither path may skip the validator +and no adapter re-implements it — that is what makes the failure identical on all four +adapters, keeps a single `send` and a one-slot collected batch equivalent (§5.4), and preserves +slot-index alignment (§3.1.1). + +**`GET`/`HEAD` + `Body::Stream` is always rejected.** "Non-empty body" is not decidable +for a stream: `Body::Stream` has **no observable emptiness** without polling it, and +polling consumes it. The validator therefore does **not** attempt to peek-and-rechain. +**Preflight precedence (a `GET`/`HEAD` + `Body::Stream` in a batch matches TWO rules — +the method/body rule here AND the batch's generic "no `Body::Stream` in buffered fan-out" +rejection).** The **method/body check runs FIRST**, so the error is the specific +`"GET/HEAD request must not carry a streamed body…"` (below), NOT the generic +streamed-in-batch message — the more informative, method-specific diagnostic wins. +(§5.4 pins this precedence with a `start_batch_until([GET + Body::Stream], observation_cutoff)` row asserting the +method-specific message.) The rule is: + +| Method | Body | Outcome | +| --- | --- | --- | +| `GET` / `HEAD` | `Body::Once(empty)` | `Ok` | +| `GET` / `HEAD` | `Body::Once(non-empty)` | `bad_request("GET/HEAD request must not carry a body")` | +| `GET` / `HEAD` | **`Body::Stream` (any)** | `bad_request("GET/HEAD request must not carry a streamed body; emptiness cannot be determined without consuming the stream")` — rejected **unconditionally**, even if the stream would have yielded nothing | +| other methods | any | `Ok` (subject to the portable-method check above) | + +Rejecting an empty-but-streamed `GET` is a deliberate, documented false-positive: the +alternative (peek the first chunk and re-chain it) adds a buffering seam to every +request for a case with no legitimate use. + +*Implementation guardrail.* The UTF-8 check uses `std::str::from_utf8(value.as_bytes())`, +**not** `HeaderValue::to_str()`. `to_str()` is stricter than UTF-8 — it rejects any +byte outside visible ASCII — and would incorrectly drop valid non-ASCII UTF-8 headers +(e.g. an `x-app-display-name: café` style header). Adapters and the core +`normalize_for_dispatch` helper both use `str::from_utf8(value.as_bytes()).is_ok()`. +Framing-sensitive response `Content-Length` is exempt from this lossy filter until its strict +parser runs; malformed or non-UTF-8 values fail as `bad_gateway` reason `Protocol` rather than +disappearing before framing validation. +§5.4 asserts exact non-ASCII UTF-8 request/response preservation on the three +Native-fidelity adapters. Cloudflare separately proves that its guest-visible string path +does not panic or discard a valid value, without claiming raw-octet or field-line fidelity; +that distinction is its documented BestEffort cell. The same tests assert that a raw header +containing a `\x80` byte is dropped on a Native response or rejected on a request. + +Headers that matter for security, tracing, caching, and content negotiation +(`authorization`, `traceparent`, `cookie`, `cache-control`, `accept`, `content-type`, +…) are ASCII-only by spec and are unaffected by this rule. The trade-off only restricts +exotic non-UTF-8 custom headers; apps requiring fidelity for those must not use +EdgeZero outbound for that case. + +**Response normalization happens before semantic interpretation.** Every adapter calls the +public core helper `outbound::normalize_response_headers(..)` immediately after extracting +the upstream method/status/header fields and **before** bodyless handling, `Content-Encoding` +selection, early `Content-Length` rejection, decompression, or construction of +`OutboundResponse`. Before any lossy filtering, the helper captures original `Content-Type` +multiplicity and UTF-8 validity. It then resolves every visible `connection` line fail-closed, strips +the standard hop-by-hop fields plus all valid nominations, applies the response UTF-8 policy, +and returns `ResponseHeadNormalization { body_disposition, content_type_provenance }`. +Adapters use `.body_disposition` for §3.4.1 and retain `.content_type_provenance` for the +sixth constructor argument, except that Cloudflare always supplies `Unknown`. This order is security- +relevant: `Connection: content-encoding` and `Connection: content-length` must remove those +fields before either can influence decoding or cap logic. As a result, app-facing +`OutboundResponse::headers()` and `into_body()` never expose raw hop-by-hop metadata. +`into_response()` runs the same helper again only as an idempotent defense against later +adapter-facing mutation. + +**Original Content-Type evidence.** `ContentTypeProvenance` is a tiny, non-exhaustive enum, +not a raw-header snapshot or a body copy. Original evidence is determined before dropping +non-UTF-8 values or hop-by-hop/connection-nominated fields: + +- No original value yields `Ineligible(Missing)`. +- More than one original value yields `Ineligible(MultipleValues)`, whether identical, + conflicting, or a mixture of textual and non-textual values. Dropping an invalid value + cannot turn an original duplicate set into an eligible singleton. +- Exactly one value that is not valid UTF-8 yields `Ineligible(NonText)`. +- Exactly one valid UTF-8 value yields `SingleTextUnchanged` only if retained unchanged; + if stripped (for example by `Connection: content-type`), it yields `Ineligible(Removed)`. +- When original bytes or field boundaries are unavailable, the adapter must supply `Unknown`. + Cloudflare MUST always use `Unknown`, including an absent or apparently single textual + value, because Fetch string iteration has already lost original octets and multiplicity. + Axum/Fastly/Spin retain the original pass's metadata on every response path. + +This is provenance only: core does not MIME-validate, trim, redact, or split commas. A known +single value `application/json, text/plain` can be `SingleTextUnchanged` even though the +application's MIME parser must reject it as a media type. A quoted comma likewise does not +create another original field value. `Unknown` says evidence is unavailable, not that MIME +is invalid; `SingleTextUnchanged` does not establish MIME validity or debug safety. +MIME validation, acceptance, and redaction remain application-owned. + +`OutboundResponse::new(method, status, headers, body, clock, content_type_provenance)` +requires explicit metadata from that original pass; it never infers provenance from the +already-normalized headers. The private original enum is immutable. `headers_mut()` sets a +separate dirty flag even if the caller makes no edit or changes an unrelated field. +`content_type_provenance()` then returns `Ineligible(Modified)` for an originally +`SingleTextUnchanged` response; original `Ineligible(..)` reasons and `Unknown` never upgrade. +Restoring the old value does not restore eligibility. + +`into_parts()` returns `(method, status, headers, body, provenance)` with this effective +accessor result and without another normalization pass. `into_response()` runs the defensive +pass and inserts effective `ContentTypeProvenance` into the resulting `Response` extensions; +it can downgrade eligibility but cannot promote original ineligible/unknown evidence. Once +converted, the extension describes the historical outbound boundary only. Ordinary later +`http::Response` header edits are not tracked and must be handled by the application. +No raw header or body is retained or copied for this enum, and no new capability is added. + +For 205, `ResponseBodyDisposition::ResetContent { declared_body }` captures whether a +still-visible, valid `Content-Length` declared a positive length **after** nomination +stripping but **before** the helper rewrites the downstream value to `0`. This lets an +adapter abort immediately without consulting a header the normalizer has already changed. + +For response `connection`, each comma-delimited nomination across every field line must be +a non-empty RFC field-name token after optional whitespace. A non-UTF-8 value (where raw +bytes are available), empty member, forbidden byte, or invalid token rejects the whole +upstream response as `bad_gateway` reason `Protocol` (502); valid prefixes are not +partially honored. +Cloudflare enforces the same visible-token rule on post-workerd strings but cannot detect a +raw malformed value that workerd did not expose, which remains its header-fidelity caveat. + +**Final normalization at dispatch (`outbound::normalize_for_dispatch`).** Two surfaces +bypass the construction-time `header(..)` check — `headers_mut()` exposes raw +`HeaderMap`, and `from_request(..)` carries inbound headers in. Adapters MUST call a +core helper `outbound::normalize_for_dispatch(&mut OutboundRequest)` immediately before +handing the request to the platform SDK. The helper is idempotent and runs the same +rules end-to-end: + +1. **First**, handle every `connection` field value (see step 3's nomination list) **before** + any UTF-8 drop, because it governs the removal of *other* headers. A `connection` + value that is **not valid UTF-8 is rejected** (`EdgeError::bad_request`), NOT silently + dropped: dropping it would discard the removal intent and let a sender **smuggle a + nominated header past hop-by-hop stripping** by appending an invalid byte + (`Connection: x-private,` would otherwise forward `X-Private`). This is the + one header where the lossy drop below is a security hole, so it fails closed instead. + Parse each comma-delimited nomination after optional whitespace as an RFC field-name + token. An empty item, forbidden byte, or otherwise invalid token rejects the request as + `bad_request`; do not strip only a valid prefix and continue. Header-name comparison and + removal are case-insensitive, and nominations are accumulated across repeated + `connection` field lines before any nominated field is removed. +2. Drop any *other* header value that is not valid UTF-8 (drop + `log::warn!` naming the + header) — same lossy semantics as the response side. This applies **only** to + values that arrived via `headers_mut()` or `from_request(..)` (which carries + inbound headers verbatim). `OutboundRequest::header(..)` already rejects invalid + UTF-8 at construction with `bad_request` (§3.1.3), so a non-UTF-8 value can only + reach this stage by bypassing the checked builder. The policy split is + deliberate: construction is loud (caller error → 400); proxy-forward and + pre-validated-map paths are lossy (don't fail an otherwise-good forward over an + exotic header). The `warn!` makes the drop observable in either case. **The + `connection` header is exempt — it was already resolved fail-closed in step 1.** +3. Strip hop-by-hop headers (`connection`, `keep-alive`, `proxy-connection`, `proxy-authenticate`, + `proxy-authorization`, `te`, `trailer`, `transfer-encoding`, `upgrade`, plus every + header named in any `connection` header value — parsed and validated from the + now-guaranteed-UTF-8 values per step 1). Idempotent for `from_request` + output; mandatory for manually built requests. +4. Remove `host` — `normalize_for_dispatch` is the single source of truth for stripping + unchecked header-map input. Adapters set final `Host`/`:authority` from the validated + `req.host_authority()` accessor and never recover it from `req.headers()`. The URI remains + separately available for connection target, SNI, and certificate identity. Spin rejects an + explicit override because WASI HTTP cannot express that separation. Cloudflare applies the + override through Fetch headers but remains BestEffort until the deployed host-observation gate + passes under the pinned SDK and compatibility date. `from_request` (§3.1.3) also drops raw + `host`, so only the typed override can create a URI/wire-authority difference. +5. Remove `content-length` — the adapter sets it from the body (length for + `Body::Once`; omitted for `Body::Stream`). +6. Remove `transfer-encoding` — the adapter sets it per body type and HTTP version. + +Apps can therefore use `headers_mut()` and `from_request` freely; portability and +framing safety are guaranteed by this final sweep, not by individual callers +remembering to sanitize. + +**Multi-value headers preserved.** `HeaderMap` permits repeated names — `set-cookie`, +`warning`, custom tracing headers, etc. EdgeZero adapters MUST preserve every entry for +a repeated header **on requests, and for response `set-cookie`**; repeated +*non-`set-cookie`* **response** field-lines are **outside the portable baseline** +(Cloudflare comma-joins them — the documented §3.1.4 exception), so apps needing that +fidelity declare the capability and target a `Native` adapter. Within that scope: use +`HeaderMap::append` (never `insert`) when building, and read with `get_all` (never `get`) +when serializing to the platform SDK or deserializing platform responses. The implemented +per-adapter mechanics are: + +| Adapter | Request side (build platform request) | Response side (read platform response) | +| --- | --- | --- | +| Axum | `reqwest::RequestBuilder::header` (calls `HeaderMap::append`) | iterate `reqwest::Response::headers()` which is already a `HeaderMap` — preserve as-is | +| Cloudflare | `worker::Headers::append(name, value)` — **not** `set` (collapses) | iterate `worker::Headers` entries; `set-cookie` is enumerated separately by the worker runtime, handled explicitly | +| Fastly | `fastly::Request::append_header(name, value)` — **not** `set_header` | `fastly::Response::get_header_all(name)` per name, **not** `get_header` (returns first only) | +| Spin | append via the WASI HTTP `fields` resource (`wasip3::http::types::Fields::append`, re-exported through `spin_sdk`) — natively multi-value. There is **no** `spin_sdk::http::Headers` type; earlier drafts named one that does not exist | iterate WASI `fields` per name | + +Contract tests in §5.4 exercise repeated `set-cookie` response headers and repeated +outbound request headers, so any regression to collapsing duplicates is caught at CI +time. Cloudflare's response-side case runs in workerd, never the browser harness. If a future SDK update breaks multi-value round-tripping on one adapter, the +spec downgrades the contract for that adapter and documents the limitation rather than +silently dropping headers. + +### 3.2 Buffered fan-out + +`OutboundHttpClient::start_batch_until` is the single adapter-facing concurrency API for +buffered fan-out. It rejects streamed request bodies and streamed response mode during isolated +per-slot preflight. All adapters capture one start instant before preflight and attempt every +eligible request before waiting for transport completion. Each emitted `OutboundBatchItem` +contains the original input index and one terminal `OutboundSlotResult`; completion order never +becomes request identity. + +`OutboundBatch::next()` lets an application parse or otherwise consume a fast terminal response +while slower siblings are still pending. It returns `OutboundBatchNext::Item` for each observed +slot and then `OutboundBatchNext::Finished(Completed | Cutoff)`. This is materially different from +returning only a terminal vector: work performed between completions remains inside the caller's +shared deadline. Dropping a pending `next()` future is safe and does not consume a ready item. Each +index is emitted at most once. `cancel()` ends observation immediately, returns every index that +was not already emitted, and invokes the strongest nonblocking teardown available to the adapter. +The caller retains items it already received, which is the portable +partial-result-on-cancellation contract. + +The driver owns the absolute observation cutoff. Before accepting a provider completion it +samples the same injected clock used for request budgets. Equality is expired. When the observation cutoff +wins, the driver stops yielding new terminal results, tears down or abandons unresolved work, and +emits an explicit cutoff event. A response that was provider-ready but not observable before a +blocking provider primitive returned remains unresolved; capability rows make that limitation +explicit. Core reports normal completion only at driver EOF after every input index emits exactly once. A +premature driver EOF and duplicate or out-of-range indices are invariant failures returned as +`EdgeError::Internal`; they never become `Cutoff`. An adapter that detects its own invariant +failure emits `OutboundBatchDriverEvent::Failed` carrying that exact `EdgeError` rather than ending +its driver or fabricating cutoff termination. Incremental `next()` callers receive the error directly. + +On the concurrent Axum, Cloudflare, and Spin drivers, each child samples its terminal instant +synchronously when that child is observed ready. The terminal samples follow poll-observation order in the shared monotonic clock domain. Once one observation is at or past +the observation cutoff, a later-observed child cannot have an earlier on-time terminal sample unless the +injected clock violates its monotonic contract; backwards samples already fail closed as internal +slot outcomes. The drivers therefore emit `Cutoff` immediately rather than polling or grouping +siblings after expiry. Fastly stores provider-selected completions across blocking host operations +and retains its separately documented BestEffort completion-order limitation. + +`HttpClient::send_all_until` is the simple ordered view. It constructs the batch and calls +`collect()`. A successful `OutboundBatchResults.slots` always has the input length. Preflight and +other terminal failures are `Some`; only work unresolved when the observation cutoff won is `None`, and +only when `termination == Cutoff`. `Completed` guarantees no missing slots. If the driver fails, +`OutboundBatchFailure.slots` has the same input length and preserves every result collected before +the failure; unresolved positions remain `None`, but that absence is distinguished from cutoff by +the `Err` result and precise `error` field. There is no second adapter collector and no +compatibility `send_all` method. + +| Adapter | `start_batch_until` mechanism | Completion/cancellation quality | +| --- | --- | --- | +| Axum | `FuturesUnordered` of complete reqwest exchanges raced with the absolute observation cutoff | completion order and future-drop cancellation are Native | +| Cloudflare | completion set of complete Fetch exchanges, each retaining its `AbortController` | completion order is Native; host cancellation remains BestEffort pending deployed proof | +| Spin | completion set of owned WASI HTTP exchanges raced with the observation-cutoff timer | completion order is Native; host teardown remains BestEffort | +| Fastly | sequential `send_async` dispatch followed by indexed pending-handle selection | completion ordering, observation-cutoff handling, and teardown remain BestEffort | + +**Why the adapter owns the batch.** Fastly's pending requests are not ordinary guest-reactor +futures, and its wait/select calls can block inside the host. Application code therefore cannot +construct one portable `FuturesUnordered` over four providers. The opaque batch driver keeps +pending handles, timer mechanics, abort guards, and provider-specific correlation out of the +application contract. + +**Fastly limitations.** Dynamic backend registration can delay later dispatch. A buffered upload +may continue after `send_async` returns without a finite teardown bound. Pending-handle selection +can identify which request became ready, but draining a selected buffered response body remains a +blocking operation and can delay observation of another ready response. These limitations are +reported by `outbound-batch-completion-order`, `outbound-batch-cancellation`, and +`outbound-batch-slot-isolation`; none is hidden by relabeling serial guest work as parallel. + +**Memory and partial failure.** Retained payload accounting remains the sum of bounded buffered +request and response bodies plus the documented in-flight chunk and provider exclusions. One +slot's validation, transport, timeout, HTTP status, or resource-limit outcome does not mutate any +other slot. `elapsed` is sampled when that slot becomes terminal, including preflight failures; +neither collection time nor cancellation time is copied into completed slots. + +### 3.3 Portable deadline + +#### 3.3.1 `Deadline` — portable value type, in core + +```rust +// crates/edgezero-core/src/time.rs (new module) + +/// Portable monotonic clock instant used by every public EdgeZero timing API. Downstream +/// crates do not need a direct `web-time` dependency to name or construct this value. +pub type MonotonicInstant = web_time::Instant; + +/// Cloneable clock source shared by one application, its admitted requests, and the +/// standard outbound clients installed by adapters. +#[derive(Clone)] +pub struct MonotonicClock { /* Arc MonotonicInstant + Send + Sync> */ } + +impl MonotonicClock { + pub fn new(now: Now) -> Self + where Now: Fn() -> MonotonicInstant + Send + Sync + 'static; + pub fn deadline_after(&self, duration: Duration) -> Deadline; + pub fn now(&self) -> MonotonicInstant; +} + +impl Default for MonotonicClock { /* web_time-backed production source */ } + +/// An absolute monotonic instant after which work should stop. A pure value type +/// — arithmetic over `MonotonicInstant`, identical on every target, with no +/// runtime dependency. `time.rs` contains `Deadline`, `DispatchBudget`, +/// `dispatch_budget`, and the public timing constants; the deliberate +/// constraint is that core carries **no runtime / timer / platform +/// dependency** — none of those types reaches outside the value-level +/// arithmetic and the trait surface adapters implement. +#[derive(Clone, Copy, Debug)] +pub struct Deadline(MonotonicInstant); + +impl Deadline { + /// `now + min(d, DEADLINE_FAR_FUTURE)`, where `DEADLINE_FAR_FUTURE` is a + /// **defined constant** clamp (7 days, see below). Bounded far-future clamping, + /// not "saturate to whatever MonotonicInstant::MAX happens to be" — `std::time::Instant` + /// has no `MAX` and platform overflow behaviour differs. The clamp is + /// finite and well above any realistic fan-out batch/proxy budget, so this never + /// truncates a legitimate caller and never panics. Adapter boundaries must + /// not crash the host. The internal `now + min(d, DEADLINE_FAR_FUTURE)` addition + /// itself uses the same saturating `now.checked_add(clamped).unwrap_or(now)` form + /// as `dispatch_budget`, so even the defensive case where the clamped add + /// would overflow the underlying `Instant` yields an already-expired deadline + /// (fails closed) rather than panicking. + pub fn after(duration: Duration) -> Self; + pub fn at_instant(instant: MonotonicInstant) -> Self; // construct from absolute instant + pub fn instant(&self) -> MonotonicInstant; // accessor for the absolute instant + pub fn is_expired(&self) -> bool; + pub fn is_expired_at(&self, now: MonotonicInstant) -> bool; + pub fn remaining(&self) -> Option; // None once passed + pub fn remaining_at(&self, now: MonotonicInstant) -> Option; +} + +/// Hard upper bound on any caller-supplied duration. The clamp exists so +/// `Deadline::after` and `dispatch_budget` cannot panic on a pathological +/// `Duration::MAX` input. Set to **7 days** rather than something larger so the +/// ceiling fits inside every supported platform's per-request timeout range — in +/// particular Fastly's backend timeouts are `u32` milliseconds (≈ 49.7 days max +/// per Fastly 0.13.1), so the EdgeZero clamp must stay well below that. 7 days +/// is still orders of magnitude above any realistic outbound budget; nobody hits +/// it legitimately. +pub const DEADLINE_FAR_FUTURE: Duration = Duration::from_hours(168); // 7 days; from_hours, not from_secs(7*24*60*60), which trips clippy::duration_suboptimal_units +``` + +Every standard adapter entry clones the exact `App::monotonic_clock()` used to capture +ingress timing into the outbound client inserted in request extensions. Axum exposes +`try_with_clock`; Cloudflare, Fastly, and Spin expose `with_clock`. Default low-level +constructors remain convenient and explicitly use `MonotonicClock::default()`. +They are not app-clock propagation paths. Cloudflare and Spin's former unit-struct literal +construction is intentionally removed by this hard cut; low-level callers migrate to +`CloudflareOutboundClient::new()` / `SpinOutboundClient::new()` or `Default` so every client +has an explicit clock source. + +Once `send` or `start_batch_until` begins, every EdgeZero-owned outbound observation uses that stored +clock: method-entry and terminal slot samples, dispatch budget selection, preparation and +provider error precedence, pre/post-ready checks, and streamed upload/response checks. A +deferred body stream owns a clock clone; it never falls back to process-global time after +`send` returns. Each returned `OutboundResponse` also owns that clock clone, so +`into_bytes_bounded_until` and `json_bounded_until` remain in the same time domain after adapter +return. Provider timers still supply target-specific preemption, but the duration +armed into them comes from the same paired clock snapshot. Later remaining-budget reads use +`min(budget.deadline.remaining_at(clock.now()), budget.duration)`, so a backwards injected +clock cannot enlarge the budget selected at method entry. Checked elapsed subtraction maps a +backwards terminal sample to the existing zero-elapsed internal invariant outcome. + +#### 3.3.2 Mapping an external batch observation deadline to EdgeZero deadlines + +| External concept | EdgeZero mechanism | +| --- | --- | +| External batch deadline (whole fan-out) | Compute one absolute observation cutoff at handler entry with `RequestContext::monotonic_clock().deadline_after(duration)` and pass it once to `start_batch_until(reqs, observation_cutoff)`. Do not use process-global `Deadline::after` with an injected app clock, and do not copy the observation cutoff into each request. | +| Optional request-specific absolute deadline | `OutboundRequest::deadline(request_deadline)` | +| Per-target request timeout | `OutboundRequest::timeout(per_target)` | +| Effective per-request budget | minimum of request timeout, request deadline, observation cutoff, and the default safety budget where applicable | + +**Effective budget rule (`dispatch_budget(req)`).** Returns a `DispatchBudget` struct +carrying **both** the duration to feed to platform SDK timeouts AND the absolute +`Deadline` to use for cooperative body-phase `is_expired()` checks. The implementation +computes a single set of candidate **absolute** deadlines from one monotonic `now` +snapshot and takes the minimum — so the effective deadline can never extend an +original `req.deadline`, and "no deadline" never gets conflated with "expired +deadline" via an `Option` round-trip. + +```rust +pub struct DispatchBudget { + pub cause: BudgetSource, // WHICH input set the effective deadline (for attribution) + pub deadline: Deadline, // effective absolute deadline + pub duration: Duration, // SDK timeout setting +} + +/// Records which configured input selected the effective deadline. The +/// per-call timeout, request deadline, and observation cutoff are separate inputs (§3.3.2 table); +/// the effective deadline is the tightest candidate, and `cause` +/// remembers which one won. This is provenance, not the physical timer phase and not +/// proof that the named deadline itself expired. +// ONE definition, shared by `DispatchBudget` (here) and `EdgeError::GatewayTimeout` +// (§3.4.3). **Defined in `error.rs` (Phase 1a Task 1)** — NOT the `time` module — because +// `error.rs` (Task 1, committed/built first) NAMES it in `GatewayTimeout`, so it must +// exist in Task 1's deliverable or the Task-1 commit fails to build. `time.rs` (Task 2) +// and `dispatch_budget` (Phase 1b) `use crate::error::BudgetSource;`. +// DERIVES + ORDER are COMPILE-VERIFIED (a throwaway crate under `arbitrary_source_item_ordering`): +// - `Debug` — `EdgeError` derives `Debug` and contains `cause`. +// - `Clone`, `Copy` — budget/error carriers pass provenance by value. +// - `PartialEq, Eq` — the Phase 1a contract tests assert `cause == Unspecified` etc. +// - Variants are **alphabetical** — the denied `clippy::arbitrary_source_item_ordering` +// rejects any other order (verified: the earlier `PerCallTimeout`-first order errored). +/// Which budget INPUT produced the effective deadline (the tightest bound) — the budget +/// SOURCE, NOT the physical phase-timer that fired. On Fastly the per-phase timers +/// (connect/first-byte/between-bytes) are sub-divisions of the budget; when one fires the +/// timeout is still attributed to this source (documented `BestEffort` — §3.5.2 footnote 5), +/// so `RequestDeadline` or `BatchCutoff` may be reported for a connect-phase-slice expiry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +// `#[non_exhaustive]`: a future phase/reason variant must stay non-breaking (public enum). +#[non_exhaustive] +pub enum BudgetSource { + /// The `start_batch_until` observation cutoff was the tighter bound. + BatchCutoff, + /// Neither timeout nor deadline was set — `DEFAULT_NO_DEADLINE_BUDGET` (30 s) applies. + Default, + /// The per-call `OutboundRequest::timeout` was the tighter bound. + PerCallTimeout, + /// The request's own absolute `OutboundRequest::deadline` was the tighter bound. + RequestDeadline, + /// A timeout with no proven dispatch-budget source: either raised outside a dispatch + /// budget (for example `json_bounded_until`) or reported early by a provider timer + /// that the adapter cannot attribute. The default a bare `gateway_timeout(msg)` carries. + Unspecified, +} + +/// `now` is passed in (not snapshotted internally) so one batch can use +/// **one** `now` snapshot across every slot. Without that, sequential per-slot +/// stored-clock calls produce slightly different `duration` values for the same +/// shared `Deadline`, which on Fastly would produce different `budget_ms` values +/// and therefore different dynamic-backend identities for the same host under one +/// batch deadline. `send` (single request) passes its outbound client's first +/// `clock.now()` sample. +// PRIVACY + NAME-COLLISION CONTRACT (both verified by compiling a skeleton). +// `time.rs` is a sibling module of `outbound.rs` and cannot read `OutboundRequest`'s +// private fields directly — earlier pseudocode did, which does not compile. +// **Crucially, the accessors CANNOT be named `timeout` / `deadline` / +// `max_response_bytes`**: those names are already taken by the PUBLIC BUILDER SETTERS +// ( — `pub fn timeout(self, duration: Duration) -> Self`, etc.). Rust does not overload +// inherent methods, so a same-named getter on the same type is a hard `E0592` +// duplicate-definition error. The inputs are therefore exposed through **one** +// crate-visible accessor returning a struct — no name clash with any setter: +// +// pub(crate) struct BudgetInputs { +// pub timeout: Option, +// pub deadline: Option, +// } +// impl OutboundRequest { +// pub(crate) fn budget_inputs(&self) -> BudgetInputs; +// } +// +// (`pub(crate)` — an internal contract between `outbound.rs` and `time.rs`, not app +// surface. Colocating `dispatch_budget` inside `outbound.rs` is rejected: `time.rs` +// must stay independently unit-testable and free of platform-shaped code.) The +// pseudocode below reads `let inputs = req.budget_inputs;` then `inputs.timeout` / +// `inputs.deadline` — **never** `req.timeout` (field) or `req.timeout` (setter). +// Lint contract (workspace denies `clippy::restriction`): public items need +// `#[inline]` (`missing_inline_in_public_items`); no single-char idents +// (`min_ident_chars`) — hence `duration`/`candidate`, never `d`; no bare arithmetic +// (`arithmetic_side_effects`) — hence `checked_add`; no `expect`/`unwrap` +// (`expect_used`/`unwrap_used`); items alphabetical (`arbitrary_source_item_ordering`). +#[inline] +pub fn dispatch_budget( + req: &OutboundRequest, + now: MonotonicInstant, + observation_cutoff: Option, +) -> Result { + let inputs = req.budget_inputs(); // single crate-visible accessor (see contract above) + + // (1) Candidate absolute deadlines. Build candidates directly from the single `now` + // snapshot; do NOT round-trip through `Deadline::remaining()`. An expired caller + // deadline remains a real (past) candidate, while absence remains no candidate, so + // "no deadline" and "expired deadline" cannot collapse into the same `None`. + // Use checked_add throughout — a caller- + // supplied Duration::MAX must not panic the adapter. The same clamp as + // Deadline::after: cap the duration at DEADLINE_FAR_FUTURE + // *before* the add, so the addition itself never overflows in practice + // (now + 7 days is well within Instant range). checked_add on the + // clamped value is belt-and-suspenders. + let saturating = |duration: Duration| -> Deadline { + let clamped = duration.min(DEADLINE_FAR_FUTURE); + let inst = now.checked_add(clamped).unwrap_or(now); // last-resort: now (immediate) + Deadline::at_instant(inst) + }; + let from_timeout = inputs.timeout.map(&saturating); + // `Deadline::at_instant` is public, so a caller could construct a + // Deadline well past DEADLINE_FAR_FUTURE and bypass Deadline::after's clamp. + // Re-clamp `from_caller` here: the caller's deadline is never honoured beyond + // `now + DEADLINE_FAR_FUTURE`. This only tightens; a caller's deadline closer + // than that is unaffected. + let from_caller = inputs.deadline.map(|deadline| { + let far = now.checked_add(DEADLINE_FAR_FUTURE).unwrap_or(now); + Deadline::at_instant(deadline.instant().min(far)) + }); + let from_observation_cutoff = observation_cutoff.map(|deadline| { + let far = now.checked_add(DEADLINE_FAR_FUTURE).unwrap_or(now); + Deadline::at_instant(deadline.instant().min(far)) + }); + let from_default_only = + (inputs.timeout.is_none() && inputs.deadline.is_none()) + .then(|| saturating(DEFAULT_NO_DEADLINE_BUDGET)); + + // (2) Effective deadline = min of the candidates (always at least one). + // NOTE: no `.expect(..)` — `clippy::expect_used` is DENIED in production code + // (the workspace denies the whole `restriction` group; the test exemption in + // clippy.toml does not apply here). The "unreachable by construction" case + // becomes an explicit invariant error instead of a panic, which is also the + // rule that adapter/core boundaries never crash the host. + // Tag each candidate with the BudgetSource it represents, pick the tightest, and + // CARRY the cause. On an exact-instant tie the iteration order wins. The batch observation + // cutoff is first so equality leaves the slot unresolved. Among request-owned + // constraints, a per-call timeout precedes the absolute request deadline. + let (cause, deadline) = [ + from_observation_cutoff.map(|deadline| (BudgetSource::BatchCutoff, deadline)), + from_timeout.map(|deadline| (BudgetSource::PerCallTimeout, deadline)), + from_caller.map(|deadline| (BudgetSource::RequestDeadline, deadline)), + from_default_only.map(|deadline| (BudgetSource::Default, deadline)), + ] + .into_iter() + .flatten() + .min_by_key(|(_, deadline)| deadline.instant()) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "dispatch_budget: no deadline candidate — invariant violated (adapter bug)" + )) + })?; + + // (3) Duration is derived from the chosen deadline and the same now snapshot + // — never `Deadline::after(duration)`, which would re-anchor to a *later* + // now and could extend the absolute deadline past the caller's intent. + let duration = deadline.instant().saturating_duration_since(now); + if duration.is_zero() { + // `cause` was just computed above — attribute the zero-budget timeout to it. + return Err(EdgeError::gateway_timeout_caused("effective budget is zero", cause)); + } + + Ok(DispatchBudget { duration, deadline, cause }) +} +``` + +**Timeout provenance — the outcome carries the selected budget input, not the timer phase.** +Because `DispatchBudget` records `cause`, every timeout an adapter (or the pre-dispatch +check) raises carries the effective `budget.cause`. `BudgetSource::RequestDeadline` means +the request-carried absolute deadline was tightest; `BatchCutoff` means the observation cutoff +was tightest. Neither alone proves that its absolute instant has elapsed: Fastly may fire a rigid connect/first-byte phase timer +while that absolute deadline is still live (§4.3). It is not a retry classification, +physical timeout phase, or batch-abandonment signal. A timeout says only that EdgeZero +stopped waiting, while the origin may already have committed the effect. The provenance is +carried as a typed field on the error: +`EdgeError::GatewayTimeout { message, cause: BudgetSource }` (§3.4.3) — NOT a message string +to be parsed. A slot that times out is raised via `gateway_timeout_caused(msg, +budget.cause)`; a timeout outside a budget context uses `gateway_timeout(msg)`, whose cause +is `Unspecified`. `dispatch_budget` selects provenance before its zero-duration return, so +an already-expired request deadline is `RequestDeadline` unless a per-call zero timeout has +the same absolute instant; the documented equality rule then selects `PerCallTimeout`. +The zero-effective-budget timeout carries that selected cause. +`BudgetSource::BatchCutoff` attribution alone never emits `OutboundBatchDriverEvent::Cutoff`; +`OutboundBatchDriverEvent::Cutoff` may be emitted only when an observation from the batch's +monotonic clock shows that the absolute observation cutoff has expired; equality is expired. +An earlier Fastly phase timeout remains that slot's terminal `GatewayTimeout` item and does not +discard siblings. + +**This is NORMATIVE for EVERY adapter, not just Fastly.** Every EdgeZero-owned budget timer +that expires — the `reqwest` timeout on **Axum**, the `worker::Delay` expiry on +**Cloudflare**, the raced wasi-timer on **Spin**, Fastly's explicitly configured +connect/response phase timers, and the **streamed-response wrapper's** `gateway_timeout` +chunk on all four — MUST use `gateway_timeout_caused(msg, budget.cause)`, NOT bare +`gateway_timeout`. A provider timeout not configured from the selected budget is different: +the pure classifier receives the absolute deadline and observation instant. At/after that +deadline, deadline precedence attributes the result to `budget.cause`; before it, an +independent provider timeout uses `gateway_timeout(msg)` and `BudgetSource::Unspecified`. +Spin applies that early rule to all five WASI timeout variants because the binding cannot +prove which accepted option caused a result. Fastly applies it to `DnsTimeout`, while its +configured `ConnectionTimeout` and `HttpResponseTimeout` phase timers remain attributed. +The deadline-aware stream wrapper is constructed with `budget.cause` so its past-deadline +chunk carries it. **§5.4 asserts the attributed cause +through ACTUAL adapter results** (not just the core helper): for each of Axum/CF/Spin, a +`.timeout(short).deadline(long)` expiry yields a `PerCallTimeout`-attributed harvested error +and the mirror yields `RequestDeadline`; a no-deadline default yields `Default`. §5.4 pins a Tier 1 test: +`.timeout(short).deadline(long)` that expires yields a `PerCallTimeout`-attributed error, +and `.timeout(long).deadline(short)` yields a `RequestDeadline`-attributed one — the two are +distinguishable from the harvested result alone. + +Behaviour table (the implementation gives these directly; listed here for clarity): + +All `now + t` entries in this table are shorthand for `now + min(t, +DEADLINE_FAR_FUTURE)` (§3.3.1) — the clamp is universal, not a special case for +`Duration::MAX`. + +Below, `clamped(d)` denotes `Deadline::at_instant(d.instant().min(now + +DEADLINE_FAR_FUTURE))` — the re-clamp of a caller's `req.deadline` performed by +`dispatch_budget` so a `Deadline::at_instant` constructed past the 7-day clamp +cannot escape the bound (§3.3.2 step 1). For brevity the table writes +`clamped(d)` rather than the full expression. + +| `req.timeout` | `req.deadline` | `duration` | `deadline` (absolute) | +| --- | --- | --- | --- | +| `None` | `None` | `30 s` | `now + 30 s` | +| `Some(t)` | `None` | `min(t, DEADLINE_FAR_FUTURE)` | `now + min(t, DEADLINE_FAR_FUTURE)` | +| `None` | `Some(d)` | `clamped(d).instant() - now` | `clamped(d)` | +| `Some(t)` | `Some(d)` with `now + min(t, …) ≤ clamped(d).instant()` | `min(t, …)` | `now + min(t, …)` (tighter) — **cause `PerCallTimeout`; EQUALITY goes HERE** | +| `Some(t)` | `Some(d)` with `now + min(t, …) > clamped(d).instant()` | `clamped(d).instant() - now` | `clamped(d)` (strictly tighter) — cause `RequestDeadline` | +| any nonzero/absent timeout | expired (`d.instant() <= now`) | — | `Err(gateway_timeout)` with cause `RequestDeadline` | +| `Some(Duration::ZERO)` | `Some(d)` with `d.instant() == now` | — | `Err(gateway_timeout)` with cause `PerCallTimeout` (the equality tie rule) | +| any | selected duration ends up zero | — | `Err(gateway_timeout)` with the selected cause | +| `Some(Duration::MAX)` | `None` | `DEADLINE_FAR_FUTURE` (7 d) | `now + DEADLINE_FAR_FUTURE` | +| `None` | `Some(d)` 100 years out via `at_instant` | `DEADLINE_FAR_FUTURE` (7 d) | `now + DEADLINE_FAR_FUTURE` | + +`.timeout(50ms)` with no request deadline therefore yields `duration = 50ms` and +`deadline = now + 50ms`, **not** 30 s. The single absolute `deadline` is what Fastly's +between-chunk checks (§3.3.4) and the streamed-body wrappers in §4.1/§4.2/§4.4 use, so +per-request `timeout` is honoured across the entire exchange — including the streamed +body phase — whether or not a request deadline was provided. A batch driver also supplies its +observation cutoff to `dispatch_budget`. When `BatchCutoff` wins and expires, the driver ends with +that slot unresolved rather than manufacturing a per-slot timeout item. A tighter request +timeout or request deadline still produces its ordinary terminal timeout before the observation cutoff. + +"No deadline configured" therefore differs from "deadline configured and expired" — +the former is bounded by the synthetic 30 s ceiling; the latter is a hard fail at +dispatch with `gateway_timeout`. + +The same rule governs the dispatch+headers phase in `Streamed` mode. The body phase is +**also** governed by `dispatch_budget(req).deadline` (see §3.3.3) — the spec +deliberately does +not split the deadline into "before headers" and "after headers" pieces. + +#### 3.3.3 What the deadline covers + +The deadline on `OutboundRequest` covers the **entire upstream exchange** in both modes: +request preparation/upload, dispatch, response headers, and the upstream response-body +read/completion protocol. It ends when the core `OutboundResponse` has been fully buffered +or its Streamed body reaches validated native EOF. It does **not** include the later write of +a resulting core `Response` to the downstream client. That response-egress lifetime needs +its own absolute write deadline, abort semantics, and exactly-once completion contract +(§1.3); an outbound deadline must never be silently reused or restarted for it. The +adapter captures one monotonic `entry_now` as the first operation inside `send`, or one +`batch_now` as the first operation inside `start_batch_until`, **before** request normalization, +batch preflight, platform builder construction, or any other preparation. Validation still +runs before `dispatch_budget` so the portable bad-request precedence is unchanged, but every +valid request computes its budget from that entry snapshot and the optional observation cutoff. No +adapter re-anchors after preflight. Clock-seam tests advance time during preparation and prove it +consumes the same absolute budget; `start_batch_until` additionally proves every valid slot shares +exactly one snapshot. +The upstream mechanism differs: + +- **`Buffered` (default):** the adapter buffers the body *inside* the deadline-bounded + region, so a slow body counts against the budget. `Ok(resp)` from `send`, or a yielded + successful batch item, means the full exchange completed within the applicable deadline. +- **`Streamed`:** `Ok(resp)` is returned once headers arrive — earliest possible + delivery — but the **body stream returned in `resp` is adapter-wrapped to honour + `dispatch_budget(req).deadline`.** That deadline is the *effective* one computed by + the budget rule (§3.3.2), which is non-`None` even for timeout-only and no-deadline + requests — adapters wrap the body stream in every case, not only when + `req.deadline.is_some()`. Axum and Cloudflare provide timer-backed cancellation, + though Cloudflare remains `BestEffort` until deployed host-observed evidence establishes + a finite cancellation bound; + Spin races a monotonic timer but has only cooperative Component Model teardown + (BestEffort, footnote 8); Fastly checks cooperatively between host reads. Every + adapter surfaces deadline expiry as a typed `gateway_timeout`; the capability + matrix states where host work can outlive that result. + +What this means in practice: + +- `OutboundResponse::into_bytes_bounded(max)` on a streamed body already honours the + effective-budget deadline through the wrapped stream — body chunks past the + deadline yield `gateway_timeout`. +- `OutboundResponse::into_bytes_bounded_until(max, deadline)` is for tightening the + bound below the effective-budget deadline (e.g. an inner budget for body-only) — + not for re-applying the same deadline, which is automatic. +- If the caller dropped the `Deadline` value but still wants the same effective + ceiling, passing `Deadline::after(remaining_budget_from_some_source)` works; or + just call `into_bytes_bounded` and trust the wrapped stream. + +This is one upstream contract for everyone: handlers never have to remember "Streamed cuts +the deadline at headers." Adapter notes (§4.1–§4.4) implement it through validated upstream +completion. + +#### 3.3.4 Per-adapter enforcement (`Buffered` mode) + +| Adapter | Mechanism | Strength | +| --- | --- | --- | +| Axum | `reqwest::RequestBuilder::timeout(effective)` — reqwest applies it through response-body read | Real, whole-operation | +| Cloudflare | race the adapter-private `fetch_raw_with_signal` (+ body drain) against `worker::Delay::from(effective)`; final fetch options carry manual response encoding and the abort signal (§4.2); the guard aborts on expiry | Real, whole-operation with cancellation | +| Spin | race the entire `send_one` future (send **and** body collect) against a WASI monotonic-clock timer; drop the guest future on expiry | Guest-visible 504 at the timer; host teardown is cooperative and therefore `BestEffort` (footnote 8) | +| Fastly | host phase timers split per §4.3 (`connect = budget/4`, `first_byte = 3*budget/4`, `between_bytes = budget`); during body drain, `budget.deadline.is_expired()` is checked **after every blocking body read returns, including the EOF read** (the synthetic 30 s deadline applies when no caller deadline was set); the host between-bytes timeout bounds each gap | Warm-path connect/headers have a documented phase split and returned body reads have a cooperative bound; the end-to-end capability remains `BestEffort` because cold registration and request-write gaps are unbounded | + +**Drop-cancellation guarantee, per adapter (what happens to a LOSING arm).** A fan-out +consumer under deadline pressure needs to know whether a timed-out/deadline-lost send is +actually *aborted* or merely *stopped-being-waited-on* — "harvest returns" ≠ "the pending +request is cancelled." The guarantee: + +| Adapter | On deadline/timeout, the in-flight send is… | Origin observes cancel? | +| --- | --- | --- | +| **Axum** | the request/body future and EdgeZero source are dropped; Reqwest/Hyper performs protocol-specific transport cleanup | HTTP/1 may close the connection; HTTP/2 resets the stream while retaining the pooled connection. No generic connection-drop or bounded origin-observation claim | +| **Cloudflare** | **cancelled** — `controller.abort()` (NOT a bare future-drop, which would leave the subrequest running) | Yes (§5.3 blocking test) | +| **Spin** | guest future is dropped and Component Model cancellation is requested; completion writers default to `Err` | Not guaranteed within a finite bound; host-observed tests are upgrade evidence (footnote 8) | +| **Fastly** | **No bounded origin cancellation guarantee.** Buffered batches retain adapter-private pending handles and select completed headers only after every eligible slot has been issued. Dropping the batch drops unselected handles and stops guest observation, but the SDK does not establish finite host teardown; once a selected response enters a blocking body read, guest cancellation cannot preempt that read. Single-`send` streamed uploads follow §4.3's upload protocol: source/cap failure, deadline expiry, write/flush/finish failure, and future drop can exit without waiting for a response. Dropping unfinished upload handles aborts that guest protocol but likewise does not establish a finite host-teardown bound. Host phase timers can fail a request; a **sibling slot's** deadline never cancels another slot | **No finite bound** — documented BestEffort limitation | + +Axum gives a bounded guest-visible timeout and deterministic future/source drop, while its +wire cleanup remains protocol-dependent. Cloudflare gives explicit bounded abort through +`AbortController`. Spin and Fastly expose different BestEffort gaps: Spin requests +cooperative teardown without a documented host bound, while Fastly exposes no guest +cancellation primitive for a dispatched request. Axum loopback tests distinguish HTTP/1 +connection closure from HTTP/2 stream reset; optional deployed HTTP/2/HTTP/3 +characterization records what intervening proxies and origins observe without promoting +that observation into a portable guarantee. + +**Fastly precision, stated honestly.** Fastly has no guest wall-clock primitive to +preempt a chunk read in progress. At dispatch the adapter computes `let budget = +dispatch_budget(req, now, observation_cutoff)?` (§3.3.2, where `observation_cutoff` is `None` for a +single `send` and `Some(observation_cutoff)` for a batch; `now` is snapshotted inline for single `send` +and passed in as the shared `batch_now` for `start_batch_until`. `DEFAULT_NO_DEADLINE_BUDGET = 30 s` +and the synthetic absolute deadline both apply when no deadline is set, identical to +every other adapter) and derives the host timeouts via the named helper: + +```rust +// Lint contract: the workspace DENIES `clippy::restriction`, so `as_conversions` +// and `arithmetic_side_effects` are hard errors — no `as` casts, no bare `+`/`-`/`/`. +// Use `Duration::as_millis` (already integer-ms), saturating/checked arithmetic, and +// `u64::try_from` instead of `as u64`. +// Returns the EXACT host-timeout ms (true ceil-to-ms) — the single source used for BOTH +// the phase split (connect/first-byte/between-bytes) AND the backend identity key, so a +// cached backend's timers always match the identity it was registered under. No +// bucketing: the cache is per-session and bounded by the fan-out size, so the raw value +// is fine and keeps the deadline bound exact. +fn fastly_timeout_ms(budget: &DispatchBudget) -> u64 { + // True ceil-to-ms — never floor a sub-ms remainder away. + // `as_millis` floors, so add 1 when there is a sub-ms remainder. + let nanos = budget.duration.subsec_nanos(); + let has_remainder = !nanos.is_multiple_of(1_000_000); + let ceil_ms = budget + .duration + .as_millis() + .saturating_add(u128::from(has_remainder)) + .max(1); + + // The DEADLINE_FAR_FUTURE clamp keeps this below Fastly's 2^32 ms ceiling. Clamp + // defensively, then convert fallibly — a bug elsewhere must not crash the host, + // and a bare `as` cast is forbidden by the lint. + let ceiling = u128::from(u32::MAX).saturating_sub(1); + let clamped = ceil_ms.min(ceiling); + u64::try_from(clamped).unwrap_or(u64::from(u32::MAX).saturating_sub(1)) +} + +// `dispatch_budget` always takes an explicit `now` and optional observation cutoff. Public `send` +// snapshots at method entry before preflight; `start_batch_until` snapshots once into +// `batch_now` at method entry and reuses it +// across slots so the dynamic-backend identity stays consistent for a shared +// caller Deadline. +let now = self.clock.now(); // single `send`; batch passes batch_now +let budget = dispatch_budget(req, now, None)?; + +// Fastly 0.13.1 exposes the timeout setters on BackendBuilder, NOT on Request — see +// https://docs.rs/fastly/0.13.1/fastly/backend/struct.BackendBuilder.html. +// IMPORTANT: connect_timeout and first_byte_timeout are *separate* phase timers +// per Fastly's docs — connect bounds DNS+TCP+TLS setup; first_byte bounds the gap +// from "request sent" until headers are received. Setting both to the same `t` +// would make the dispatch+headers worst case ~2*t, breaking the absolute-deadline +// bound. We therefore SPLIT the budget across the two phases (and the third, +// between-bytes, which only applies once chunks are flowing during body drain), +// keeping the sum exactly equal to total_ms: +// total_ms = ceil-to-ms(budget.duration) +// connect_ms = total_ms / 4 [floor; most connects take <100ms] +// first_byte_ms = total_ms - connect_ms [remainder; sum invariant] +// between_ms = total_ms [body-phase ceiling unchanged] +// Sub-4 ms degenerate case: both = total_ms (sum = 2*total_ms, documented). +// SSL configuration also lives on BackendBuilder: `use_ssl` defaults to false, so +// HTTPS targets MUST opt in explicitly with .enable_ssl() and configure SNI + +// certificate verification (implemented by the dynamic-backend builder in +// crates/edgezero-adapter-fastly/src/outbound.rs). HTTP targets opt out via +// .disable_ssl(). +// +// Five canonicalized values come from the OutboundRequest accessors — +// adapters MUST consume these, never re-derive from `req.uri`): +// - `req.backend_target` — connection target `"host:port"` with the +// resolved port; passed as the +// BackendBuilder's `target` arg. +// (implemented by `OutboundRequest::backend_target`). +// - `req.host_authority` — authority for `.override_host(..)` +// (carries the explicit port only when +// non-default; preserves Host +// semantics). +// - `req.host_name` — canonical host only, with no port or IPv6 brackets; +// used in `BackendIdentity`. +// - `req.sni_hostname` — `Option<&str>`. `Some(host)` for DNS-name HTTPS +// targets; `None` for IP-literal HTTPS (RFC 6066 +// forbids SNI for IP literals). When `None`, the +// adapter omits `.sni_hostname(..)` entirely; it +// does NOT fall back to `req.uri.host`. +// - `req.cert_host` — `Option<&str>`. `Some(host)` for any HTTPS target +// (DNS name OR IP literal — port-stripped, +// bracket-stripped); `None` for non-HTTPS schemes. +// Passed to `.check_certificate(..)` verbatim; the +// adapter does NOT bracket-trim, parse, or +// post-process. +// Phase split. The documented semantics: connect gets a *floor quarter* of the +// already-ceiled total; first_byte gets the remainder; between_bytes gets the full +// budget. Invariant we want: connect_ms + first_byte_ms == total_ms exactly, so +// the worst-case dispatch+headers wall-clock is bounded by `budget.duration` +// (modulo ms rounding). Using `total_ms / 4` (floor) keeps the sum exact; the +// earlier "ceil-to-ms of budget * 1/4" framing was a misnomer — that would have +// made the sum exceed total_ms by up to 1 ms for some inputs. For tiny budgets +// where the 1/4 share would round to 0, we degenerate to "both = total_ms" — +// the absolute-deadline bound becomes 2*total_ms but at sub-4 ms scale this is +// negligible (and the ceil-to-ms rounding already dominates). +let total_ms = fastly_timeout_ms(&budget); // exact ceil-to-ms of budget.duration +let (connect_ms, first_byte_ms) = if total_ms < 4 { + (total_ms, total_ms) // sum = 2*total_ms; documented +} else { + let connect = total_ms / 4; // floor — keeps sum exact + let first_byte = total_ms - connect; // sum = total_ms exactly + (connect, first_byte) +}; +let between_ms = total_ms; +let mut builder = Backend::builder(&backend_name, &req.backend_target()) + .connect_timeout(Duration::from_millis(connect_ms)) + .first_byte_timeout(Duration::from_millis(first_byte_ms)) + .between_bytes_timeout(Duration::from_millis(between_ms)) + .override_host(req.host_authority()); +// TLS handling — the accessors carry the canonicalized split. We do NOT +// inspect `req.uri` directly: `cert_host` returns `Some` iff the scheme is +// HTTPS (the adapter-local "is TLS?" question), and `sni_hostname` carries +// the DNS-vs-IP-literal distinction (`None` for IP literals per RFC 6066). +builder = match req.cert_host() { + Some(cert) => { + // HTTPS: always set .check_certificate(..). Pass req.cert_host + // through unmodified — bracket-stripping for IPv6 is already done in + // the accessor; we never call .trim_start_matches('['). + let mut b = builder.enable_ssl().check_certificate(cert); + // SNI: only when the accessor returns Some (DNS-name host). + // For IP literals (`None`).sni_hostname is omitted entirely. + if let Some(sni) = req.sni_hostname() { + b = b.sni_hostname(sni); + } + b + } + None => builder.disable_ssl(), // HTTP +}; +let backend = builder.finish()?; +// Fastly's Request public API has no `with_backend`. The backend is passed as +// the argument to `send` / `send_async` / `send_async_streaming` at send time +// (each accepts `impl ToBackend`). `Backend` implements `ToBackend`. +// Buffered request body (send_all_until only — preflight rejected streams): +let pending = fastly_req.send_async(&backend)?; +// Streamed request body (single `send` only): +// let (streaming_body, pending) = fastly_req.send_async_streaming(&backend)?; +``` + +The dynamic-backend identity tuple (§4.3) is `scheme + ":" + host + ":" + +resolved_port + ":" + tls_mode + ":" + budget_ms`, where `tls_mode` is derived from +`req.uri().scheme_str()` and `budget_ms = ceil-to-ms(budget.duration)` — the same +`total_ms` that drives the `connect_ms / first_byte_ms / between_ms` deterministic +phase split above. The cached `Backend` and a freshly-requested one therefore always +carry identical timeouts AND identical SSL configuration because both are +deterministic functions of the same tuple. The implementation in +`crates/edgezero-adapter-fastly/src/outbound.rs` applies the SSL setters to every dynamic +backend. The budget is set once +before `send_async` and not mutated afterwards — the Fastly SDK does not expose +dynamic per-chunk timeout updates. During body drain the adapter checks +`budget.deadline.is_expired()` **after every blocking body read returns, including +the EOF read** (per the §3.3.4 rule — the earlier "between chunks" wording was +incomplete because a final EOF read can itself cross the deadline). Because +`dispatch_budget` always returns a concrete `Deadline` (synthetic if the request +had none), this cooperative check works uniformly whether or not the caller +supplied a deadline. +`connect-timeout` and `first-byte-timeout` together bound the dispatch+headers phase +at `budget.duration` (their sum, by the §4.3 split) **when `total_ms ≥ 4`**; for +`total_ms < 4` the code degenerates to `connect = first_byte = total_ms` and the +sum is `2 * total_ms`. The absolute-deadline guarantee in the sub-4 ms branch is +therefore "≤ `total_ms + BATCH_DISPATCH_SLACK_MAX + ms_rounding` past deadline" +(strict upper bound: `BATCH_DISPATCH_SLACK_MAX + total_ms + ms_rounding` +which is `25 + (≤ 3) + (≤ 1) < 29` ms), not the common-case "≤ 26 ms" — see +the two explicit +branches in §4.3 "Net guarantee." Sub-4 ms outbound budgets are degenerate inputs +where ms-rounding already dominates, not a normal operating point. The documented trade-off (§4.3) is that a request +spending more than `budget/4` on the configured connect phase (TCP+TLS) fails at the +connect timer even if the remaining budget would have sufficed for headers; that +is captured by the separate `outbound-flexible-phase-budget` capability (§3.5.1). +DNS is excluded from this causal claim because the pinned SDK exposes no DNS-timeout +setter; live observations may characterize provider behavior but do not turn it into a +portable configured-timer guarantee. +During body drain (post-`wait()`), the adapter checks `budget.deadline.is_expired()` +**after every blocking body read returns, including the EOF read** (not "between +chunks" — the EOF read can itself block past the deadline and would otherwise +slip through with `Ok(resp)`). On the first expired check the slot is aborted +with `gateway_timeout`; each individual chunk-gap (including the gap before EOF) +is bounded by the host `between-bytes-timeout`. So the Buffered `Ok(resp)` +contract — "headers AND body completed within the deadline" — holds **for the response +phase**: either every read (including EOF) observed `!is_expired()`, or the slot returned +`gateway_timeout`. **The REQUEST-transmission phase is not covered by this argument** — no +Fastly timer bounds the guest-to-origin write (footnote 2), which is why Fastly's +`outbound-deadlines` is `BestEffort` rather than `Native`. + +**Slot-level vs. wall-clock-observed completion.** The response-side bound above begins +only after a slot's sequential `send_async` dispatch has returned. A cold backend +registration can block before that call and prevent later dispatches. A buffered request +upload also has no finite host-write completion bound, but pinned Fastly 0.13.1 +`send_async` returns as soon as sending begins and continues transmitting headers/body in +the background; that upload therefore cannot synchronously prevent the guest from issuing +later `send_async` calls. It can leave its own pending handle unresolved. For slots that have +been issued, the Fastly host runs requests independently and applies each slot's configured +response timeouts. + +After dispatch, EdgeZero passes pending handles to `select_handles` in bounded groups. The +selected position and raw handle identity restore the original slot index even when the host +reorders the returned remaining handles. This removes input-order header blocking: a ready slot +in the active group can be observed while another remains pending. It does not make the whole +exchange concurrent in the guest. Once a response head is selected, EdgeZero drains that one +buffered body synchronously before selecting another handle. Therefore: + +- **Per-slot headers after dispatch:** each dispatched slot's connect / first-byte / + between-bytes timers derive from its own `budget.duration`, and the host enforces them + independently. This does not cover a later slot that cold registration prevented from being + dispatched, nor does it prove exact completion order across groups larger than + `fastly_shared::MAX_PENDING_REQS`. +- **Observed completion order:** the adapter yields the slot selected by the host, paired with its + original index. Preflight and synchronous dispatch failures are queued before selected host + completions. Provider selection order, bounded grouping, and synchronous selected-body drain + make `outbound-batch-completion-order` `BestEffort` on Fastly rather than `Native`. +- **Buffered body drain is serialized by selection, not input order.** While one selected body is + draining, other completed headers cannot be observed by the guest. A later-observed slot can + consequently cross its own request deadline or the observation cutoff even if its response would + have completed within that bound when drained in isolation. A request-deadline expiry is a + terminal attributed 504; an observation-cutoff expiry leaves that slot unresolved. Small response + bodies reduce this serial drain term, but the contract assigns no universal threshold because + host-call timing is deployment-specific. + +There is deliberately no finite whole-batch wall-clock bound on Fastly. Dispatch may include cold +registration, request writes have no finite completion bound, `select_handles` is a blocking host +operation, and selected bodies drain one at a time. Once one selected response is actively +draining, however, the worst-case post-deadline overshoot for an individual blocking read is one +between-bytes-timeout interval, which is no greater than that slot's +`effective_at_dispatch`. The absolute check after every read, including EOF, then terminates the +slot. This active-read bound does not include time spent before selection or behind an earlier +selected body. + +Apps reasoning about precise wall-clock behavior must not treat `effective_at_dispatch` as a +whole-batch bound. `outbound-batch-completion-order` exposes the observation-order limitation; +`outbound-batch-slot-isolation` exposes the fact that sequential dispatch and selected-body drain +can change a sibling's observed result; and `outbound-batch-cancellation` exposes the lack of a +finite host teardown proof. Requiring any of those capabilities produces a hard build failure on +Fastly. The warm single-slot body-read mechanism has a documented cooperative bound, while the +static `outbound-deadlines` cell remains `BestEffort` because cold registration and request-write +paths retain unbounded gaps. + +#### 3.3.5 No general-purpose timeout combinator (deliberate) + +An earlier draft put a `timeout(deadline, future)` combinator for *arbitrary* futures in +`edgezero-core`. That is **removed**: a real timer future needs a platform runtime +(`tokio` / `worker` / `spin-sdk`), which core may not depend on (§1.3). Core therefore +ships only the `Deadline` value type; outbound-deadline enforcement lives entirely inside +adapters (§3.3.4). A general arbitrary-future timeout would require an adapter-injected +`Timer` trait and a dedicated capability; it is **out of scope** here because the fan-out pattern's +timing needs are fully met by the outbound path. Noted as possible future work. + +### 3.4 Bounded buffering & error mapping + +#### 3.4.1 Outbound responses + +**Adapter-visible upstream header limits happen first; normalization and no-content handling follow, +before body decode or body-cap logic.** §3.4.5 measures response headers at the earliest +adapter-visible boundary, before normalization can remove fields. After those limits +pass, `normalize_response_headers` captures pre-filter Content-Type evidence and strips +hop-by-hop fields and `connection` nominations (§3.1.4). Its returned +`ResponseHeadNormalization::body_disposition` determines the following handling; its +`content_type_provenance` is retained separately, with Cloudflare always using `Unknown`. +A response that is bodyless by HTTP framing — the response to a +**`HEAD`** request, or any **`1xx`**, **`204`**, or **`304`** status — carries no payload even +though HEAD/304 MAY legitimately carry `Content-Encoding` and a *representation* +`Content-Length` (e.g. a `HEAD` echoing what a `GET` would return; a `304` echoing the cached +representation's metadata). For these: +- **Do NOT attempt to decode.** There are no body bytes; feeding EOF to a compressed decoder + would error and produce a **false `bad_gateway` (502)**. Skip the decoder entirely. +- **Framing headers are status-dependent (RFC 9110 §8.6 / RFC 9112 §6.2).** For a + **`HEAD` response and `304`**, `content-encoding` and a *representation* `content-length` + are legitimate metadata the client needs (a `304` or `HEAD` with them stripped breaks + cache validation). Validate every visible `content-length` as one consistent `u64` first: + malformed values, comma lists, conflicting duplicates, and values above `u64::MAX` are protocol + 502 before any body poll; valid values, including values above 4 GiB, are preserved unchanged. + For **`1xx` and `204`**, + `content-length` is prohibited and is removed. This framing normalization is centralized + in the same response helper as hop-by-hop stripping so every adapter applies it + identically. +- The adapter settles or drops the platform body handle according to that runtime's + protocol and constructs an empty core body. It never feeds a framing-bodyless response + to the content decoder. + +**`205 Reset Content` is a separate semantic-suppression case.** RFC 9110 forbids a server +from generating content in a 205 response, but HTTP/1.1 message framing does not grant 205 +the same automatic no-body precedence as HEAD/1xx/204/304. An illegal framed body therefore +cannot simply be replaced with `Body::Once(empty)` while leaving the native body unread: +that would risk connection reuse on unread bytes and, on Spin, falsely complete the +`consume_body` caller-result protocol. For 205, the adapter skips content decoding and awaits +at most one native body read under the effective deadline. An explicit EOF writes/observes +clean completion; any observed non-empty body bytes cause the adapter to abort/drop the +remaining native body and mark the platform completion protocol as failed. The +downstream/core result is still a 205 with an empty body, and `content-length` is normalized +to `0`. A native read failure before that disposition is `bad_gateway` reason `Transport`; +invalid completion/framing is reason `Protocol`; deadline expiry is the attributed +`gateway_timeout`. `ResetContent { declared_body: true }` permits +immediate abort without a read. + +This contract applies to **guest-visible read results**, not HTTP frame boundaries. +Fastly's `std::io::Read` returns `Ok(0)` for EOF when given a non-empty buffer and cannot +expose an empty wire data frame. Stream APIs can instead yield an empty item before EOF: +that item is not proof of completion, so the adapter conservatively aborts/drops the +remaining body and marks completion failed, still returning an empty 205. It need not +classify the empty item as illegal HTTP content. The one-read limit avoids an unbounded +discard of empty items; tests exercise each SDK's observable EOF/empty-item distinction +without asserting that every adapter can inspect wire frames. On Cloudflare every early +abort disposition calls the subrequest's `AbortController::abort()` (§4.2). + +Cloudflare has one narrower observable boundary: workerd can expose `Response.body == null` +for 205 after suppressing content itself. After adapter-visible upstream header checks, a positive +`Content-Length` still selects `declared_body` and aborts the subrequest. With absent/zero +length and a null body, the adapter treats the host-suppressed body as clean empty 205 and +does not call `stream()` or invent a read. It cannot prove whether illegal bytes existed +before workerd suppressed them, so Tier 3 characterizes this behavior and no test claims raw +framing visibility. A non-null body uses the ordinary one-read protocol above. + +The bodyless determination is **method- and status-aware**. Every adapter passes the +originating request method into `OutboundResponse::new`; `OutboundResponse` retains it until +`into_parts`/`into_response`, so a downstream conversion cannot lose HEAD semantics. The +adapter performs the authoritative disposition before constructing the response; final +conversion only rechecks the already-normalized metadata defensively. §5.4 pins tests for +`HEAD 200` (with `Content-Encoding: +gzip` + representation `Content-Length`, no body → passes through, headers preserved, no +502), `1xx`, `204`, clean 205, illegal-body 205, and `304`. Only AFTER this handling do the +decode/cap rules below apply to payload-bearing responses. + +In `Buffered` mode, `max_response_bytes` (default `DEFAULT_MAX_RESPONSE_BYTES = 1 MiB`) +caps the final collected `Body::Once` for every coding disposition. The independent +`max_decoded_response_bytes` cap is measured only in effective-identity or +EdgeZero-decoded gzip/deflate/Brotli output. Every adapter **must enforce that decoded cap +incrementally** and abort as soon as output exceeds it; this closes the decompression-bomb +gap while allowing a caller to set a larger final buffer for an intentionally retained raw +passthrough body. Decoded over-cap → +`Err(EdgeError::response_too_large_with_reason("response body exceeded N bytes", +ResponseLimitReason::DecodedBody))` (the distinct kind, 502 — §3.4.3; NOT +`bad_gateway`, so a consumer classifies it apart from transport). Final collection +over-cap uses `ResponseLimitReason::BufferedBody` instead. + +**Early `Content-Length` rejection is sound ONLY for effective identity (no +`content-encoding`, or exactly one bare `identity` value) responses.** In that case the wire +`Content-Length` *is* the decompressed +size, so a `u64` `Content-Length` above the `u64` cap can be rejected **before buffering** +(cheap, correct). When the response **is** compressed, the wire `Content-Length` is the +*compressed* size, which bounds the decompressed size in NEITHER direction — gzip +typically expands, but incompressible input can make the compressed representation +*larger* than its decoded output — so an early reject on the wire `Content-Length` could +wrongly reject a body whose decompressed size is under the cap. For compressed responses +the decoded cap is therefore enforced **only** incrementally during decompression (above); +the decoded early-reject shortcut is skipped. It is also skipped for unknown/stacked +`Passthrough`, whose bytes may remain encoded. A configured encoded-byte cap is independent +and may reject the same wire `Content-Length` before reading (§3.4.5). (This corrects an +earlier note that implied every wire `Content-Length` over the decoded cap is rejected up +front.) + +**Pre-append check is mandatory.** Outbound bounded drains +(`OutboundResponse::into_bytes_bounded` / `_until` and adapter buffered-response drains) +MUST check the running total against `max` **before** extending the buffer. The comparison +is done in the outbound cap's `u64` type (§3.1.3): convert `usize` lengths with +`u64::try_from`, use checked addition, compare, then extend. A single oversized chunk on a +small cap would otherwise allocate past the limit before erroring. The persistent collected +buffer therefore never exceeds `max`; inbound bounded-drain semantics are owned by the +[inbound body design](2026-08-22-inbound-body-design.md). + +Worst-case **transient** resident memory during a drain is `max + current_chunk.len()`: +the in-flight chunk briefly co-exists with the collected buffer during the check, then +is dropped (over-cap) or appended (under-cap). **`current_chunk.len()` is +source-controlled, not bounded by this spec.** The `8–64 KiB` figure typical sources +yield (`tokio::io` 8 KiB, `hyper` 16 KiB, WASI body reads 64 KiB) is descriptive of the +adapters' incoming stream chunking, not a contract. Three concrete consequences readers +must internalise: + +- **An upstream that yields one large `Bytes` exceeds the typical figure.** A peer + returning a 4 MiB response in a single chunk produces a single 4 MiB in-flight + `Bytes` while the over-cap check runs; if the cap is 1 MiB, the persistent buffer + never grows past 1 MiB but resident memory transiently includes the full 4 MiB + chunk. The check still aborts before any append, but the host did receive 4 MiB. +- **Rechunking is opt-in.** `OutboundRequest::max_chunk_bytes(NonZeroU64)` installs the + §3.4.5 core wrapper on Streamed app-visible output. Unset requests preserve source chunk + boundaries. Rechunking limits emitted item length; it does not prevent the adapter/host + from allocating or yielding one large source chunk, and shared `Bytes` slices may retain + that large backing allocation until the last slice is dropped. +- **The batch model in §3.4.4 inherits the same property.** `Σⱼ current_chunkⱼ.len()` + for actively-draining slots is bounded by what each source yields, not by EdgeZero. + EdgeZero cannot currently provide a hard per-batch ceiling against adversarial source + allocation. Apps that require one must bound fan-out (N) against a documented + provider/upstream frame or chunk ceiling; `max_chunk_bytes` alone is not that guarantee + (§8 risk 11). + +This is a per-call drain bound, **not** a whole-process memory ceiling; the batch-level +bound is `Σ persistent buffers + Σ in-flight chunks` per §3.4.4, with the same +source-controlled caveat on the in-flight term. + +Decoded-output-cap responsibility per adapter: + +- **Cloudflare** — first disables host response decoding in the final fetch options + using `encodeResponseBody: "manual"` (§4.2). Only then can the shared decoder apply + this policy and its independent decoded-output cap. Existing explicit decoding alone does + not prevent workerd from decoding the subrequest body first. +- **Fastly, Spin** — already decompress gzip/deflate/br explicitly today; the cap obligation + applies in-line in their decode paths. +- **Axum** — the workspace `reqwest` dependency is currently + `default-features = false` and does not enable gzip/deflate/brotli decoding. This migration + does **NOT** enable reqwest's `gzip`/`brotli` auto-decoding. Axum inserts + `accept-encoding: identity` only when the normalized request does not already contain + `accept-encoding`; a caller-supplied value is preserved and its response is processed by + the shared decoder. reqwest's built-in decoder matches + **exact lowercase** `content-encoding` values from a single map entry and would not + honour the portable contract (case-insensitive `GZIP`, `identity`, unknown/**stacked**/ + repeated → passthrough untouched). Instead Axum routes the raw response body through + the **same shared `content-encoding` inspection + decoder** the other adapters use + (§3.4.1 policy table), enforcing the independent decoded-output cap incrementally. This is the + only way all four adapters share one decompression contract. + +**Portable `content-encoding` policy for visible values.** The action table is identical on +all four adapters for the values and field structure visible to the guest. Exact treatment +of malformed raw bytes or field lines that workerd has already joined is guaranteed only by +`outbound-header-fidelity`; Cloudflare applies the table to its post-workerd representation. +Every adapter calls the public core `classify_content_encoding(&HeaderMap)` helper and uses +its typed `ContentEncoding::{Brotli, Deflate, Gzip, Identity, Passthrough(reason)}` result. `Identity` +is distinct from `Passthrough` internally even though neither decodes: only `Identity` lets +`enforce_payload_content_length` compare the visible length with the decoded cap before the +first body poll. Unknown/stacked passthrough bytes may still be encoded, so their encoded +cap and, in Buffered mode, raw final-buffer cap are sound; the decoded cap is not. + +| `content-encoding` value | Action | +| --- | --- | +| absent, or `identity` | **Identity delivery** — no codec runs and bytes are delivered as-is, but the independent decoded-output counter applies because these are already representation bytes. `identity` is treated exactly as absent. | +| a single `gzip` | **Decode** one gzip layer; strip `content-encoding` + `content-length`. | +| a single `br` | **Decode** one brotli layer; strip `content-encoding` + `content-length`. | +| a single `deflate` | **Decode** one RFC 1950 zlib-wrapped DEFLATE layer; strip `content-encoding` + `content-length`. Raw RFC 1951 streams without the zlib wrapper are malformed compressed content and fail as `Decode(Deflate)`. | +| one valid unsupported token (`zstd`, `compress`, …) | **Passthrough `Unsupported(token)`**, untouched; preserve the normalized lowercase token for application policy. | +| more than one field, or one valid coding list (`gzip, deflate`, `br, gzip`, …) | **Passthrough `Stacked`**, untouched. | +| invalid token syntax, an empty list element, a parameterized value, or non-UTF-8 bytes | **Passthrough `Malformed`**, untouched. | + +- **Matching is case-insensitive** on the token (`GZIP` == `gzip`) and tolerant of + optional ASCII space/tab surrounding whitespace. The classifier examines every value + returned by `HeaderMap::get_all`; exactly one field is required for `identity`, `gzip`, + `deflate`, or `br`. Two or more fields are `Passthrough(Stacked)`, even if each field + contains the same token. A sole field containing two or more valid comma-separated tokens + is also `Stacked`; an empty element, non-UTF-8 byte, or otherwise invalid token syntax is + `Malformed`, never a decode attempt. One valid unknown token is lowercased and retained in + `Unsupported(token)`. + **`Content-Encoding` is a bare content-coding token + (RFC 9110 §8.4.1) and carries NO `q=` weight** — quality values belong to + `Accept-Encoding`, not `Content-Encoding`. A value bearing any parameter (`gzip;q=0.5`, + `gzip;x=1`) is therefore **not** the bare `gzip`/`br` form: it falls through to + **`Passthrough(Malformed)`**, never decoded. Only the three known bare single-layer forms + decode; every other valid single token or valid stack passes through with its typed reason. +- **A repeated `content-encoding` field** (two header lines) is treated as the stacked + case → passthrough untouched. +- Passthrough here means `max_encoded_response_bytes` still counts the raw transport body + and Buffered `max_response_bytes` caps the raw final collection. The independent + `max_decoded_response_bytes` counter does not run because EdgeZero produced no decoded + representation. +- Rationale: decoding stacked/unknown encodings is unbounded surface for little value on + edge fan-out; failing them hard (`502`) would break apps that can decode a `zstd` body + themselves. Passthrough is deterministic and never worse than "the app got the bytes." + +Whenever an adapter **does** decompress (the three known single-layer cases above), the +`OutboundResponse.headers` it returns MUST have +both `content-encoding` and `content-length` removed — the original values describe +compressed wire bytes and no longer match the app-visible body. This applies in both +`Buffered` and `Streamed` modes: callers must never see decoded bytes alongside stale +compressed metadata. Existing Cloudflare and Fastly proxy code already does this and +the contract codifies it. + +**Streaming-decompressor design (Streamed mode).** Every adapter wraps the raw compressed byte +stream with a **streaming decoder** that emits decompressed chunks as they arrive, +never buffering the full body. Every standard response-egress path then preserves the wrapped +`Body::Stream` lazily. Axum and Cloudflare are `Native` for lazy passthrough; Fastly and Spin +remain `BestEffort` pending the end-to-end target evidence documented in footnotes 6 / 7. The +decoder's *only* +responsibilities are decoding bytes, stripping the two compressed-only headers, and +surfacing decoder errors. The independent `max_decoded_response_bytes` counter wraps +effective identity and known decoded output in both response modes; it is not part of the +codec implementation and never wraps passthrough. `ResponseMode::Streamed` carries no final +collection cap. The encoded-byte counter and Brotli prefix/window/decoder-state charge are +separate upstream layers (§3.4.5): + +1. Pull a raw compressed chunk from the platform stream. +2. Feed it into the decoder; emit whatever decompressed output is currently available + (zero, one, or many output chunks per input chunk). +3. Yield each decompressed chunk through the decoded-output counter. The decoder itself does + no policy accounting. A later optional rechunker may split that item. +4. End successfully only after codec completion **and** verified native-body EOF; + a codec end marker alone is not EOF. Apply the completion protocol below. +5. `content-encoding` and `content-length` are stripped from + `OutboundResponse.headers` at construction time — the wrapper's output bytes are + the new ground truth. + +Every terminal error item is a resource-ownership boundary. Before a response limiter, gzip or +Brotli decoder, native-EOF validator, or framing wrapper yields `Err`, it drops its source and +decoder/native reader state. The caller need not poll the wrapper again or drop it to release the +owned upstream body; the yielded error item is already terminal and resource-free. + +Cap ownership is then unambiguous: + +- **Buffered mode:** the adapter drains the decompressed stream inside the + buffered-drain helper with `max_response_bytes` (per-append-checked, §3.4.1). + Cap fires inside the adapter. +- **Streamed mode + `into_bytes_bounded(max)` / `into_bytes_bounded_until(max, + deadline)`:** the helper's own pre-append check enforces `max` against the + decompressed chunks it pulls from the wrapped stream. Cap fires in the helper. +- **Streamed mode + `into_response()` passthrough (proxy-forward):** no final collection cap is + implied on any adapter. Configured encoded/header/Brotli limits and any configured decoded cap + still apply before the body reaches response egress. Apps that require a final byte cap do + `into_bytes_bounded` first, then re-emit. The capability matrix records transport-boundary + quality separately from whether EdgeZero preserves lazy production. + +**Oversize is a DISTINCT outcome, not a transport error.** When a decoded/encoded/header +cap fires or a Brotli window is refused, the result is +`EdgeError::response_too_large_with_reason(.., reason)`, a distinct +variant/kind, **NOT** `bad_gateway`. The bare `response_too_large(..)` constructor uses +`ResponseLimitReason::Unspecified` for call sites with no narrower origin. It maps to HTTP +**502** on the wire, but its `kind_str()` is `response_too_large`, so a fan-out consumer can +classify policy/resource failure apart from a genuine transport `bad_gateway`; Rust callers +can additionally distinguish the exact resource without parsing the message. Byte-cap +enforcement stays **incremental**. For an identity response, a wire `Content-Length` above +the decoded cap rejects before buffering; a compressed wire length is not comparable to the +decoded cap, while the independent encoded cap may reject it (§3.4.5). Adding +`ResponseTooLarge` and `ResponseLimitReason` follows the same exhaustive-match discipline as +the Phase 1a variants (every `match` arm and test matrix updated; neither reason nor the +variant adds `Retry-After`/`field_path` to the wire). §5.4 pins resource-reason tests and +keeps transport failure distinguishable from every limit outcome. +- Request-body over-cap keeps its distinct existing outcome — `bad_request` (400), + a client-side misuse — unchanged. + +**Pipeline order — resource and decoder work stay inside the absolute-deadline output +wrapper.** After adapter-visible upstream header limits and bodyless disposition, payload layers compose +in exactly one order: +`platform raw/completion stream → encoded-byte counter → optional Brotli prefix gate → +EdgeError/io::Error carrier bridge → gzip/deflate/br decoder + native-EOF validation → exact carrier +restoration → decoded-byte cap → optional rechunker → absolute-deadline/cancellation wrapper → +consumer`. Unknown/stacked encodings bypass only the prefix/decoder/carrier stages and decoded +cap; they still pass through raw counting, rechunking, and the deadline. The outer deadline sees +every emitted item and terminal result, while adapter-specific ready-input quotas prevent an +inner decoder poll from monopolizing the runtime. Codec completion and native-EOF validation +remain inside this pipeline, never in an unbounded drain after the deadline wrapper ends. +- Each adapter keeps whatever raw-read timer its transport provides. On Axum, Cloudflare, + and Spin, the outer decoded-output wrapper races every `decoded.next().await` against the + absolute deadline and re-checks the deadline after readiness, including terminal EOF and + error. Fastly performs the same pre/post absolute-deadline checks around each blocking + decoded read but cannot preempt that read guest-side. This covers work performed by the + decoder after the final raw read; a converter-only check cannot cover lazy `Streamed` + consumption. Cloudflare additionally applies §4.2's host-event yield quota to raw + decoder input, decoded output, and terminal checks; clock reads alone cannot advance + its production clock during ready-only processing. +- Its timeout chunk is a typed + `EdgeError::gateway_timeout_caused(.., budget.cause)` (504). On timeout it drops the + decoder/raw stream and invokes the adapter's available transport cancellation. Fastly has + no guest timer, so it retains the host raw-read timeout plus cooperative pre/post checks at + this same outer boundary. +- **Precedence at the decoded-stream boundary:** at or after the deadline, timeout wins over + simultaneous success, EOF, raw-read failure, or decoder failure. A malformed-compression + 502 applies only when the decoder produces that error before the deadline. A caller-owned + final collection cap outside this wrapper may legitimately win after the wrapper yielded a + within-deadline chunk: the generic streamed helper does not retain the request deadline, so it + cannot reclassify a cap decision merely because the clock crossed after that yield. Buffered + adapter drains remain + inside the adapter's whole-exchange race, and `into_bytes_bounded_until` rechecks its own + caller-supplied deadline before returning over-cap; those two surfaces preserve + timeout-over-cap precedence for the deadline they actually own. +- §5.4 tests all four: a compressed **stall before first decoded byte**, **mid-stream stall**, + **stall at EOF** → each `gateway_timeout` (504); **malformed compression with no stall** → 502; + and **malformed-compression-vs-timeout precedence** (deadline fires first → 504, not 502). + Separate cap-precedence tests assert the narrower ownership rule above rather than claiming + a universal race after a within-deadline chunk has already been yielded. + +**Implementation hooks (shared helpers).** The async stream decoders for gzip and brotli live in +`edgezero-core/src/compression.rs`; they are core helpers, not adapter-local code. The removed +Spin slice decoder is not retained as an alternate path. The shared helpers' chunk error type is +**`io::Error`**, and that is **not a free choice**: `TryStreamExt::into_async_read` +(which both helpers use to feed the decoder) is hard-bound to +`Self: TryStreamExt` (futures-util `try_stream/mod.rs`). A +decoder input stream therefore **cannot** simply be re-typed to `EdgeError` — that does +not compile — and naively mapping `EdgeError -> io::Error` on the way in would collapse a +`gateway_timeout` (504) into the decoder's generic 502 outcome, the exact bug §3.4.1 +forbids. Their existing stop-at-first-decoder-EOF loops also need the following +completion correction; reusing those loops unchanged is not conformant. + +**Codec completion is not native-body completion.** A gzip representation can contain +concatenated members within one content-coding layer +([RFC 1952 §2.2](https://www.rfc-editor.org/rfc/rfc1952.html#section-2.2)). The shared helpers +must preserve all decoded payload and observe late source errors before reporting success: + +- **Gzip:** enable `GzipDecoder::multiple_members(true)` in exact manifest-pinned + `async-compression` 0.4.43. Decode every member in order under the same absolute + deadline, including empty members. Any configured decoded-byte cap counts cumulatively + across members in the separate output wrapper; no cap enters the decoder. + Member boundaries reset neither budget. A truncated/corrupt later member or trailing bytes + that are not a valid member are `bad_gateway` reason `Decode(Gzip)`; stacked content-coding + passthrough is unchanged. +- **Brotli:** EdgeZero accepts one complete Brotli stream for a single `br` coding. + After decoder completion, recover its buffered reader and inspect both unread buffered + bytes and subsequent source input. Any trailing non-empty bytes, including a second + Brotli stream, are `bad_gateway` reason `Decode(Brotli)`; never silently discard read-ahead bytes + when recovering the reader. Empty source items are not EOF. Continue completion checks until native EOF + or failure, inside the same deadline wrapper and Cloudflare yield discipline. +- **Both:** a late source `EdgeError` still passes through the exact carrier restoration + below. A source that stalls after the codec end marker remains pending until the + adapter's deadline enforcement yields attributed 504, with Fastly's documented host + gaps unchanged. The outer wrapper checks terminal readiness before exposing EOF/error; + timeout precedence and the existing cap-ownership rule remain unchanged. Do not drain + the remaining input after a cap/error/timeout just to reach EOF; abort/drop it instead. +- Successful decoded-stream EOF requires native EOF and the adapter's required + trailer/completion outcomes. Keep the underlying reader and cancellation/completion + ownership until success or early termination; no terminal result may swallow unread + input or a pending native error. Cloudflare disarms its abort guard only after this + success, and Spin reports caller-result success only after the response protocol and + decoding have succeeded. Already-yielded Streamed bytes remain delivered, but a later + failure must still surface as an error chunk rather than false clean EOF. + +**The bridge (compile-verified).** Carry the typed error *through* the `io::Error` +boundary instead of converting it. On input, wrap each inner `EdgeError` in a private +carrier: `stream.map_err(|error| io::Error::other(Carried(error)))`. On output, first +capture the `io::Error` diagnostic, then inspect `into_inner()` and restore the original +`EdgeError` only when the boxed source downcasts exactly to `Carried`. A missing or failed +downcast is the decoder's own failure and maps to +`EdgeError::bad_gateway_with_reason(.., BadGatewayReason::Decode(coding))`, where the +helper supplies `Gzip` or `Brotli`, while preserving the captured diagnostic. The restored +stream is then wrapped by the +decoded-output deadline guard above, so a lazy stream checks every yield and terminal EOF. + +All adapter outbound clients call these existing core helpers. The wrapper runs incrementally on +every target (§3.4.1), never as a whole-body decode. When an outbound response is forwarded, each +response-egress coordinator continues polling that same portable stream lazily. + +In `Streamed` mode a configured `max_decoded_response_bytes` is enforced incrementally on +effective identity and EdgeZero-decoded gzip/deflate/Brotli output before each item reaches the app. +If that optional policy is unset, there is no default decoded-output cap. A caller may still +apply a distinct final collection cap later with +`OutboundResponse::into_bytes_bounded(max)`. The independent encoded, header, and +Brotli-window/decoder-state limits in §3.4.5 also apply before or during streamed delivery. +The bounded collection method does **not** delegate to +`Body::into_bytes_bounded` directly — `Body::into_bytes_bounded` maps over-limit to +`bad_request` (400), correct for the inbound body case but wrong for an over-large +upstream response. `OutboundResponse::into_bytes_bounded` performs its own bounded +drain and maps over-cap to **`response_too_large`** (distinct kind, 502 — §3.4.1; NOT +`bad_gateway`, so a consumer classifies it apart from transport). On adapters that +decompress, the cap is enforced against decompressed output here too. + +#### 3.4.2 Inbound dependency boundary + +Inbound `RequestContext::into_request` semantics are owned by the dedicated +[inbound body design](2026-08-22-inbound-body-design.md). This outbound spec requires only +that `OutboundRequest::from_request` preserve the source method, normalized headers, and +whatever buffered or streamed `Body` the core request supplies; it does not deliver or test +the inbound body state machine, extractor limits, or adapter ingress buffering. +#### 3.4.3 New `EdgeError` variants & mapping + +`EdgeError` is `#[non_exhaustive]`, so this is additive. + +```rust +// crates/edgezero-core/src/error.rs +// Phase 1a lands the first TWO variants (needed for deadline/transport mapping). +// BadGateway carries a typed reason so consumers never classify retries or diagnostics by +// parsing a provider-specific message. `Unspecified` preserves the ergonomic bare +// constructor and is used when a platform exposes no narrower cause. +EdgeError::BadGateway { message: String, reason: BadGatewayReason } // -> 502 (Phase 1a) +// GatewayTimeout carries a TYPED `cause`, NOT just a message: consumers can observe +// which configured budget input selected the effective deadline without parsing strings +// (§3.3.2). This does not say which physical timer fired or prove that input elapsed. +// **Phase 1a MUST land this shape** (the `BudgetSource` enum + +// the field), even though its producer `dispatch_budget` is Phase 1b — freezing +// `GatewayTimeout { message }` now would bake in a variant the master contract can't use, +// forcing a breaking change later. `inner()` is still `None` (a `cause` is not a source error). +EdgeError::GatewayTimeout { message: String, cause: BudgetSource } // -> 504 (Phase 1a) +// The OUTBOUND response-handling phase adds a THIRD, so response-cap over-run is a +// distinct machine-classifiable outcome, NOT collapsed into `bad_gateway` (§3.4.1): +EdgeError::ResponseTooLarge { message: String, reason: ResponseLimitReason } +// -> 502, kind "response_too_large" + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BadGatewayDecodeReason { Brotli, Gzip, Json } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BadGatewayReason { + Decode(BadGatewayDecodeReason), + Protocol, + Transport, + Unreachable, + Unspecified, +} + +// `BudgetSource` is defined once in §3.3.2 and `error.rs`; use that canonical definition +// here. Its variants are `BatchCutoff`, `Default`, `PerCallTimeout`, `RequestDeadline`, and +// `Unspecified`. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ResponseLimitReason { + BrotliWindow, + BufferedBody, + DecodedBody, + DecoderMemory, + EncodedBody, + HeaderBytes, + HeaderCount, + Unspecified, +} + +pub fn bad_gateway(message: impl Into) -> Self; +pub fn bad_gateway_with_reason( + message: impl Into, + reason: BadGatewayReason, +) -> Self; +pub fn gateway_timeout(message: impl Into) -> Self; // cause = Unspecified +pub fn gateway_timeout_caused(message: impl Into, cause: BudgetSource) -> Self; +pub fn response_too_large(message: impl Into) -> Self; // reason = Unspecified +pub fn response_too_large_with_reason( + message: impl Into, + reason: ResponseLimitReason, +) -> Self; +``` + +`Unreachable` is reserved for a failure before any upstream response head is available: +DNS resolution, connection establishment/refusal, or TLS establishment. `Transport` covers +an established exchange failing during request upload, response body/trailer reads, native +completion, or a later connection termination. If a provider does not expose enough phase +information, use `Unspecified`; never guess from message text. EdgeZero-owned decoders always +use `Decode(BadGatewayDecodeReason::{Brotli,Gzip,Json})` as applicable. Provider-native decoding +remains `BadGatewayReason::Unspecified` unless the adapter can establish the coding without +parsing a diagnostic string. + +`EdgeError::status()` gains `BadGateway => 502`, `GatewayTimeout => 504`, and (outbound +phase) `ResponseTooLarge => 502` with `kind_str() == "response_too_large"`. Like the other +two it carries no `Retry-After` and no `field_path`. `BadGatewayReason` and +`ResponseLimitReason` are Rust-side fields and are not serialized; the JSON envelope stays +`status`/`kind`/`message`. The message at that HTTP boundary is category-only: +`BadGateway` emits `"bad gateway"`, `GatewayTimeout` emits `"gateway timeout"`, +`ResponseTooLarge` emits `"upstream response exceeded configured limits"`, and `Internal` +emits `"internal server error"`. `EdgeError::message()` and `Display` remain internal +diagnostic surfaces, but adapters never interpolate a provider error, URL, query, token, or +response content into an outbound diagnostic. Tests use token-bearing fixtures to prove +`IntoResponse` cannot serialize those values. Each addition follows the same +exhaustive-match discipline (every `match` arm + test matrix updated, alphabetical +insertion); Phase 1a does the first two plus `BadGatewayReason`, and the outbound work does +the third plus `ResponseLimitReason` in the same mechanical style. + +| Condition | `EdgeError` | HTTP status | +| --- | --- | --- | +| Invalid outbound URI (relative / no authority / bad scheme) | `bad_request` | 400 | +| Upstream unreachable before a response head (DNS / connect / TLS establishment / connection refusal) | `bad_gateway`, reason `Unreachable` when the adapter can establish it, else `Unspecified` | 502 | +| Outbound transport failure after connection progress (request upload, response read, trailer/completion, connection termination) | `bad_gateway`, reason `Transport` when the adapter can establish it, else `Unspecified` | 502 | +| Outbound protocol/framing/completion failure | `bad_gateway`, reason `Protocol` | 502 | +| Outbound response over a body/header/window resource limit | `response_too_large`, typed `ResponseLimitReason` (§3.4.5) | 502 | +| Outbound response body not valid JSON, gzip, or Brotli / `json::` called on a streamed body | `bad_gateway`, reason `Decode(Json/Gzip/Brotli)` for malformed content and `Protocol` for invalid API state | 502 | +| Outbound per-request timeout or request deadline exceeded | `gateway_timeout` (carries `budget.cause`: per-call vs request deadline — §3.3.2) | 504 | +| Batch observation cutoff reached | collection terminates as `Cutoff`; unresolved slots are `None`; no synthetic terminal slot error is emitted | n/a | +| Malformed or explicitly failed batch driver | `OutboundBatchFailure` with the precise batch-level `Internal` and all previously collected slots; never represented as `Cutoff` | 500 | +| Outbound completed with a non-2xx status | **not an error** — `Ok(OutboundResponse)` | app decides | + +The non-2xx rule is load-bearing: a target returning 204/400/500 is a normal fan-out batch +outcome, not a transport error. + +#### 3.4.4 Batch memory model (explicit) + +`send_all_until` does not impose a global allocation ceiling. Its logical payload accounting has +two parts — +a **persistent collected buffer** term that holds the request payloads and the +buffered response payloads, plus a **transient in-flight chunk** term that +briefly co-exists with the collected buffer per actively-draining slot (per +§3.4.1's pre-append checked accounting, the in-flight chunk is held during the +overflow check before being appended or dropped): + +``` +persistent collected buffer = Σᵢ request_bodyᵢ.len() + + Σᵢ max_response_bytesᵢ (send_all_until is buffered-only) + +transient in-flight chunks = Σⱼ current_chunkⱼ.len() + // j ranges over slots + // currently inside a drain + // step; typically 8-64 KiB + // per active slot + +worst-case LOGICAL PAYLOAD bytes = persistent + transient +// This is NOT a complete core-allocation or process-RSS bound. It EXCLUDES: +// `Vec`/`BytesMut` spare capacity (amortised growth over-allocates), shared `Bytes` backing +// allocations not yet freed, gzip decoder state, Brotli state other than the separately +// bounded source-audited decoder-state charge, and allocator overhead/fragmentation. During active Brotli +// decodes, add up to each slot's `max_decoder_bytes` to this logical payload number; +// even that sum remains narrower than RSS. Actual RSS also includes adapter/host buffering +// and every opaque term listed below (§ send_all_until rustdoc). + +// Equivalently, when all slots share the same response cap, the persistent term is: +// Σᵢ request_bodyᵢ.len() + N × max_response_bytes +// — but the precise sum is over the per-slot caps, not a single N × max. +// Heterogeneous caps (mix of `.max_response_bytes(small)` and unset slots) bound +// the persistent term by Σᵢ instead of N × max(capᵢ). +``` + +`send_all_until` rejects streamed request bodies and streamed responses in preflight +(§3.1.1), so a Streamed-mode batch memory model does not exist. Single `send` +with `Streamed` is the path for lazy bodies, where memory is bounded by the +streaming chunk buffer plus whatever the consumer chooses to buffer via +`into_bytes_bounded`. + +This arithmetic is not an admission-safe process/isolate RSS bound. Informational response +blocks that a host consumes before exposing the final response, parser tables, provider-side +field sections, trailers not exposed to the guest, native receive-chunk allocation, shared +backing storage, `BytesMut` spare capacity, allocator metadata/fragmentation, task stacks, +and host copies can all exist outside it. When an adapter exposes informational fields or +trailers to guest code, it applies the configured header count/byte totals cumulatively +across every exposed field section and final completion; it never claims that this prevents +an earlier host allocation. When those fields are not exposed, their allocation is opaque. +The `outbound-complete-resource-accounting` capability is therefore `Unsupported` for every +current adapter. This is a deliberate fail-closed statement, not a reason to weaken the +narrower limits below. + +EdgeZero's contract — **persistent** (post-append, retained) vs **transient** +(in-flight, dropped after the cap check): + +- **Per-response (Buffered).** *Persistent* memory — the collected buffer — is bounded + by `max_response_bytes`. *Transient* worst-case core-owned payload during a drain is + `max_response_bytes + current_chunk.len()`, where `current_chunk.len()` is + source-controlled (§3.4.1). The post-check buffer never exceeds `max_response_bytes`. +- **Batch (N)** memory is the app's responsibility: the app must bound the number of + requests passed to `send_all_until`. Both terms add up — *persistent* is + `Σᵢ request_bodyᵢ.len() + Σᵢ max_response_bytesᵢ` (`request_bodyᵢ` and + `max_response_bytesᵢ` denote slot `i`'s buffered request body length and its + per-request response cap respectively); *transient* adds + `Σⱼ current_chunkⱼ.len()` over actively-draining slots, source-controlled, plus up to + `Σₖ max_decoder_bytesₖ` over actively decoding Brotli, gzip, or deflate slots. This remains a set of + enforceable guest terms rather than a complete memory bound. + For typical fan-out workloads this is intrinsic — `N` is the fixed, configured target count and + target responses are small JSON. The spec deliberately does **not** add a + `max_concurrency` knob: on Fastly all requests must be in-flight at once for + fan-out to work, so throttling concurrency would defeat the feature. This + requirement is documented in the `send_all_until` rustdoc and in `docs/`. The optional + `max_chunk_bytes` wrapper shapes app-visible items but does not remove the source-chunk + term from this model (§3.4.5). + +#### 3.4.5 Response resource limits + +`ResponseMode::Buffered { max_bytes }` (set by `max_response_bytes`) is the final collection +cap for every Buffered response, including raw passthrough. It is not the decoded-output +policy. Eight independent controls cover transport bytes, identity/decoded output, decoder +heap requests, field metadata, and Streamed item shape. This separation permits, for +example, a 4 MiB decoded limit with a 16 MiB final buffer for an intentionally retained raw +passthrough representation: + +| Builder | Default | Measures | Typed overflow/rejection reason | +| --- | --- | --- | --- | +| `max_decoder_bytes(u64)` | `DEFAULT_MAX_DECODER_BYTES = 32 MiB` | conservative decoder-state charge for Brotli, gzip, or deflate, checked before decoder construction | `ResponseLimitReason::DecoderMemory` | +| `max_encoded_response_bytes(u64)` | unset | cumulative guest-visible body bytes before content decoding | `ResponseLimitReason::EncodedBody` | +| `max_decoded_response_bytes(u64)` | unset | cumulative identity or EdgeZero-decoded gzip/deflate/Brotli output; never raw passthrough | `ResponseLimitReason::DecodedBody` | +| `max_response_header_bytes(u64)` | unset | sum of each adapter-visible upstream field name length plus value length before normalization, plus EdgeZero's synthetic `x-edgezero-proxy` marker | `ResponseLimitReason::HeaderBytes` | +| `max_response_header_count(u64)` | unset | adapter-visible upstream name/value entries before normalization, including repeated values, plus EdgeZero's synthetic `x-edgezero-proxy` marker | `ResponseLimitReason::HeaderCount` | +| `max_brotli_window_bits(u8)` | `DEFAULT_MAX_BROTLI_WINDOW_BITS = 24` | advertised Brotli window bits, valid configured range `10..=30` | `ResponseLimitReason::BrotliWindow` | +| `max_chunk_bytes(NonZeroU64)` | unset | each app-visible `Body::Stream` item after decode or passthrough | no overflow; items are split lazily | +| `max_response_bytes(u64)` | `DEFAULT_MAX_RESPONSE_BYTES = 1 MiB` | final bytes collected into `Body::Once`, regardless of coding disposition | `ResponseLimitReason::BufferedBody` | + +All byte/count fields and running totals use checked `u64` arithmetic. A Brotli limit outside +`10..=30` is caller configuration and fails dispatch as `bad_request`; zero chunk size is +unrepresentable through `NonZeroU64`. Unset optional controls preserve existing behavior. +These are per-response controls, not aggregate batch limits. + +**Enforcement order and precedence:** + +1. At the earliest adapter boundary where an upstream response field section is exposed, count + entries and `name.as_bytes().len() + value.as_bytes().len()` for every visible pair + before normalization, duplicate coalescing by EdgeZero, or additional collected header + state. Fields that normalization later strips still count. Maintain one cumulative count/byte + total across every adapter-visible informational block, the final response headers, and + adapter-visible trailers; a later field section can + therefore fail the returned body/completion stream with the same typed reason. + Increment and check the entry count before adding/checking bytes for that entry, so if + the same field crosses both configured limits, `HeaderCount` wins. Reject on the first + exceeded configured limit; never truncate. Cloudflare can measure + only workerd's already-materialized strings and merged non-`set-cookie` entries. No + adapter claims these limits prevent provider/SDK allocation, parsing of an informational + block or trailer the SDK does not expose, or a host-side rejection that occurs before + metadata reaches the guest. After normalization, account EdgeZero's synthetic + `x-edgezero-proxy: ` field against the same cumulative limiter before inserting it. +2. Apply bodyless/205 disposition. Framing-bodyless responses do not consume a body or + invoke body/window limits. A visible normalized `Content-Length` greater than a configured + encoded cap rejects a payload-bearing response before reading, including compressed + responses. Identity responses independently reject against the decoded cap and, in + Buffered mode, the final-buffer cap. Passthrough responses compare their raw length to + the encoded cap and, in Buffered mode, the final-buffer cap, but never to the decoded cap. + A compressed response that EdgeZero will decode cannot compare wire `Content-Length` to + either output cap. For a + payload-bearing response or 205, malformed, comma-list, or conflicting repeated visible + `Content-Length` is `bad_gateway` reason `Protocol` before body reads. HEAD/304 preserve + representation metadata without interpreting it as a body promise. +3. Count every guest-visible raw body item before handing it to the content decoder or + passthrough stream. The encoded total is cumulative across gzip members and native + completion reads. Check before forwarding/appending. On raw overflow, abort/drop the + owned native response and return/yield `response_too_large_with_reason(.., + ResponseLimitReason::EncodedBody)`. +4. For a single `br` coding, parse only the fixed-size stream prefix needed to determine + WBITS **before constructing the decoder or allocating its state**, then replay those + bytes unchanged. The parser handles both the standard one-byte encoding and the pinned + decoder's two-byte large-window extension (10 through 30 bits); the extension cannot + bypass the configured cap. A valid over-limit value is `BrotliWindow`; malformed or + truncated prefix syntax is `bad_gateway` with reason `Decode(Brotli)`. +5. Before decoder construction, compute + `brotli_decoder_memory_charge(WBITS) = BROTLI_DECODER_FIXED_CHARGE_BYTES + 2^WBITS` + with checked `u64` arithmetic. `BROTLI_DECODER_FIXED_CHARGE_BYTES` is 16 MiB and covers + the pinned Rust decoder's non-ring state; `2^WBITS` charges its maximum ring window. The + normal dependency graph is pinned exactly to `alloc-no-stdlib 2.0.4`, `alloc-stdlib 0.2.4`, + `async-compression 0.4.43`, `brotli 8.0.4`, `brotli-decompressor 5.0.1`, + `compression-codecs 0.4.38`, and `compression-core 0.4.32`. Core constructs + `async_compression`'s reader with the explicitly + pinned `compression_codecs::BrotliDecoder`, and CI runs + `scripts/check_brotli_dependency_contract.mjs` against both root and demo lockfiles. The + checked-in [decoder memory audit](../../audits/2026-09-27-brotli-decoder-memory-accounting.md) + derives a 3,442,530-byte allocation-payload subtotal for that graph, including the ring + allocation's 66-byte write-ahead/dictionary slack beyond `2^WBITS`. The 16 MiB fixed charge + is a conservative accounting reservation with explicit headroom for wrapper and allocator + overhead; it is not represented as an exact total-heap bound. A core regression test checks + compiled type and EdgeZero buffer sizes, while CI pins every audited normal dependency by + version, registry source, and checksum to protect private table, grammar, and layout + assumptions. Allocation measurements may supplement but never replace this source proof. A + dependency upgrade must repeat the audit and may raise the constant; it may not retain the old + charge on test evidence alone. Reject with + `DecoderMemory` before constructing the decoder when the charge exceeds + `max_decoder_bytes`. The default 32 MiB cap therefore admits the default WBITS 24 + and rejects a raised window unless the caller also raises the memory policy. This is a + conservative reservation charge, not a claim that the process reserves or uses exactly + that many bytes. It excludes allocator metadata, fragmentation, output/body buffers, + task stacks, adapter/SDK buffers, and host RSS; `outbound-complete-resource-accounting` + remains Unsupported because those terms are not portable (§3.5.2). +6. Apply `max_decoded_response_bytes` only to effective identity and EdgeZero-decoded + gzip/deflate/Brotli output. Unknown, parameterized, stacked, or otherwise passthrough encodings + bypass this decoded counter. The encoded counter still applies to every path. +7. For Streamed output apply the optional rechunker, then wrap the stream in the adapter's + absolute deadline/cancellation owner. For Buffered output, collect under + `ResponseMode::Buffered { max_bytes }`; that final cap applies to identity, decoded, and + passthrough bytes and reports `BufferedBody`. + The rechunker preserves byte order, error order, cancellation ownership, and absolute + deadline/fairness checks. It pulls lazily and emits items no larger than the configured + value. It does not alter a final Buffered body. Splitting a `Bytes` value may retain its + original backing allocation; copying can temporarily coexist with it. Therefore + `max_chunk_bytes` is an item-shape guarantee, not a source-allocation or RSS guarantee. + +For adapter-owned decisions inside the exchange wrapper, if the request deadline is already +expired when a resource decision becomes ready, the attributed `gateway_timeout` wins. +`into_bytes_bounded_until` applies the same rule to its caller-supplied deadline. The generic +`into_bytes_bounded` helper intentionally retains no deadline; after it receives a chunk that +the stream yielded within budget, its decoded cap can therefore win even if wall clock crosses +the original request deadline before the helper finishes accounting (§3.4.1). Otherwise +pipeline order breaks simultaneous resource ties: header limits precede body limits; encoded +bytes precede decoder/window/decoded bytes. All early termination follows each adapter's +existing native cleanup and completion protocol. + +### 3.5 Capability declaration + +#### 3.5.1 Manifest section + +```toml +# edgezero.toml +[capabilities] +# This strict declaration intentionally rejects Fastly: both of these cells are +# BestEffort there. A portable four-adapter app declares `outbound-http` optional and +# accepts Fastly's documented dynamic-backend service prerequisite. +required = ["outbound-http", "outbound-deadlines"] + +[capabilities.outbound] +# Optional plumbing. OMITTING this field is NOT the same as `["*"]`: +# - field absent → https-only default `["https://*:*"]` (no cleartext) +# - hosts = ["*"] → explicit opt-in to BOTH http and https +# So an existing manifest that never declared hosts keeps its https-only posture. +hosts = ["*"] +``` + +```rust +// crates/edgezero-core/src/manifest.rs — defined INLINE here, NOT in a separate +// capability.rs. `manifest.rs` is textually `include!`d by edgezero-macros +// (manifest_definitions.rs), and edgezero-core depends on edgezero-macros, so the +// macro crate can neither see `edgezero_core::` paths nor add core as a dep (cycle). +// A separate `capability.rs` that `manifest.rs` imports would fail to compile in the +// macro crate. Core re-exports these: `pub use manifest::{Capability, CapabilitySupport};`. +// +// MUST derive Serialize as well as Deserialize: `Manifest` derives `Serialize` and +// `app!` calls `serde_json::to_string(&manifest)` — a Deserialize-only capability type +// breaks the `Manifest` derive. (Both facts verified against the current tree.) + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] // a future capability must not break out-of-tree adapters +// Outbound-only excerpt. The complete shared enum also contains config, ingress, and +// response-egress cells specified by their owning designs. Declaration order follows the +// repository's alphabetical-item lint. +pub enum Capability { + LazyStreamedResponsePassthrough, // downstream response chunks flow without + // collecting the whole body. Cloudflare is + // Native alongside Axum; Fastly/Spin are BestEffort. + OutboundAuthorityOverride, // wire HTTP authority can differ from connection/TLS + // identity without retargeting the request. + OutboundBatchCancellation, // unresolved observation can stop and invokes the + // adapter's strongest documented teardown. + OutboundBatchCompletionOrder, // indexed terminal slots are observable as they finish. + OutboundBatchSlotIsolation, // sibling timing cannot change the result a slot + // would have produced in isolation. + OutboundCacheBypass, // bypasses adapter-managed intermediary response cache + // without synthesizing origin-visible headers. + OutboundCompleteResourceAccounting, // every response allocation from host parsing + // through guest delivery has a portable bound; + // Unsupported on all current targets. + OutboundDeadlines, // one exchange budget: connect, headers, + // buffered body, and streamed body yields. + // Cross-slot selection/drain delay is owned by + // OutboundBatchSlotIsolation. + OutboundFlexiblePhaseBudget, // the total budget is one elastic pool rather + // than a rigid provider-specific phase split. + OutboundHeaderFidelity, // request field lines survive transport conversion; + // raw response-header octets and original field-line + // boundaries reach security-sensitive normalization. + OutboundHttp, // adapter supplies an outbound HTTP dispatch path; + // support level and documented prerequisites determine + // whether a selected deployment may rely on it + StreamedUploadDeadlines, // can preempt a stalled request-body source/write; + // Fastly and Spin are BestEffort. +} + +impl Capability { + pub fn as_str(&self) -> &'static str; // kebab-case, for messages +} + +// Also inline in manifest.rs (see Capability note above). `Serialize` is needed only if +// it ever appears in a serialized manifest field; it does not today (support is computed +// per-adapter via `Adapter::capability`, not stored), so `Deserialize`/`Serialize` are +// omitted here. If a future manifest field carries a `CapabilitySupport`, add both. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum CapabilitySupport { + /// Implemented, but with a documented limitation or deployment prerequisite that the + /// matrix footnotes describe. A prerequisite that static adapter metadata cannot prove + /// may make the path unavailable in a particular deployment; applications declaring the + /// capability optional explicitly accept that runtime failure mode. The limitation can + /// otherwise be timing-related (unbounded cooperative + /// enforcement, e.g. Fastly source-stream-stall in + /// `streamed-upload-deadlines`) **or observational** (the adapter preserves lazy delivery but + /// cannot prove end-client acceptance, e.g. Axum `lazy-streamed-response-passthrough`). + /// `BestEffort` therefore means + /// "implemented, with a real-world deviation or prerequisite you need to read the + /// footnote to understand" — not specifically "unbounded cooperative timing." + BestEffort, + /// Real enforcement with a precisely documented, deterministic bound on any + /// deviation. No current outbound matrix cell uses this level; it remains in + /// the support ladder for future capabilities with a true end-to-end bound. + BoundedCooperative, + /// Fully supported with no documented caveats. + Native, + /// Not available. + Unsupported, +} +``` + +The capability is named **`outbound-deadlines`**, not `timers`, and describes the +wall-clock budget contract for one outbound HTTP exchange. The matrix support level says +whether that contract is Native or has a documented BestEffort gap. It makes no claim +about timing arbitrary guest computation (which EdgeZero does not offer — §3.3.5). + +```rust +// crates/edgezero-core/src/manifest.rs — new field on Manifest. +// +// MUST derive Serialize as well as Deserialize: `Manifest` derives `Serialize` +// (manifest.rs) and `app!` calls `serde_json::to_string(&manifest)`. A +// Deserialize-only member breaks `Manifest`'s derive and therefore edgezero-core +// AND the macro crate (which textually `include!`s this file). Same for every +// nested type and for `Capability` itself. +// `deny_unknown_fields` is REQUIRED — without it a typo like `require = [..]` or +// `host = [..]` is silently ignored, disabling enforcement or invoking the broad default +// (fail-open). The `#[validate(custom = ..)]` attaches the disjoint/duplicate check. +// `#[non_exhaustive]` matches the existing manifest-struct precedent and keeps future field additions +// non-breaking for out-of-tree code and composes fine with `Default` + `Deserialize` +// (these are built by deserialization / `..Default::default()`, never external literals). +#[derive(Debug, Default, Serialize, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +#[non_exhaustive] +#[validate(schema(function = "validate_capabilities_disjoint"))] +pub struct ManifestCapabilities { + #[serde(default)] + pub required: Vec, + #[serde(default)] + pub optional: Vec, + #[serde(default)] + #[validate(nested)] + pub outbound: ManifestOutboundCapability, +} +// `validate_capabilities_disjoint(&ManifestCapabilities) -> Result<(), ValidationError>` +// rejects a capability DUPLICATED within `required` or `optional`, or listed in BOTH +// (required ∩ optional must be empty — asking for a capability as both is a +// contradiction). Every manifest parse path rejects these failures; baked input becomes +// `Malformed`. §5.4 tests: an unknown field (typo `require`), a duplicate, and a +// required∩optional overlap each fail. + +#[derive(Debug, Default, Serialize, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +#[non_exhaustive] +pub struct ManifestOutboundCapability { + /// Outbound host plumbing. + /// + /// **`Option>`, NOT `Vec` — this is security-relevant.** + /// The renderer defines an explicit `"*"` as **http + https**, while an + /// **absent** field must preserve today's **https-only** default + /// (`["https://*:*"]`). A bare `Vec` with `#[serde(default)] = ["*"]` collapses + /// those two cases, so **every existing manifest that never declared hosts would + /// silently gain cleartext (http) outbound permission** on its next build. The + /// `Option` keeps them distinct: + /// - field absent → `None` → render `["https://*:*"]` (unchanged) + /// - explicit `hosts = ["*"]`→ `Some(["*"])` → render http + https (opt-in) + /// - explicit `hosts = []` → `Some([])` → rejected by `length(min = 1)` + /// + /// Validation applies only when present: `length(min = 1)` enforces at least one + /// entry, and `validate_outbound_hosts` (below) checks each entry against + /// the accepted forms (wildcard, scheme-prefixed, host:port, bare host, + /// wildcard subdomain). `#[validate(nested)]`-style option handling: the custom + /// validator is a no-op for `None`. + #[serde(default)] + #[validate(length(min = 1), custom(function = "validate_outbound_hosts"))] + pub hosts: Option>, +} + +/// Per-entry validation for `[capabilities.outbound].hosts`. This is +/// **host-authority-only plumbing**, not a URI field — the same rationale as +/// `OutboundRequest`'s userinfo rejection: credentials must not leak +/// through the manifest into `allowed_outbound_hosts`. +/// +/// Each entry MUST be one of: +/// - `"*"` (the wildcard). +/// - `scheme://host[:port]` where: +/// - `scheme ∈ {http, https}`, case-**insensitive** at the validator +/// (RFC 3986) — `HTTPS`, `https`, `Https` all accepted. The +/// Spin renderer then canonicalizes to lowercase before emitting +/// `spin.toml`, so the rendered manifest carries one canonical +/// spelling. Other schemes → rejected at the validator. +/// - `host` is a DNS label, IPv4 literal, IPv6 literal in brackets, or +/// `*` / `*.domain.tld` wildcard form. +/// - `port`, if present, is a decimal integer in `1..=65535` **or the literal +/// `*` (wildcard port = any port)**. The wildcard port is what lets a manifest +/// express "**HTTPS to any host on any port**" as `https://*:*` while still +/// scoping HTTP narrowly (`http://api.internal:8080`) — a distinction the bare +/// `"*"` (which grants http+https on any host/port) cannot make. `:*` is +/// therefore VALID; only a bare numeric out of `1..=65535` rejects. +/// - **NO userinfo, NO path, NO query, NO fragment.** `https://user:pass@x`, +/// `https://x/p`, `https://x?q`, `https://x#f` all reject. +/// - `host[:port]` (no scheme) — same host/port rules as above. +/// +/// Empty entries, schemes other than `http`/`https`, ports outside +/// `1..=65535` or non-numeric, any userinfo / path / query / fragment, and +/// malformed authorities the hand-rolled splitter rejects (NOT `http::Uri` — +/// the macro-crate constraint below forbids that dep) all yield a `ValidationError`. `"*"` +/// mixed with specific hosts is allowed; the wildcard renders both schemes +/// and specific hosts render alongside. +/// +/// **Grammar (this is a security-relevant splitter — spell it out, don't hand-wave):** +/// `entry := "*" | [scheme "://"] authority`; `scheme := "http" | "https"` (ASCII, +/// case-insensitive); `authority := hostpat [":" port]`; `hostpat := "*" | +/// "*." label ("." label)* | label ("." label)* | "[" ipv6 "]"`. A **`label`** is a +/// non-empty LDH DNS label: 1..=63 chars of ASCII letter/digit/hyphen, **no leading or +/// trailing hyphen, no underscore, no empty label** (so `-x.com`, `x-.com`, `x..com` +/// (empty middle label), `x_y.com` all reject; full name ≤ 253 chars). **`ipv6`** is a +/// standard RFC 4291 address parsed by `std::net::Ipv6Addr::from_str` on the +/// bracket-stripped inner text — anything it rejects (`[::g]`, `[:::1]`, `[12345::]`, +/// `[1:2:3:4:5:6:7:8:9]`) rejects here. `port := "*" | 1..=65535`. **ASCII/IDNA policy:** hostnames are **ASCII-only** — a non-ASCII byte +/// or a raw Unicode label is REJECTED (callers must pre-encode to punycode `xn--`); +/// this splitter does not perform IDNA itself. +/// +/// has a Tier 1 test row exercising every accept AND reject case. Rejects: +/// empty string, bad scheme (`ftp://x`), missing authority (`https://`), +/// userinfo (`https://u:p@x`), path (`https://x/p`), query (`https://x?q`), +/// fragment (`https://x#f`), out-of-range port (`https://x:0`, `https://x:70000`), +/// non-numeric port (`https://x:abc`), **empty port (`https://x:`)**, +/// **malformed brackets (`https://[::1` unclosed, `https://::1]` no open bracket)**, +/// **unbracketed IPv6 (`https://::1` — colons ambiguous with the port sep)**, +/// **invalid wildcard placement (`ex*ample.com`, `*.*.com`, `a.*.com`, `**.com`)**, +/// **internal/leading/trailing whitespace (`https:// x`, `x .com`, ` x.com`, `x.com `)**, +/// **trailing dot (`x.com.`)**, **non-ASCII / raw-Unicode host (`ex€ample.com`, `café.com`)**. +/// Accepts: wildcard (`*`), wildcard subdomain (`*.example.com`), bare host with port +/// (`x:8443`), bracketed IPv6 (`https://[::1]`), IPv4 (`https://127.0.0.1`), +/// punycode (`xn--caf-dma.com`), and mixed `"*"` + host. +// Takes the INNER Vec — `validator` applies a custom function on `Option` to the +// contained value only, so `None` (field absent → https-only default) is a +// no-op and never fails validation. Signature matches the `Option>` field. +fn validate_outbound_hosts(hosts: &[String]) -> Result<(), ValidationError>; + +// SHARED CANONICALIZER — one parser, three consumers, ATOMIC entries. `validate_outbound_hosts` +// returns `()` (validator contract), but platform-manifest rendering and the +// build/serve/deploy DRIFT check both need the *canonical* form to compare — and they +// MUST NOT re-implement parsing (divergence = a manifest that validates but drifts, or +// drifts spuriously). So both delegate to one function that expands each manifest entry +// into a SET of ATOMIC `(scheme, host, port)` triples — because ONE manifest entry can +// render as MULTIPLE `spin.toml` entries (`"*"` = http AND https → two lines), a +// canonicalizer that returned a single multi-scheme value could never set-equal the two +// rendered lines. Atomic-and-flatten fixes that: +// PUBLIC + cross-crate: the consumers (platform-manifest generation and Spin drift validation +// in the adapter crate) are DIFFERENT crates, so the fn and ALL its types MUST be `pub` and +// exported from `edgezero-core` (a private `fn`/type would not compile at those call sites). +// Concretely: +// - The error is a DEDICATED `pub enum HostParseError` — NOT the validator's +// `ValidationError` (that would leak validator internals into the adapter crate, which +// doesn't depend on `validator`). The MANIFEST validator is a thin wrapper that calls +// `canonicalize_outbound_host` and maps `HostParseError -> ValidationError` at the +// validator boundary only; rendering/drift get the `HostParseError` directly. +// - `AtomicHost` and ALL its component types are public: `pub struct AtomicHost` with +// `pub scheme: Scheme`, `pub host: HostPat`, `pub port: Port`, and `pub enum Scheme`, +// `pub enum HostPat` (`Any` | `Exact(String)` | `WildcardSubdomain(String)`), +// `pub enum Port` (`Any` | `Exact(u16)`). `HostPat` is part of the surface (an earlier +// draft omitted it). Derive `Hash, Eq, PartialEq` for the drift `HashSet`. +// - The parser error surface is exact and safe to expose across crates: +// +// #[non_exhaustive] +// #[derive(Clone, Debug, Eq, PartialEq)] +// pub enum HostParseError { +// Empty, +// FragmentNotAllowed, +// InvalidHost, +// InvalidPort, +// InvalidScheme, +// InvalidWildcard, +// MissingAuthority, +// NonAsciiHost, +// PathNotAllowed, +// QueryNotAllowed, +// UserinfoNotAllowed, +// Whitespace, +// } +// +// It implements `std::error::Error`. Its `Display` messages are respectively +// `host entry is empty`, `fragment is not allowed`, `host is invalid`, +// `port must be * or 1..=65535`, `scheme must be http or https`, +// `wildcard is invalid`, `authority is required`, `host must be ASCII`, +// `path is not allowed`, `query is not allowed`, `userinfo is not allowed`, and +// `whitespace is not allowed`. +// Messages never echo the caller input. Tier 1 maps every reject-table row to one +// variant and asserts nonempty stable Display output; downstream callers include a +// wildcard arm because the enum is non-exhaustive. +// For inputs violating multiple rules, classification precedence is fixed as follows; +// host syntax (including malformed brackets or unbracketed IPv6) is resolved before a +// trailing port is parsed: Empty → Whitespace → InvalidScheme → +// MissingAuthority → UserinfoNotAllowed → PathNotAllowed → QueryNotAllowed → +// FragmentNotAllowed → NonAsciiHost → InvalidWildcard → InvalidHost → InvalidPort. +// - Platform-manifest generation must not hand-inspect internals to build `spin.toml`, so `AtomicHost` +// exposes a canonical rendering method: `pub fn render_spin_host(&self) -> String`. +// **It OMITS a scheme-default exact port** (443 for `Https`, 80 for `Http`) so the +// output is deterministic: `https://x` and `https://x:443` both canonicalize to +// `{Https, Exact("x"), Exact(443)}` and BOTH render as **`"https://x"`** (NOT +// `"https://x:443"` — explicitness is already lost at canonicalization, so the renderer +// must not re-introduce a port that would then mismatch the manifest's `https://x` +// form). A non-default port renders explicitly: `{Https, Exact("x"), Exact(8443)}` -> +// `"https://x:8443"`. `Port::Any` renders `":*"`; `HostPat::Any` renders `"*"` as +// the HOST COMPONENT while retaining the atomic's concrete scheme and port. Therefore +// `{Https, Any, Any}` renders exactly `"https://*:*"`, NEVER bare `"*"` (which Spin +// interprets as both http and https and would widen the https-only default). +// `WildcardSubdomain("example.com")` renders the host component `"*.example.com"`. +// Drift uses `Eq`/`Hash` on the atomics; rendering uses `render_spin_host`. Neither +// re-parses. The input shorthand `"*"` expands to two atomics and consequently renders +// as two explicit lines: `"http://*:*"` and `"https://*:*"`. +// Keep it dependency-free (no `http::Uri`, per the macro-crate constraint below). +// pub fn canonicalize_outbound_host(entry: &str) -> Result, HostParseError>; +// pub struct AtomicHost { pub scheme: Scheme /* Http | Https, CONCRETE — never "both" */, +// pub host: HostPat, pub port: Port /* Any | Exact(u16) */ } +// `"*"` -> [ {Http, *, *}, {Https, *, *} ] (two); `https://x` -> [ {Https, x, Exact(443)} ] +// (one); `https://x:*` -> [ {Https, x, Port::Any} ]. Both the manifest hosts AND the parsed +// `spin.toml` `allowed_outbound_hosts` are flattened through this into a `HashSet`, +// and DRIFT compares the two SETS. So `https://x:443` == `https://x`, `HTTPS`==`https`, +// `:*` == any-port, list order is irrelevant, AND `"*"`/`:*` round-trip +// (manifest one-entry -> two atomics == spin.toml two-lines -> two atomics). §5.4 pins a +// render-then-validate round-trip for BOTH `"*"` and `:*` (the generated values must +// report NO drift). +// +// DEPENDENCY CONSTRAINT: `canonicalize_outbound_host` lives in `manifest.rs`, which is +// **textually `include!`d into `edgezero-macros`** — a crate with **no `http` dependency**. +// So the canonicalizer/`validate_outbound_hosts` MUST be **dependency-free**: parse the +// authority with a small hand-rolled splitter (scheme `://` split, rsplit host:port, +// bracket-strip IPv6), **NOT** `http::Uri`. Using `http::Uri` here would force adding +// `http` to `edgezero-macros`'s `Cargo.toml` (and the include! then drags it into every +// build of the macro crate). Either keep it dependency-free (preferred) or the §7 +// inventory must add `http` to `crates/edgezero-macros/Cargo.toml`. Preferred: no dep. + +// Manifest gains: #[serde(default)] #[validate(nested)] +// pub capabilities: ManifestCapabilities, +// +// AND the TOP-LEVEL `Manifest` struct itself gains `#[serde(deny_unknown_fields)]`. +// Without it the strictness above is defeated one level up: `deny_unknown_fields` on +// `ManifestCapabilities` only catches a bad key INSIDE a correctly-spelled +// `[capabilities]` table. A misspelled SECTION — `[capabilites]`, `[capability]`, +// `[Capabilities]` — is an unknown top-level field, silently dropped, leaving +// `capabilities` at its `Default` (empty required+optional). That is fail-OPEN: the +// app declares a contract, the contract vanishes, and `ensure_capabilities` waves it +// through because `caps.required` is empty. +// #[serde(deny_unknown_fields)] +// pub struct Manifest { .. } +``` + +Every capability field is `#[serde(default)]`, so **schema-conforming** manifests parse +unchanged. This is deliberately narrower than "all existing manifests": the top-level +`#[serde(deny_unknown_fields)]` (above) is an intentional behaviour change — a manifest +carrying an **unknown top-level section** (a custom/misspelled `[...]` table that older +builds silently ignored) now **fails to parse**. That break is the fail-closed direction +and is inventoried here; any repo relying on unknown top-level sections for custom +metadata must move them under a modelled section. + +**Top-level strictness is safe here** — `Manifest` already models every documented +section (`adapters`, `app`, `environment`, `logging`, `stores`, `triggers`), so +`deny_unknown_fields` rejects only genuinely-unknown sections, not valid ones. It is a +deliberate behaviour change: a stray/misspelled top-level section becomes a **hard parse +error** instead of a silent drop, which is the fail-closed direction for a +security-relevant contract. (`#[serde(skip)]` internals are unaffected — they are never +read from input.) §5.4 pins this with regression rows: a manifest whose only capability +declaration is under `[capabilites]` (transposed) must **fail**, not parse to an empty +contract; likewise `[capability]` and a stray unknown section. + +**Top-level `deny_unknown_fields` is necessary but NOT sufficient — a `[capabilities]` table +misplaced at ANY depth would be silently dropped.** Per-struct `deny_unknown_fields` only +catches ONE level (`[app.capabilities]`), and chasing every level is whack-a-mole: +`[triggers.http.capabilities]`, `[environment.variables.capabilities]`, +`[adapters.axum.build.capabilities]` are two levels down, and `ManifestAdapter` can't even +take `deny_unknown_fields` (its `#[serde(flatten)]` legacy map is mutually exclusive with +it). **So the PRIMARY defense is a depth-independent reserved-key scan, not per-struct +strictness.** Before deserializing into `Manifest`, parse the input into a `toml::Value` +or `serde_json::Value` and recursively inspect table/object keys. The only accepted reserved +key is the exact lowercase top-level `capabilities`; any nested key for which +`key.eq_ignore_ascii_case("capabilities")`, and any non-lowercase top-level spelling, is a +hard error. The TOML and baked-JSON walkers share this key policy and array recursion. This +catches +`[app.capabilities]`, `[triggers.http.capabilities]`, `[environment.variables.capabilities]`, +`[adapters.axum.build.capabilities]`, and anything future, in one place, without touching +every struct and without the `flatten` conflict. (Top-level `deny_unknown_fields` still +guards misspelled top-level *sections* like `[capabilites]`; the two are complementary — the +scan owns the depth problem.) §5.4 adds rows: a `capabilities` table nested under `app`, +`triggers.http`, `environment.variables`, and `adapters.axum.build` must EACH **fail to +parse**, not silently drop the block and run with an empty contract. + +#### 3.5.2 Adapter capability metadata + +The `Capability` / `CapabilitySupport` enums are `#[non_exhaustive]` (a future capability +must not force every out-of-tree adapter to recompile-or-break), and `Adapter::capability` +carries a **default returning `CapabilitySupport::Unsupported`** for any capability an +adapter doesn't recognize — so an out-of-tree adapter compiled against an older core still +builds, and an unknown capability fails closed (Unsupported → a `required` mismatch +hard-fails) rather than failing to compile. The registry `Adapter` trait also reports typed target +resource facts through a defaulted `platform_metadata` method. This outbound spec does not change +or depend on the trait's store/config lifecycle methods: + +```rust +// crates/edgezero-adapter/src/registry.rs — current (post-#269) shape +pub trait Adapter: Sync + Send { + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String>; + fn name(&self) -> &'static str; + // Added by this spec. MUST carry a default body: without one, every existing + // (and every out-of-tree) adapter compiled against an older core would fail to + // build, and the intended "unknown capability ⇒ unsupported" contract would not + // hold. The default returns `Unsupported`; in-tree adapters override it. + fn capability(&self, _capability: Capability) -> CapabilitySupport { + CapabilitySupport::Unsupported + } + // Target resource metadata, separate from behavioral capability support. The + // default preserves unknown memory, population, and host-accounting facts. + fn platform_metadata(&self) -> PlatformMetadata { + PlatformMetadata::default() + } + // NOTE for in-tree overrides: an adapter that overrides `capability` REPLACES this + // default — the default does not run for capabilities the override's `match` doesn't + // name. `Capability` is `#[non_exhaustive]`, so every in-tree `match capability { .. }` + // MUST end with `_ => CapabilitySupport::Unsupported`, or a capability added later + // reads as some accidental value instead of the intended fail-closed `Unsupported`. + + // Existing non-outbound methods are elided. `ensure_capabilities` consults only + // `capability(..)` for behavior gates. Resource-policy consumers consult + // `platform_metadata()` independently. +} +``` + +This reference is intentionally partial. Existing non-outbound trait methods retain +their current ownership and behavior. + +`PlatformMetadata` carries `PlatformFact` values for the memory ceiling, the maximum live inbound +request population charged to that memory domain, and whether host-owned ingress memory counts +toward the ceiling. Each known fact carries `PlatformResourceSource`; each unknown fact carries a +`PlatformUnknownReason`. `MemoryCeiling` itself carries exact primary bytes, an optional separately +published stack allowance, and `MemoryCeilingScope::{PerExecution, PerInstance}`. + +Axum records all three facts as `Unknown(OperatorConfigured)`. Cloudflare records a platform-limit +memory fact of 128,000,000 bytes per instance while population and host accounting are +`Unknown(ProviderUnpublished)`. Its six-simultaneous-outbound-connection limit applies to +connections waiting for response headers within one invocation and is not evidence of an inbound +request population bound. Fastly records a platform-limit 128,000,000-byte heap plus a separate +1,000,000-byte stack per execution, a platform-limit population of one, and +`Unknown(ProviderUnpublished)` host accounting. Generic Spin records all three facts as +`Unknown(RuntimeConfigured)`. The explicit Akamai Functions Spin profile records a hosted-default +134,217,728-byte ceiling per execution, a hosted-default population of one, and +`Unknown(ProviderUnpublished)` host accounting. Generic Spin does not inherit that profile because +its deployment memory is runtime-configurable. + +All adapters currently receive host-parsed requests. Provider-unpublished host parser, HPACK, and +framing allocations therefore remain unknown; guest-visible allocation accounting cannot replace +that missing provider fact. `PlatformMetadata::validate_memory_envelope` returns `Fits` only when +the ceiling, population, host accounting, scope, and any separate-stack requirement are complete +and compatible. A known minimum excess returns `Exceeds` even if another fact is unknown. +Otherwise, missing facts return `Indeterminate` with typed reasons and must not be treated as a +complete validation result. + +The registry is CLI-owned, so runtime adapters also pass the same canonical `PlatformMetadata` +to the core-owned `App::build::(platform)` before `Hooks::configure` runs. Application hooks +cannot replace this assembly step. `App::platform()` therefore +supports startup resource validation without linking CLI registration code into runtime artifacts. + +**Publication order:** adding the trait/default does not immediately advertise the matrix. +Each in-tree adapter inherits `Unsupported` until its outbound implementation, deterministic +contracts, and any required host evidence land. That adapter's exact override is committed +in the same phase. The completed repository has the matrix below; no intermediate branch +state may claim support for behavior that has not landed. + +Capability matrix (all four adapters): + +| Capability | Axum | Cloudflare | Fastly | Spin | +| --- | --- | --- | --- | --- | +| `outbound-http` | Native | Native | BestEffort⁹ | Native | +| `outbound-authority-override` | Native | BestEffort⁸ | Native | Unsupported[^spin-authority] | +| `outbound-batch-cancellation` | Native | BestEffort⁸ | BestEffort⁴ | BestEffort⁸ | +| `outbound-batch-completion-order` | Native | Native | BestEffort⁴ | Native | +| `outbound-batch-slot-isolation` | Native | Native | BestEffort⁴ | Native | +| `outbound-cache-bypass` | Native | Native | Native | Native | +| `outbound-complete-resource-accounting` | Unsupported[^resource-accounting] | Unsupported[^resource-accounting] | Unsupported[^resource-accounting] | Unsupported[^resource-accounting] | +| `outbound-header-fidelity` | Native | BestEffort⁸ | Native | Native | +| `outbound-deadlines` | Native | BestEffort⁸ | BestEffort¹ | BestEffort⁸ | +| `outbound-flexible-phase-budget` | Native | Native | BestEffort⁵ | BestEffort⁵ | +| `streamed-upload-deadlines` | Native | BestEffort⁸ | BestEffort² | BestEffort⁸ | +| `lazy-streamed-response-passthrough` | Native³ | Native | BestEffort⁶ | BestEffort⁷ | + +[^spin-authority]: Spin's WASI HTTP authority is both the connection target and outgoing HTTP + authority. EdgeZero rejects an explicit override before body polling or provider dispatch + rather than silently retargeting the connection or ignoring the requested wire contract. + +[^resource-accounting]: This capability means a complete pre-admission bound for process or + isolate memory attributable to one outbound response, including provider parser and + field-section materialization, informational responses, final headers, trailers, + native receive chunks, guest buffers and spare capacity, decoder state, allocator + metadata, and runtime copies. Current platform APIs do not expose or bound every term, + so all four adapters report `Unsupported`. EdgeZero still enforces and documents its + narrower guest-visible body limits and adapter-visible field limits: encoded bytes, + decoded bytes, final Buffered bytes, upstream header fields before normalization plus + EdgeZero's synthetic proxy marker, Brotli decoder-state charge, and emitted item shape. A caller + requiring a complete RSS/isolate bound must declare this capability required and fail + closed on every current target rather than treating the narrower arithmetic in §3.4.4 + as a complete memory guarantee. + +¹ **Fastly `outbound-deadlines` is `BestEffort`, because it cannot be guaranteed on +every request.** Even with an already-registered/cached backend, any non-empty request body +has an unbounded guest-to-origin host-write interval: Fastly exposes no upload-write timer. +For a zero-length request body on the warm path, the dispatch/headers and response-read +portions have the documented deterministic overshoot bounds below (common-case +`total_ms ≥ 4` phase split; the sub-4 ms branch adds `total_ms`, see §4.3 "Net guarantee"). +Separately, the **FIRST request to a new host** calls `Backend::builder(..).finish()`, a synchronous host +call that can block waiting for a service-wide dynamic-backend slot. In `send_all_until`, a cache miss +checks the absolute deadline and `BATCH_DISPATCH_SLACK_MAX` before entering `finish()`, which +prevents an already-delayed later slot from starting another registration. Nothing guest-side +can preempt a registration after it starts, so it may still overshoot before the post-registration +guard executes; on that path the guard checks the **absolute deadline FIRST** and returns an +**attributed `gateway_timeout` (504)** — a `finish()` that returns past the deadline is a +genuine expiry, not an EdgeZero bug (`internal` is reserved for slack exceeded *while time +remains*, §4.3). Either way the wall-clock overshoot happened, so the capability is still +`BestEffort`. Because `capability()` is a **static** value +that cannot distinguish cached from cold, the honest declaration is **`BestEffort`** — a +`required outbound-deadlines` therefore hard-fails on Fastly rather than passing a gate it +cannot actually honour for cold registration or body-bearing writes. Apps that accept +those gaps declare it **`optional`** (logged, never gated) and get the documented partial +bounds below. + +**These dispatch/headers and response-read bounds hold once the backend is registered +(cached); they do not bound request-body transmission.** On the +**first** request to a new host, `Backend::builder(..).finish()` — a +synchronous host call that can block waiting for a service-wide dynamic-backend slot — +may overshoot after the batch pre-registration guard admits it and before the post-registration +guard immediately ahead of `send_async`. On that path the latter guard checks +the deadline first and returns an **attributed `gateway_timeout` (504)** for the actual +expiry (not `internal`); the wall-clock was still overshot, which is exactly why the +capability is `BestEffort` and not `BoundedCooperative` — see §4.3 +*Honesty note — what the guard can and +cannot do*. +- **Single `send`** — `now` is snapshotted inline so there is no batch drift, + and the post-registration **`BATCH_DISPATCH_SLACK_MAX` guard** applies to the gap between + `dispatch_budget(req, now, None)` and `send_async` (backend lookup, possible + `Backend::builder().finish()`, SDK request construction; see §4.3). Worst-case + warm-path dispatch+headers overshoot is `BATCH_DISPATCH_SLACK_MAX + ms_rounding`; the + window is typically narrower than a batch because there is no multi-slot preflight and + dispatch loop. Response-body phase overshoot ≤ one between-bytes-timeout + interval (§3.3.4). For a non-empty buffered or streamed request body, the upload write + remains unbounded and sits outside these finite terms. **Streamed-upload-specific + post-upload overshoot**: when the request + body is `Body::Stream` and the upload drain leaves a tiny positive + `budget.deadline.remaining()`, the post-upload headers wait can additionally + cost up to one dispatch-time `first_byte_ms` interval before the cooperative + check at the `wait()` boundary or the response-wrapper preemption fires + (§4.3 "Response phase"). That overshoot is **one-shot**, not per-chunk — + the response wrapper preempts at the first post-deadline read. +- **`start_batch_until` / `send_all_until`** — `batch_now` is shared across slots so the measured setup and + host-timer portions of dispatch+headers carry + `BATCH_DISPATCH_SLACK_MAX + ms_rounding` (≈ 26 ms when `total_ms ≥ 4`, §4.3 + "Dispatch-overhead slack"). A non-empty buffered body's unbounded host write proceeds + after `send_async` has returned, so it does not prevent later dispatch calls, but the slot + cannot complete until that background transmission and its response progress. Response-body + phase **once a slot is actively draining** is still ≤ one between-bytes-timeout — but the slot's **observed + completion** can additionally be delayed while previously selected response bodies drain. + That selection/drain delay is what the separate + `outbound-batch-slot-isolation` capability owns (footnote 4); the + `outbound-deadlines` bound here is on the active-drain phase only, not on + total observed wall-clock across the batch. + +The finite terms above are hard adapter constants, not "scales with preflight"; cold +registration and request-write completion remain explicitly unbounded. `Native` is reserved +for adapters with no such caveat — this rubric lets future adapters be judged consistently +without quiet downgrading. A new adapter unable to honour a capability declares +`Unsupported` and is caught at build time. The batch *buffered-body* cross-slot +caveat (selected-body-drain false 504s) is **not** within this capability — that one is +`outbound-batch-slot-isolation` (footnote 4), so each label means exactly one thing. + +² Fastly has no guest primitive to preempt a stalled `stream.next().await` while feeding +a streamed REQUEST body via `send_async_streaming` (§4.3). **Both phases are unbounded +on Fastly:** (a) the *source pull* — a source stream that never yields the next chunk +cannot be preempted; and (b) the *host write* — `between_bytes_timeout` is documented as +**receive-side only** (it bounds the gap between bytes *received from origin*) and does +**not** bound guest-to-origin writes, so it gives no inter-chunk guarantee on the upload +path. (An earlier draft of this footnote claimed `between-bytes-timeout` still bounds +upload inter-chunk gaps — that is wrong; §4.3 and §8 risk 7 are correct.) The only +adapter-side bound is the cooperative `budget.deadline.is_expired()` check **between** +chunks. This is `BestEffort` — no documented preemption bound — and is exposed as the +separate +`streamed-upload-deadlines` capability so apps that need real-time enforcement on this +specific path declare it required and get a hard build failure on every BestEffort target, +including Fastly and Spin, per §3.5.3. +Apps that buffer their request bodies before calling `send` are unaffected **on the +source-pull axis only** — buffered uploads use `Body::Once` with no `stream.next().await`. +They are **NOT** exempt on the **host-write** axis: Fastly's `connect_timeout` ends at TLS +setup and `first_byte_timeout` starts at *"request sent"*, so the interval in which the +host pushes the buffered body to the origin is bounded by **no** Fastly timer, and +`between_bytes_timeout` is receive-side only. An origin that completes the handshake then +stops reading stalls that write unbounded, and the guest cannot abort a dispatched +`PendingRequest` (§3.3.3). Buffered uploads therefore fall under `outbound-deadlines`, +which is **`BestEffort` on Fastly** (footnote 1) — a label that already covers this gap; +buffering narrows the exposure to the write phase, it does not remove it. + +⁵ `outbound-flexible-phase-budget` captures whether the adapter treats the request +budget as one elastic pool. Axum and Cloudflare have one authoritative total timer +(reqwest's `.timeout(..)` and `worker::Delay`); a slow connect followed by fast +headers/body inside the total budget succeeds. On Fastly the budget is +**rigidly split** (§4.3 — `connect = budget/4`, `first_byte = 3*budget/4`, +`between_bytes = budget`); a request that takes more than `budget/4` on connect-phase +work fails at the connect timer even though the rest of the budget would have +sufficed. This is a documented `BestEffort` deviation — the platform-level cause is +that Fastly's `BackendBuilder` exposes per-phase timers and no total-budget timer. +Apps that need elastic budget allocation (slow-connect workloads, mixed-latency +upstreams) declare this capability required and get the hard build failure on +Fastly per §3.5.3. Spin also runs an outer monotonic total race, but WASI permits any +`RequestOptions` phase-timer setter to return `NotSupported`. In that case an opaque host +default can fail earlier than the configured total budget, so the static capability is +`BestEffort` even though the guest-visible outer race remains. Promotion requires a target +whose three setters are guaranteed or a host API that disables independent defaults. + +⁴ `outbound-batch-slot-isolation` is `BestEffort` on Fastly for two direct cross-slot reasons. +**(a) Selected response-body drain** (§3.3.4): a slot whose own +`budget.deadline` would have covered its body in isolation can still return +`gateway_timeout`, or remain unresolved at the observation cutoff, because another selected body +monopolised the guest before the next `select_handles` call. Selection is not input-ordered, but +body consumption is still one selected slot at a time. **(b) Cold sequential registration** +(§4.3): a first-time `Backend::builder(..).finish()` can block without a guest-side bound, +preventing later slots from being dispatched. A non-empty buffered upload has no finite +guest-to-origin write bound and can leave its own slot unresolved, but after `send_async` returns +it does not by itself block later dispatch or selection of another ready handle in the active +group. + +Selected-body delay becomes smaller for small response bodies. Small responses do not repair +cold registration, and a small request body still has no documented write-time bound for its own +slot. Consequently the +spec makes no general "typical small-body fan-outs are unaffected" claim. Apps that need +cross-slot result isolation declare this capability required and get a hard build failure on +Fastly per the "required + BestEffort = hard fail" rule (§3.5.3). On Axum/CF/Spin, +the completion driver polls complete per-slot exchanges concurrently, so isolation is `Native`. + +³ `lazy-streamed-response-passthrough` captures whether +`OutboundResponse::into_response()` delivers a streamed upstream body to the platform +response **without whole-body buffering**. Axum and Cloudflare are `Native`; Fastly and Spin are +`BestEffort` under footnotes 6 / 7 pending end-to-end target evidence. Axum runs the non-`Send` +portable stream directly on the connection's `LocalSet` and yields one frame under Hyper demand; +unit and raw-socket tests prove first-frame delivery before source EOF. Socket/client completion +and collateral HTTP/1 deadline closure belong to the separate response-egress capabilities and +remain BestEffort. + +⁶ Fastly uses an undecorated, send-owning entrypoint. EdgeZero receives the request and owns +`stream_to_client`, writes portable chunks with short-write accounting, and closes or abandons the +body handle exactly once. The cell remains `BestEffort` because synchronous source polls and host +writes cannot be preempted, and a consuming `finish` failure leaves no handle to abandon. + +⁷ Spin returns EdgeZero's raw `SpinResponse` and keeps the WASI response, body, and result writers +inside one spawned coordinator. It preserves lazy production, accounts accepted prefixes, races +pending writes against the absolute deadline, and destroys the response on failure. The cell +remains `BestEffort` because guest cancellation does not establish a deployed finite host-teardown +or end-client-completion bound. The hand-built outbound request path remains independently +`BestEffort` per footnote 8. + +⁸ **Spin/Cloudflare deadline and Cloudflare header-fidelity caveats.** Spin has a monotonic timer and can race the +guest-visible exchange, but Component Model cancellation is cooperative. Dropping a +`FutureWriter` that has not written launches a background default write, and dropping a +canonical-ABI subtask does not establish a documented one-tick host teardown bound. The +adapter therefore implements explicit request/response completion protocols (§4.4) and +returns a timeout when its timer wins, while `outbound-deadlines` and +`streamed-upload-deadlines` remain `BestEffort` until host-observed runtime tests prove +bounded teardown for stalled upload and response paths. Separately, Spin exposes raw +header bytes and original field lines, so `outbound-header-fidelity` is `Native`. +Cloudflare wires one owned `AbortController` through the final fetch options and body wrapper, +but the PR has no deployed host-observed cancellation artifact proving a finite bound. Its +`outbound-deadlines` and `streamed-upload-deadlines` cells therefore remain `BestEffort` until +that evidence exists. Cloudflare's separate `BestEffort` header-fidelity cell reflects workerd's loss of raw octets and +original non-`set-cookie` field boundaries; its response-out runtime can also recompute or +ignore `Content-Length` for a streamed passthrough. EdgeZero uses `EncodeBody::Manual` to +preserve already encoded bytes and visible `Content-Encoding`, but it does not claim an +exact downstream-wire length where Workers owns framing. + +⁹ **Fastly `outbound-http` requires service enablement.** The adapter implements arbitrary +dynamic destinations, but Fastly disables dynamic backends by default and the local CLI has +no authoritative service-entitlement query. A static capability result therefore cannot +promise that the selected deployment can issue any request. The current cell is +`BestEffort`: optional use is supported with an explicit warning, while a required +declaration fails closed. Trusted Phase 6 live probes characterize enabled and disabled +services; promotion to `Native` requires a separately reviewed deployment preflight that +proves enablement for the selected service. + +#### 3.5.3 Build / startup enforcement + +`ensure_capabilities` is an **outbound runtime gate**, not the owner of every CLI lifecycle. +It runs before shell or registry dispatch for `build` / `serve` / `deploy` / +`deploy --staging`, and before the Axum `demo` runtime starts. The outbound-scoped adapter +dispatch entry points, `execute_runtime(..)` and `execute_capture_runtime(..)`, accept only an owned +`ResolvedRuntime` plus adapter arguments. They derive the canonical adapter identity and +action from that runtime and call the same action-aware gate **before** dispatching to its +already resolved shell or registry target. This is required because +`execute_capture_runtime(..)` owns a separate `run_shell_tee(..)` branch and can otherwise bypass +a gate placed only in `execute_runtime(..)`. `auth *`, `EmitVersion`, `Healthcheck`, and `Rollback` +are operational actions rather than construction/deployment of the current manifest's +runtime and remain exempt; the latter three may be invoked without any manifest contract. +`provision` and every `config` subcommand are outside this outbound specification. Their +owning store/capability specifications decide whether they participate in any broader +cross-capability gate. + +```rust +// crates/edgezero-cli/src/adapter.rs +// Keep the action classification exhaustive over today's Action variants. A newly added +// action must be deliberately classified here; it must not inherit an accidental default. +fn produces_current_runtime(action: Action) -> bool { + match action { + Action::Build | Action::Deploy | Action::DeployStaging | Action::Serve => true, + Action::AuthLogin + | Action::AuthLogout + | Action::AuthStatus + | Action::EmitVersion + | Action::Healthcheck + | Action::Rollback => false, + } +} + +fn ensure_action_capabilities(runtime: &ResolvedRuntime) -> Result<(), String> { + if !produces_current_runtime(runtime.action()) { + return Err("operational action entered outbound runtime dispatcher".to_string()); + } + ensure_capabilities( + runtime.adapter_name(), + ManifestContract::from_opt(runtime.manifest()), + ) +} + +// Outbound-scoped dispatch path 1. Gate BEFORE branching to the resolved target. +pub fn execute_runtime( + runtime: ResolvedRuntime, + adapter_args: &[String], +) -> Result<(), String> { + ensure_action_capabilities(&runtime)?; + // Existing shell-command / registry dispatch follows. The registered-adapter branch + // lives in a private helper so execute_capture can reuse it without gating twice. + execute_after_gate(runtime, adapter_args) +} + +// Outbound-scoped dispatch path 2. This owns a distinct shell/tee branch, so it gates too. +pub fn execute_capture_runtime( + runtime: ResolvedRuntime, + adapter_args: &[String], +) -> Result, String> { + ensure_action_capabilities(&runtime)?; + if let ResolvedAdapterTarget::Shell(shell) = runtime.target() { + // Command/root/environment/bind setup is already stored in `shell`; this branch + // does not re-run `manifest_command`, target discovery, or manifest parsing. + return run_shell_tee(shell, adapter_args).map(Some); + } + // Call the private already-gated registry path, NOT public execute_runtime(..): calling the + // public entry point would duplicate optional-capability warnings. Pass the complete + // resolved pair so the registry branch executes `runtime.target` and preserves + // manifest-present/absent diagnostics without rediscovery. + execute_registered_after_gate(runtime, adapter_args)?; + Ok(None) +} + +// crates/edgezero-cli/src/demo_server.rs — no manifest FILE exists; read the +// manifest baked in by `app!` (Hooks::manifest). +#[cfg(feature = "demo-example")] +pub fn run_demo() -> Result<(), String> { + // baked ('static): BakedManifest -> ManifestContract via as_contract + ensure_capabilities("axum", ::manifest().as_contract())?; + /* …Axum runner… */ +} + +``` + +There is deliberately no separate `adapter_name` or `action` argument on either outbound +dispatcher. The resolver records the canonical adapter identity and requested action in +`ResolvedRuntime`; the gate, registry lookup, shell diagnostics, and +`AdapterAction` conversion all consume those same fields. Resolver-produced values are the +only construction path exposed outside `manifest_source`, so a caller cannot gate adapter A +or action X and then dispatch adapter B or action Y. The private post-gate helpers likewise +accept the complete runtime rather than duplicate identity arguments. The pre-existing +`execute(..)` / `execute_capture(..)` operational path remains available to auth, version, +healthcheck, and rollback and never accepts `ResolvedRuntime`. + +`run_demo` is feature-gated (`demo-example`) and always selects Axum implicitly, so its +gate hardcodes the adapter name and reads the **baked** manifest rather than a file. The +two adapter dispatch entry points plus `run_demo` are exhaustive for the outbound runtime +gate; unrelated CLI lifecycles are deliberately absent. The exact private-helper split may +follow the current module, but the invariant is normative: each public dispatch path gates +exactly once before either shell or registry work. + +`ensure_capabilities` itself reads from the **registry** (not from `Adapter::execute`) +because capability metadata is the trait fact `capability(Capability) -> +CapabilitySupport`, and the registry is where adapter implementations are looked up +by name. That means **shell-overridden adapters still get checked**: even if the +manifest configures `[adapters..commands.build]` so dispatch never reaches +`Adapter::execute`, the gate still consults the registered adapter's `capability(..)` +tuple — the shell override only routes the *action*, it does not opt out of the +*manifest contract*. + +**Missing-from-registry policy.** If `registry::get_adapter(adapter_name)` returns +`None`, the policy depends on whether the manifest declares any required or optional +capabilities: + +| Manifest `[capabilities]` shape | Adapter in registry? | Outcome | +| --- | --- | --- | +| absent or empty (`required = []`, `optional = []`) | no | `log::warn!` "capability check skipped (no capabilities declared)" — proceed | +| **any `required`** entry | no | **hard failure**: `Err("adapter '' is not in the registry; cannot verify REQUIRED capabilities. …")` | +| **only `optional`** entries (no `required`) | no | `log::warn!` "cannot verify its OPTIONAL capabilities — proceeding, since optional capabilities never hard-fail" — proceed | +| absent / empty | yes | proceed (loop bodies trivially pass) | +| has entries | yes | check each per the rubric below | + +This preserves the "required capabilities fail early" contract while honouring +"optional never hard-fails" (§3.5.3) — an unverifiable *optional* capability warns and +proceeds, exactly as a known-degraded optional one does; only an unverifiable **required** +capability is fatal. It also keeps the brand-new-shell-only-adapter ergonomics for the +*no-capabilities* case (e.g. a contributor wiring a new edge platform via shell-out, +before they've written the adapter stub). An app that declares a **required** capability +needs a registered adapter that can answer the `capability(Capability) -> +CapabilitySupport` question; there is no silent bypass of a required contract. + +Commands covered by the outbound gate sites above: + +| Command/action | Entry point | Gate site | +| --- | --- | --- | +| `edgezero build` | `run_build` resolves `Action::Build` → `execute_runtime(runtime, ..)` | `execute_runtime(..)` — **gated** | +| `edgezero serve` | `run_serve` resolves `Action::Serve` → `execute_runtime(runtime, ..)` | `execute_runtime(..)` — **gated** | +| `edgezero deploy` | `run_deploy` resolves `Action::Deploy` → `execute_runtime(runtime, ..)` or, for captured Fastly output, `execute_capture_runtime(runtime, ..)` | whichever outbound-scoped dispatcher is selected — **gated before its shell branch** | +| `edgezero deploy --staging` | `run_deploy` resolves `Action::DeployStaging` → `execute_runtime(runtime, ..)` | `execute_runtime(..)` — **gated** | +| `edgezero auth login` / `logout` / `status` | existing `run_auth` → existing `execute(adapter, action, manifest, ..)` | **EXEMPT** (credential + read-only class); no paired resolver migration | +| version emission / healthcheck / rollback | existing operational call sites → existing dispatcher | **EXEMPT** — no paired resolver or manifest requirement | +| `edgezero demo` (feature `demo-example`) | `run_demo` → Axum runner. `run_demo()` takes **no path or loader** and reads no manifest file, so a file-based gate is impossible. **Locked resolution — gate on baked manifest metadata via a new `Hooks` accessor** (see below) | `run_demo()` calls `ensure_capabilities("axum", ::manifest().as_contract())` before the Axum runner starts | + +**The `demo` gate needs a baked-manifest accessor — `app!` must emit one.** +`run_demo()` (`crates/edgezero-cli/src/demo_server.rs`) hardcodes `run_app::()` for the +concrete `app_demo_core::App` (a **struct**, so it cannot be a trait bound), so a test cannot +inject a crafted `Hooks`. **Add a PURE gate helper**, not a generic `run_demo`: + +```rust +// edgezero-cli — no server start, so a test can call it directly and cheaply. +// NOT feature-gated: this helper and its test are compiled UNCONDITIONALLY (they do not +// live behind `#[cfg(feature = "demo-example")]`), so the row runs under the plain +// `cargo test --workspace --all-targets` CI gate. Only `run_demo()` itself — which pulls +// in the `app-demo` example — stays behind `demo-example`; the pure gate does not depend +// on the example, only on the `Hooks` trait, so keeping it always-compiled is free. +pub(crate) fn demo_capability_gate() -> Result<(), String> { + ensure_capabilities("axum", ::manifest().as_contract()) +} +``` + +`run_demo()` (behind `demo-example`) calls `demo_capability_gate::()?` +**before** `run_app`. The failure test calls `demo_capability_gate::()` directly +with an in-crate `TestApp` — no `demo-example` feature, no blocking server on the success +path — so it is exercised by the default test command. **`TestApp` must override `manifest()`, not just `manifest_json()`:** the default +`manifest()` returns `Absent` (a static in the trait default would be shared across impls), so +overriding only `manifest_json()` would leave the gate seeing `Absent` and proceeding. `TestApp` +overrides `manifest()` to return `Manifest::from_baked_json()`. +`run_demo` has no path, no `ManifestLoader`, and no way to find `edgezero.toml` at runtime. But +the `app!` macro **already parses, validates, and serializes the manifest at compile +time** (`crates/edgezero-macros/src/app.rs`: `manifest.finalize()` → +`serde_json::to_string(&manifest)` → `manifest_json_lit`) — it just embeds that JSON for +the router and never exposes it. Today `Hooks` +(`crates/edgezero-core/src/app.rs`) has `routes()` / `stores()` / `name()` and **no +manifest accessor**, so the gate has nothing to consult. Add one: + +```rust +// edgezero-core/src/app.rs — extend the existing Hooks trait. +pub trait Hooks { + // …existing: build_app / configure / name / routes / stores… + + /// Raw manifest JSON baked in at compile time by `app!`. + /// Default `None` for hand-written `Hooks` impls that never ran the macro. + fn manifest_json() -> Option<&'static str> { None } + + /// Parsed + finalized, cached view of the above. + /// + /// **The default MUST be `Absent` — it must NOT hold a cache.** A `static` declared + /// inside a trait default method body is **one item shared by every implementor**, + /// not one per `Self` (items in generic fns are not monomorphized — Rust Reference). + /// A caching default would therefore let the FIRST app to call `manifest` + /// populate the value every OTHER app reads — capability checks against the wrong + /// manifest. Proven: two impls relying on such a default both returned the first + /// impl's value. The cache lives in each **macro-generated impl** instead (below), + /// where the `static` is a distinct item per impl. + fn manifest() -> BakedManifest { BakedManifest::Absent } +} + +// edgezero-core/src/manifest.rs +// +// THREE states, not `Option`. `Option<&Manifest>` conflates "this app has no baked +// manifest" (legitimate: a hand-written `Hooks`, no macro → no capability contract → +// proceed) with "the baked manifest is CORRUPT" (an adapter/macro contract bug). If +// both collapse to `None` and `ensure_capabilities` treats `None` as permission to +// proceed, a malformed contract **silently disables required-capability enforcement** +// — it fails OPEN. This enum makes that unrepresentable. +// `#[non_exhaustive]`: a future state (e.g. a lazily-parsed variant) must not silently +// pass a match written today. Matches in OTHER crates (the CLI gate) therefore need a +// `_` arm, and that arm MUST fail closed (treat an unknown state as "cannot verify → +// refuse"), never proceed — see `ensure_capabilities`. +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub enum BakedManifest { + /// No `app!`-baked manifest (hand-written `Hooks`). No capability contract. + Absent, + /// `manifest_json` returned Some, but it did not parse/finalize. An + /// adapter/macro contract bug: the JSON came from `serde_json::to_string` on an + /// already-validated `Manifest`, so this is unreachable unless the macro is broken. + Malformed(&'static str), // static reason, for the diagnostic + /// Successfully parsed + finalized. + Present(&'static Manifest), +} + +impl Manifest { + /// Parse baked JSON and rebuild derived state (`finalize`). Returns + /// `BakedManifest::Malformed` — never `Absent` — on failure, so a corrupt + /// contract can never be mistaken for "no contract". + pub fn from_baked_json(json: &'static str) -> BakedManifest { /* … */ } +} + +// What `app!` GENERATES per app — each impl gets its OWN `manifest` fn, hence its +// OWN `static`, hence a genuinely per-app cache. +// +// TWO requirements on the generated code: +// 1. FULLY-QUALIFIED PATHS. This expands in the DOWNSTREAM crate, which may not have +// `Manifest` or `OnceLock` in scope (or may shadow them). Macro output must name +// `::edgezero_core::manifest::Manifest`, `::std::sync::OnceLock`, etc. — never a +// bare `Manifest`/`OnceLock`. (The snippet below is written qualified for exactly +// this reason; do not "tidy" it into short paths.) +// 2. FAIL CLOSED on a malformed baked contract — see `BakedManifest` below. +impl ::edgezero_core::app::Hooks for MyApp { + fn manifest_json() -> Option<&'static str> { Some(r#"{…baked…}"#) } + + fn manifest() -> ::edgezero_core::manifest::BakedManifest { + // per-IMPL: a distinct static item, because this fn is generated per impl. + static CACHE: ::std::sync::OnceLock<::edgezero_core::manifest::BakedManifest> = + ::std::sync::OnceLock::new(); + *CACHE.get_or_init(|| { + match ::manifest_json() { + None => ::edgezero_core::manifest::BakedManifest::Absent, + Some(json) => ::edgezero_core::manifest::Manifest::from_baked_json(json), + } + }) + } + // …routes/stores/etc… +} + +// edgezero-core/src/manifest.rs — the accessor CANNOT parse+finalize itself, because +// `Manifest::finalize` is `pub(crate)` and the `app!`-generated `manifest` lives in +// the DOWNSTREAM crate, which can't reach it. Core owns the parse+finalize: +impl Manifest { + /// Parse baked JSON and rebuild derived state (`finalize`). + /// Returns `Malformed` — NEVER `Absent` — on failure, so a corrupt contract can + /// never be mistaken for "no contract" (fail closed, see `ensure_capabilities`). + pub fn from_baked_json(json: &'static str) -> BakedManifest { + // SAME pipeline as `try_load_from_str`: parse -> VALIDATE -> finalize. + // Skipping validate() would fail OPEN: `{}` is valid JSON and parses to a + // default Manifest with EMPTY capabilities, so an invalid-but-parseable + // contract would become `Present` and the gate would proceed against no + // required capabilities. finalize() only rebuilds derived logging state — it + // is NOT validation. A parse OR validation failure is a contract bug -> Malformed. + let value: serde_json::Value = match serde_json::from_str(json) { + Ok(value) => value, + Err(_) => return BakedManifest::Malformed("baked manifest did not parse"), + }; + if reject_reserved_capability_keys_json(&value).is_err() { + return BakedManifest::Malformed("baked manifest has misplaced capabilities"); + } + let mut manifest: Manifest = match serde_json::from_value(value) { + Ok(manifest) => manifest, + Err(_) => return BakedManifest::Malformed("baked manifest did not parse"), + }; + if manifest.validate().is_err() { + return BakedManifest::Malformed("baked manifest failed validation"); + } + manifest.finalize(); // pub(crate) — reachable here, inside core + // Leaked into a 'static: parsed once per process behind the generated + // per-impl OnceLock, and the result must outlive the call. + BakedManifest::Present(Box::leak(Box::new(manifest))) + } +} +``` + +> **`from_baked_json` is `#[doc(hidden)]` macro-support API — call it AT MOST ONCE per +> process.** Each successful call `Box::leak`s a `Manifest` into a `'static`; the generated +> `app!` code invokes it exactly once behind a per-impl `OnceLock`, so the leak is bounded +> to one allocation per app. It is `pub` only because the macro expands in the app's crate, +> **not** an invitation for arbitrary callers — a hand-written caller that invokes it per +> request would leak unboundedly. The doc-comment states the once-per-process contract and +> the type is `#[doc(hidden)]`; direct use is out of contract. + +- **`app!` MUST emit BOTH methods explicitly.** The macro's generated `impl Hooks` + cannot rely on the trait defaults: `crates/edgezero-macros/src/app.rs` sets + **`clippy::missing_trait_methods = deny`** (verified — a generated impl that omits a + defaulted method is a hard clippy error: *"missing trait method provided by default"*). + So the macro emits `manifest_json()` (returning `Some(#manifest_json_lit)`) **and** + `manifest()` (the `OnceLock` + `from_baked_json` body above), exactly as it already + emits `configure`/`build_app` for the same reason. **Any hand-written `Hooks` impl in + the codebase must also emit both** (or locally `#[allow]` the lint) — "additive and + defaulted" is **not** free here. +- **Reparsing JSON alone is WRONG — it skips `finalize()`.** `Manifest::finalize()` + rebuilds derived state (e.g. resolved routes) that is **not** in the serialized JSON, + and several fields are `#[serde(skip)]`. A bare `serde_json::from_str` yields a + half-built `Manifest`. Hence `from_baked_json` calls `finalize()` — and hence it must + live in **core** (only core can call the `pub(crate)` `finalize`), not in the generated + downstream impl. +- **Ownership / lifetime — the cache MUST live in the generated impl, never in the trait + default.** The JSON is a `&'static str` in the binary. The `OnceLock` is a + function-local `static` inside **each generated `manifest()`**; because every impl + emits its own `manifest()` fn, each gets a **distinct** `static` — a genuine per-app + cache, parse paid once per process. **A `static` in the trait DEFAULT body is shared + by all implementors** (items inside generic fns are not monomorphized), so a caching + default would serve app A's manifest to app B. This was **empirically proven** with two + impls relying on such a default: both returned the first one's value. Hence the default + is `BakedManifest::Absent`, and a single core-global `OnceLock` is likewise rejected. *(This is a second, + independent reason the macro must emit `manifest()` explicitly — beyond the denied + `clippy::missing_trait_methods`.)* +- **Failure mode — FAIL CLOSED, and the two failures are distinct.** `manifest()` + returns a three-state `BakedManifest`, never an `Option`, because `Option` conflates + two opposite situations: `Absent` (hand-written `Hooks`, no macro → genuinely *no + capability contract* → `ensure_capabilities` short-circuits `Ok(())`, the same "no + manifest" policy as §3.5.3) versus `Malformed` (the JSON was baked but doesn't + parse → an `app!`/core contract bug). If both were `None` and `None` meant "proceed", + **a corrupt baked contract would silently disable required-capability enforcement** — + the gate would report success precisely when it can no longer verify anything. + `Malformed` therefore **hard-fails** with an actionable message. A gate that fails + open on unreadable input is worse than no gate. +- **Test seam:** capability-gate failure tests drive `demo` with a **test-only `Hooks` + impl** overriding `manifest_json()` to return crafted JSON (which then flows through + the real `from_baked_json` + `finalize`) — no file, no macro re-expansion. This is why + `manifest_json()` is a trait method with a default, not a free function or bare `const`. + (The test impl must also emit `manifest()` — or `#[allow(clippy::missing_trait_methods)]` + — per the lint above.) + +Commands **not** covered (and why): +- `edgezero new` — generates source files; no adapter is selected, so capabilities + cannot be checked. The scaffold itself is identical across adapters. +- `edgezero auth *` — **exempt by command class** (credential; §3.5.3 table), so the + gate never runs for it regardless of manifest presence. (Independently, a genuinely + absent manifest is `BakedManifest::Absent` ⇒ no capability contract ⇒ `Ok(())`; a + *malformed* one hard-fails. Documented in the rustdoc.) + +**Support-level enforcement ladder (what `required` means).** `capability()` returns one of `Native` > `BoundedCooperative` > `BestEffort` > `Unsupported`. A capability in `required` is satisfied by **`Native` or `BoundedCooperative`** (both are *real* enforcement — `BoundedCooperative` has a precisely documented, deterministic bound); it **hard-fails** on `BestEffort` (real-world deviation the app must opt into) or `Unsupported`. `optional` never hard-fails — a `BestEffort`/`Unsupported` optional capability is logged, not gated. **Apps that require the documented outbound-deadline guarantee declare `outbound-deadlines` `required`.** No adapter reports `BoundedCooperative` for that capability, so the declaration is currently accepted only on Axum; it hard-fails on Cloudflare, Fastly, and Spin. A separate `outbound-deadlines-exact` capability is unnecessary. + +**Historical (pre-#269) shape — now superseded (PR #269 has merged to main):** +Before #269 landed, `Command::{Build, Serve, Deploy, Dev}` all dispatched through +the registry's `Adapter::execute(AdapterAction::{Build, Serve, Deploy}, ..)` plus +`Command::Dev`'s implicit-Axum runner, and the gate went at the top of each of +those four handlers (or the equivalent helper they called). #269 collapsed the +runtime-producing actions into the single `execute(..)` dispatcher; `demo` remains +the second outbound runtime gate. + +```rust +// NOTE: takes an already-parsed manifest, NOT a `ManifestLoader`. The `demo` gate has +// no manifest *file* to load — it reads the manifest baked in by `app!` via +// `::manifest` (see the demo row above). File-backed callers pass +// `Present`/`Absent` from their loader; `demo` passes the baked `BakedManifest`. +// +// FAIL CLOSED. `Absent` (no contract) proceeds; `Malformed` MUST NOT — an earlier +// draft used `Option` and treated `None` as permission to proceed, so a corrupt baked +// contract silently disabled required-capability enforcement. A capability gate that +// fails open on malformed input is worse than no gate: it reports success. +// INPUT TYPE: a LIFETIME-BEARING contract, NOT `BakedManifest`. File-backed runtime +// sites hold a **local** `&Manifest` borrowed from a loader — those are not +// `'static`, so they cannot be wrapped in `BakedManifest::Present(&'static Manifest)`. +// Only `run_demo` has a `'static` (baked) manifest. So the gate accepts a borrow of any +// lifetime, and `BakedManifest` (which is `'static`) converts INTO it: +// +// #[non_exhaustive] // future states must fail closed in the cross-crate CLI match +// pub enum ManifestContract<'a> { +// Malformed(&'static str), // corrupt baked contract → fail closed +// None, // no contract → proceed (legit: no manifest) +// Present(&'a Manifest), // any lifetime — file-backed OR baked +// } +// impl BakedManifest { +// pub fn as_contract(&self) -> ManifestContract<'_> { /* Absent→None, Malformed→Malformed, Present→Present */ } +// } +// impl<'a> ManifestContract<'a> { +// pub fn from_opt(manifest: Option<&'a Manifest>) -> Self { +// manifest.map_or(Self::None, Self::Present) +// } +// } +// `ManifestContract` + `as_contract` are public because the generated demo app lives +// outside edgezero-core. File-backed callers build `Present(local_ref)` / `None`; +// `run_demo` calls `::manifest.as_contract`. +// `ensure_capabilities` is `pub(crate)` because `run_demo` imports it from another +// edgezero-cli module. +pub(crate) fn ensure_capabilities( + adapter_name: &str, + manifest: ManifestContract<'_>, +) -> Result<(), String> { + let manifest = match manifest { + // No manifest ⇒ no capability contract to enforce. Legitimate. + ManifestContract::None => return Ok(()), + // Corrupt baked contract ⇒ we cannot know what was required. Refuse. + ManifestContract::Malformed(reason) => { + return Err(format!( + "capability check aborted: {reason}. This is an EdgeZero/app! contract \ + bug — the baked manifest is unreadable, so required capabilities \ + cannot be verified. Refusing to proceed rather than silently skipping \ + enforcement." + )); + } + ManifestContract::Present(manifest) => manifest, + // `ManifestContract` is `#[non_exhaustive]`; a future state we don't recognize is + // treated like `Malformed` — we cannot verify the contract, so we FAIL CLOSED. + _ => { + return Err( + "capability check aborted: unrecognized manifest-contract state. \ + Refusing to proceed rather than skipping enforcement." + .to_string(), + ); + } + }; + let caps = &manifest.capabilities; + let Some(adapter) = registry::get_adapter(adapter_name) else { + // Missing-from-registry policy (see table). If the manifest + // declares no capabilities, we can't verify anything anyway — log + // and proceed so brand-new shell-only adapters work before a stub + // is wired. Optional declarations still warn and proceed because optional never + // hard-fails; only a REQUIRED declaration is unverifiable and must fail closed. + if caps.required.is_empty() { + // No REQUIRED capabilities to verify. Optional ones can't be verified either, + // but "optional never hard-fails" (§3.5.3) — so warn and proceed rather than + // erroring. A hard fail here would break an optional-only manifest. + if caps.optional.is_empty() { + log::warn!( + "adapter '{adapter_name}' not in registry; capability check skipped (no capabilities declared)", + ); + } else { + log::warn!( + "adapter '{adapter_name}' not in registry; cannot verify its OPTIONAL \ + capabilities — proceeding, since optional capabilities never hard-fail", + ); + } + return Ok(()); + } + // At least one REQUIRED capability and no metadata to check it against → fail closed. + return Err(format!( + "adapter '{adapter_name}' is not in the registry; cannot verify REQUIRED \ + capabilities. Register an adapter stub that returns capability metadata, or \ + move those entries to `optional`.", + )); + }; + + // POSITIVELY accept only `Native | BoundedCooperative` for a REQUIRED capability. + // `CapabilitySupport` is `#[non_exhaustive]`; matching the two acceptable values and + // routing EVERYTHING else — `BestEffort`, `Unsupported`, and any FUTURE variant via + // `_` — to a rejection fails CLOSED. A `.filter(== Unsupported)` / `.filter(== + // BestEffort)` pair would let an unknown future support level pass a required gate. + let mut unsupported: Vec<&str> = Vec::new(); + let mut best_effort: Vec<&str> = Vec::new(); + for cap in caps.required.iter().copied() { + match adapter.capability(cap) { + CapabilitySupport::Native => {} + CapabilitySupport::BoundedCooperative => log::info!( + "adapter '{adapter_name}': required capability '{}' is bounded-cooperative; \ + see capability docs for the bound", + cap.as_str(), + ), + CapabilitySupport::BestEffort => best_effort.push(cap.as_str()), + // `Unsupported` AND any unknown/future variant → treated as unsupported. + _ => unsupported.push(cap.as_str()), + } + } + if !unsupported.is_empty() { + return Err(format!( + "adapter '{adapter_name}' does not support required capabilities: {}", + unsupported.join(", "), + )); + } + if !best_effort.is_empty() { + return Err(format!( + "adapter '{adapter_name}': required capabilities are only best-effort: {}. \ + best-effort means a documented limitation applies — timing (e.g. \ + unbounded cooperative enforcement) or functional (e.g. lazy streaming \ + becomes buffered). See the capability reference at \ + https://edgezero.dev/guide/capabilities. Declare them \ + `optional` if the documented limitation is acceptable.", + best_effort.join(", "), + )); + } + // Optional: warn on BestEffort, Unsupported, AND any unknown/future variant (the + // ladder logs all degradations, per §3.5.3); only `Native | BoundedCooperative` is + // "available" and silent. An unknown future support level is a degradation we can't + // characterize, so it warns rather than passing silently. + for cap in caps.optional.iter().copied() { + match adapter.capability(cap) { + CapabilitySupport::Native | CapabilitySupport::BoundedCooperative => {} + CapabilitySupport::Unsupported => log::warn!( + "adapter '{adapter_name}': optional capability '{}' unavailable", + cap.as_str(), + ), + CapabilitySupport::BestEffort => log::warn!( + "adapter '{adapter_name}': optional capability '{}' is best-effort — a \ + documented deviation applies; see the capability reference at \ + https://edgezero.dev/guide/capabilities", + cap.as_str(), + ), + _ => log::warn!( + "adapter '{adapter_name}': optional capability '{}' reports an unrecognized \ + support level; treating as degraded", + cap.as_str(), + ), + } + } + Ok(()) +} +``` + +**Resolve the execution target and its capability contract as one operation.** +`ManifestContract::None → proceed` is safe only when it means no contract belongs to the +target that will actually execute. Resolving `./edgezero.toml` while Spin independently +searches ancestors and workspace descendants for `spin.toml` lets the gate inspect one app +or no app and then build another. The shared resolver therefore returns both values: + +``` +// crates/edgezero-adapter/src/registry.rs — the exact cross-crate handoff. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct AdapterExecutionTarget { + app_root: PathBuf, + component: Option, + platform_manifest: Option, +} + +impl AdapterExecutionTarget { + pub fn app_root(&self) -> &Path; + pub fn component(&self) -> Option<&str>; + pub fn new( + app_root: PathBuf, + component: Option, + platform_manifest: Option, + ) -> Self; + pub fn platform_manifest(&self) -> Option<&Path>; +} + +// The existing `execute` remains the direct-discovery API. The CLI's resolved-runtime +// path calls this method, which may not rediscover from cwd or encode the target in args. +pub trait Adapter { + fn execute_target( + &self, + action: AdapterAction, + target: &AdapterExecutionTarget, + args: &[String], + ) -> Result<(), String>; +} + +// crates/edgezero-cli/src/manifest_source.rs +pub enum ResolvedAdapterTarget { + Registered(AdapterExecutionTarget), + Shell(ResolvedShellTarget), +} + +pub struct ResolvedManifest { + loader: ManifestLoader, + path: PathBuf, +} + +pub struct ResolvedRuntime { + action: Action, + adapter: String, + contract: Option, + target: ResolvedAdapterTarget, +} + +pub struct ResolvedShellTarget { + bind_host: Option, + bind_port: Option, + command: String, + environment: ResolvedEnvironment, + root: PathBuf, +} + +``` + +These declarations and their fields are alphabetized for the repository's denied +`arbitrary_source_item_ordering` lint. `AdapterExecutionTarget` lives in +`edgezero_adapter::registry`; its fields are private and its public constructor/accessors are +the only cross-crate handoff. The remaining fields are private to `manifest_source`. +`ResolvedRuntime` exposes only the alphabetically ordered crate-private read-only getters +`action()`, `adapter_name()`, `manifest()`, and `target()`; the contained +target/manifest types expose read-only getters for their listed fields. No `ResolvedRuntime` +constructor or mutator is exposed outside the resolver. + +`ResolvedRuntime` exists only for the outbound-gated runtime-producing actions `Build`, +`Serve`, `Deploy`, and `DeployStaging`. Its `ResolvedAdapterTarget` is action-specific and +complete: the shell variant owns every input the current shell dispatcher derives from a +manifest; the registered variant pins its app root and optional platform +manifest/component. `AdapterExecutionTarget::app_root` and `ResolvedShellTarget::root` are +absolute canonical directories. `ResolvedManifest::path` and any +`AdapterExecutionTarget::platform_manifest` are absolute canonical regular files contained +by the corresponding root; a platform manifest must also belong to the same selected app +target as the capability contract. Pre-dispatch code asks the target value whether the selected action is +shell- or registry-backed; it does not call `has_manifest_command` or independently resolve +an adapter manifest path after the pair is formed. + +Capture the invocation directory once. An explicit `EDGEZERO_MANIFEST` is authoritative; +resolve a relative value against that captured directory, load exactly that file, and +resolve the adapter target declared relative to its manifest root. Missing, malformed, or +conflicting explicit input fails without fallback. For default discovery, apply the +adapter's existing ancestor/workspace-descendant selection domain to **paired app +candidates**: each candidate `edgezero.toml` is loaded once and its declared target is +resolved with the existing canonical containment/regular-file checks. Select the same +candidate for contract and execution; reject ambiguity or a target/contract mismatch. +`contract: None` is reserved for a selected hand-written target for which discovery proved +there is no associated `edgezero.toml`, not merely no ancestor manifest from the invocation +directory. + +`run_build`, `run_serve`, and `run_deploy` pass one owned `ResolvedRuntime` into new +`execute_runtime(..)` or `execute_capture_runtime(..)` entry points. The scoped dispatcher +derives the adapter and action from that runtime, gates its `contract` exactly once, then executes its pinned `target`; +neither shell nor registry branches rediscover a path or reparse a manifest. A registry +adapter receives `AdapterExecutionTarget` through `Adapter::execute_target`; every in-tree +adapter implements that method by calling target-aware internal build/serve/deploy helpers. +The default trait implementation fails with an explicit "pinned target unsupported" error, +so an out-of-tree adapter cannot silently fall back to `execute` and rediscover cwd. The +existing `Adapter::execute` remains the direct adapter API and preserves its current +discovery behavior for callers outside the paired CLI path. The resolved-runtime dispatcher +must not inject paths/components into stringly passthrough arguments. +Shell overrides retain their declared arguments and run with `Command::current_dir` rooted +at the selected manifest; the CLI never mutates process-wide cwd or environment. A shell +can retarget itself, so the checked target is the declared contract, not a claim that an +arbitrary script's behavior was statically verified. Spin target/component validation must +remain available even when no Spin registry adapter is linked. + +The existing `execute(..)` / `execute_capture(..)` signatures and resolution behavior remain +unchanged for `AuthLogin`, `AuthLogout`, `AuthStatus`, `EmitVersion`, `Healthcheck`, and +`Rollback`. They do not construct `ResolvedRuntime`, enter the outbound capability gate, or +acquire a new manifest requirement. After a captured Fastly Deploy produces no version, +`run_deploy` invokes the existing registered `EmitVersion` path exactly as it does today; +that second operational action is explicitly outside the paired outbound resolver. Provision +and config commands are likewise unchanged. Any attempt to migrate all actions to one owned +runtime belongs in a separate CLI lifecycle specification. + +- **Required + `Unsupported` → hard failure** with an explicit message. +- **Required + `BestEffort` → hard failure.** `BestEffort` means a **documented + deviation from `Native`** — that can be timing (e.g. Fastly's unbounded source-stall + in `streamed-upload-deadlines`) or observational (e.g. Axum cannot prove that a + Hyper-accepted response frame reached the client). Either way the deviation is + real, the matrix footnotes describe it, and "required" should mean the deviation + is unacceptable. If degradation is acceptable, declare the capability `optional` + instead — the principle is "required means the matrix footnote's deviation is not + acceptable for this deployment." +- Required + `BoundedCooperative` → informational log (works, with a documented bound). +- Optional + **`BestEffort` OR `Unsupported`** → `log::warn!` naming the capability and + the degradation (the ladder logs *both*, not just Unsupported); never a hard fail. + +#### 3.5.4 Outbound host plumbing — not policy + +`[capabilities.outbound].hosts` is **plumbing**, not an application security allowlist +(non-goal §1.3). Applications still enforce target policy in handler code. + +- **Spin** requires `allowed_outbound_hosts` in `spin.toml`. The platform manifest + consumes the canonical list below; absent hosts preserve today's + `["https://*:*"]` default. The outbound runtime path for `build` / `serve` / + `deploy` resolves the component with existing CLI semantics: an explicit selector must + exist, one component is implicit, and zero or multiple components without a selector + fail. It compares only that selected component's canonicalized + `allowed_outbound_hosts` set before shell or registry dispatch; a union across components + could let one component's permission mask drift in the component being built. On drift, + fail with the `edgezero.toml` path, selected component, and expected canonical list. + Synchronization is manual: update that component field and rerun. Generation initializes + new projects; this spec does not promise a regeneration command for an existing + user-owned `spin.toml`. + + Every entry is canonicalized by the host-authority subset of `OutboundRequest`'s + URI rules (§3.1.3): lowercase scheme and host; strip `:443` for `https` and + `:80` for `http`; reject userinfo and fragments. Manifest host entries are + declarations, not request targets, so paths and queries are also rejected. Drift + comparison is set-based and order-insensitive. + + The absent default deliberately remains `["https://*:*"]`; it is not widened to + cleartext HTTP. Apps that need cleartext outbound access declare it explicitly. + + | Input form (after canonicalization) | Example | Spin output | + | --- | --- | --- | + | wildcard | `"*"` | `["http://*:*", "https://*:*"]` | + | scheme-prefixed | `"http://localhost:3000"`, `"https://api.example.com:8443"` | rendered as-is | + | `host:port` (no scheme) | `"api.example.com:8443"` | `"https://api.example.com:8443"` | + | bare host | `"api.example.com"` | `"https://api.example.com"` | + | wildcard subdomain | `"*.example.com"` | `"https://*.example.com"` | + + The §3.5.1 validator is authoritative; there is no fallback for other forms. + Mixing `"*"` with specific hosts is allowed. Bare hosts mean HTTPS on the default + port only. Canonicalization occurs once before both rendering and drift comparison. +- **Fastly** uses runtime dynamic backends, so it does not need the list at build time; + `hosts` is informational there. +- **Axum / Cloudflare** ignore the list because they require no host pre-declaration. + +## 4. Adapter-by-adapter implementation notes + +Each adapter renames `src/proxy.rs` → `src/outbound.rs`, replaces its `ProxyClient` +impl with an `OutboundHttpClient` impl, adds `capability()`, and gains a +`tests/contract.rs`. + +### 4.1 Axum — `crates/edgezero-adapter-axum` + +- `AxumProxyClient` → `AxumOutboundClient`; keeps the pooled `reqwest::Client`. +- Build that client with `no_gzip()`, `no_brotli()`, `no_deflate()`, and `no_zstd()` in + addition to the manifest's disabled default features. This makes the raw-byte boundary + explicit even if Reqwest feature selection changes later; only the shared EdgeZero + classifier/decoder may transform a response body. +- `start_batch_until` first snapshots `let batch_now = self.clock.now()` once, then runs a + **preflight** per slot: call `validate_for_dispatch(&request)` first; only a request that + passes that portable validator reaches the batch-only checks. Then any request whose `body` is + `Body::Stream` OR whose `response_mode` is `Streamed` is converted in place to + `Err(EdgeError::bad_request(..))` (§3.1.1) so the trait contract holds identically + on every adapter. Buffered survivors become complete-exchange futures in a + `FuturesUnordered`; the adapter polls every eligible future before yielding the first item and + emits each terminal completion with its original index. It passes that entry snapshot and the + observation cutoff to every per-slot `dispatch_budget(req, batch_now, Some(observation_cutoff))` — see §3.3.2 / + §4.3 for why a per-slot + second clock sample would drift the shared-deadline `duration` and (on Fastly) the + backend identity. +- A public single `send` takes its monotonic snapshot first, calls + `validate_for_dispatch(&req)` exactly once immediately afterward, then invokes the same + private already-validated flow used for batch survivors. +- The private prepared-request flow runs in this order: + 1. **Compute the budget.** `let budget = dispatch_budget(req, now, None)?` (§3.3.2 — + never an adapter-local formula, so `DEFAULT_NO_DEADLINE_BUDGET = 30 s` is + applied uniformly when no deadline is set). On expiry-before-dispatch this + returns `Err(gateway_timeout)` for the slot immediately. For a single `send`, + `now = self.clock.now()` is taken inline. + 2. **If the request body is `Body::Stream`, drain it to `Bytes` first.** Core + `Body::Stream` is `LocalBoxStream` (not the `Send + 'static` stream + `reqwest::Body::wrap_stream` requires), so Axum drains a streamed request body + into `Bytes` up to `req.max_request_body_bytes` (default 8 MiB) **before** + constructing the reqwest request. Pre-append checked accounting per §3.4.1; + over-cap → `bad_request`. Every pull uses this absolute-deadline protocol: + (a) call `budget.deadline.remaining()` immediately before the pull and return + attributed `gateway_timeout` if it is `None`; (b) race `source.next()` against + `tokio::time::sleep(remaining)`; (c) when the source arm becomes ready, call + `budget.deadline.is_expired()` **before** inspecting or accepting its chunk, + EOF, or error, and return the same timeout if expired. The post-ready check + makes the deadline win simultaneous source/timer readiness and prevents an + always-ready stream, including one yielding empty chunks, from starving the + timer past the absolute boundary. Only a source result proven ready before the + deadline proceeds to error propagation and pre-append cap accounting. Adding + reqwest's `stream` feature is **not** required. + 3. **Construct the reqwest request.** Build the `reqwest::Request` / + `RequestBuilder` from the buffered (or now-buffered) body, URI, method, + and normalized headers. Do not arm the timeout yet — it gets re-read + at the very last moment in step 4. + 4. **Arm the reqwest timeout and send.** Immediately before + `.send().await`, re-read `budget.deadline.remaining()`. If `None` (drain + + construction consumed the budget) → `gateway_timeout` without + sending. Otherwise `.timeout(remaining)` is set from this + just-re-read value, **not** from the cached value at end-of-drain and + **not** from the original `budget.duration`. Re-reading at arming time + (matching Spin's "at the moment the race starts" — round 21) closes + the construction-time gap that would otherwise let a 100 ms build + phase silently extend the SDK timeout past the absolute deadline. + reqwest's timeout covers the response-body read, so a `Buffered` + drain inherits the deadline. `Buffered` mode drains the response + body with a running decompressed-byte counter against `max_bytes` + (pre-append check per §3.4.1). `Streamed` mode wraps `reqwest`'s + byte stream with a `tokio::time::timeout`-per-chunk wrapper bounded + by `budget.deadline`; the wrapper yields a `gateway_timeout` (attributed via `budget.cause`, §3.3.2) error + chunk past the deadline so the streamed body honours the deadline + end-to-end per §3.3.3. +- **Downstream response delivery is connection-owned and lazy.** `run_app` serves HTTP/1 with a + private Hyper connection on a Tokio `LocalSet`. Its non-`Send` response body owns the sole + `ResponseEgressAttempt`, polls one portable chunk only when Hyper asks for a frame, and accounts + the frame before returning it. An independent connection timer closes the HTTP/1 connection at + the absolute write deadline even when Hyper is not polling the body. The deadline-owning + response reports `DeadlineExceeded`; other unsettled pipelined responses report + `TransportError`. There is no blocking bridge or whole-response collection path. +- Errors: `reqwest` timeout → **`gateway_timeout_caused(msg, budget.cause)`** (carries the + attribution — §3.3.2, NOT bare `gateway_timeout`); connect/DNS/TLS before a response + head → `bad_gateway_with_reason(.., Unreachable)`; transport failure after connection + progress → `Transport`; invalid upstream framing/completion → `Protocol`; shared JSON, + gzip, and Brotli failures → `Decode(Json)`, `Decode(Gzip)`, and `Decode(Brotli)`; + response over-cap → the exact + typed `response_too_large` reason (§3.4.5). Any completed exchange (incl. non-2xx) → + `Ok`. +- `capability()` per §3.5.2: `outbound-http` = `Native`, + `outbound-complete-resource-accounting` = `Unsupported`, + `outbound-header-fidelity` = `Native`, `outbound-deadlines` = `Native`, + `outbound-flexible-phase-budget` = `Native` (Axum's reqwest exposes a single total + timeout, not a phase split), `outbound-batch-slot-isolation` = `Native`, + `streamed-upload-deadlines` = `Native`, `lazy-streamed-response-passthrough` = + `Native` (footnote 3 — direct non-`Send` body and raw-socket tests prove no collection). +- Reference adapter for the contract (§5): real loopback HTTP. + +### 4.2 Cloudflare — `crates/edgezero-adapter-cloudflare` + +- `CloudflareProxyClient` → `CloudflareOutboundClient` (stays stateless). +- `start_batch_until` first snapshots `let batch_now = self.clock.now()` once, then runs a + **preflight** per slot: call `validate_for_dispatch(&request)` first; only a request that + passes that portable validator reaches the batch-only checks. Then any request with `Body::Stream` + OR `response_mode = Streamed` is converted to `Err(EdgeError::bad_request(..))` + per §3.1.1 before provider construction. It passes that entry snapshot and the observation cutoff to + every `dispatch_budget` call. Buffered survivors become complete Fetch-exchange futures in a + `FuturesUnordered`; the adapter polls every eligible future before yielding and associates each + terminal completion with its original index. The Workers event loop provides the concurrency. +- A public single `send` takes its monotonic snapshot first, calls + `validate_for_dispatch(&req)` exactly once immediately afterward, then invokes the same + private already-validated flow used for batch survivors. +- The private already-validated flow runs in this order: + 1. **Compute the budget.** `let budget = dispatch_budget(req, now, None)?` (§3.3.2). + Expiry before dispatch returns `Err(gateway_timeout)` for the slot. + 2. **If the request body is `Body::Stream`, drain it to `Bytes` first.** Up to + `req.max_request_body_bytes` (default 8 MiB), pre-append checked accounting; + over-cap → `bad_request`. Every pull uses the same absolute-deadline protocol + as Axum: read `remaining()` immediately before the pull and fail with an + attributed `gateway_timeout` when absent; race `source.next()` against + `worker::Delay::from(remaining)`; then, if the source arm becomes ready, + recheck `is_expired()` **before** accepting its chunk, EOF, or error. Timeout + wins simultaneous readiness. Also apply the host-event yield quota below: + post-ready clock checks alone do not prevent starvation on Cloudflare. + Source errors and cap accounting run only after the deadline checks. + 3. **Construct the `worker::Request`.** Build the request from the + buffered (or now-buffered) body, URI, method, and normalized headers. + Set `worker::RequestInit::redirect` to `RequestRedirect::Manual` here and assert + `request.inner().redirect()` is manual. Do not add a `Host` header: pass the exact + canonical URI serialization as the string supplied to `worker::Request` and let Fetch + derive Host from that authority. The Web API may apply its own WHATWG + parse/serialization before network dispatch, so no test claims byte-for-byte equality + between that input string and the final wire URL. Native bridge tests assert the + supplied string; workerd/deployed tests assert the effective origin-observed + scheme/authority/path/query and Host against the corresponding WHATWG canonical + semantics, including dot segments, numeric IPv4 aliases, IDNA, percent encodings, + empty paths, and queries. + Do not start the `worker::Delay` race yet. + 4. **Prepare raw, signal-aware fetch options, then arm the race and send.** + Create a `worker::AbortController` owned by the guard below and take its `signal()`. + The adapter-private `fetch_raw_with_signal` helper follows worker 0.8.5's + `global.rs::fetch_with_request` bridge, but supplies **both** the abort signal and + top-level `encodeResponseBody: "manual"` in the **final fetch options**: + + - Create `worker::web_sys::RequestInit` and call `set_signal(Some(&**signal))` + where `signal: &worker::AbortSignal`. + - Set the `encodeResponseBody` property to the JS string `"manual"` with + `worker::js_sys::Reflect::set`. Handle both an exception and `Ok(false)` as + `internal` setup failures with no dispatch; never ignore a failed property set. + - Use the SDK's `WorkerGlobalScope` bridge to call + `fetch_with_request_and_init(request.inner(), &init)`. Await the Promise through + `worker::wasm_bindgen_futures::JsFuture`, cast the result to + `worker::web_sys::Response` with `JsCast`, then convert it to `worker::Response`. + Fetch rejection before a response head is `bad_gateway` reason `Unreachable` only when + platform evidence establishes DNS/connect/TLS establishment failure. The current Workers + Fetch bridge exposes a generic rejected promise without a stable phase discriminator, so + current generic rejections are `Unspecified`; expiry still wins. An unexpected JS response + type is an `internal` binding invariant failure. + + Redirect mode remains on the already-constructed Request; the final raw + `web_sys::RequestInit` owns only the abort signal and response-encoding override and + must not reset redirect behavior. + + The property setup completes **before** issuing fetch. Immediately before the + fetch call and timer arming, re-read `budget.deadline.remaining()`; `None` → + `gateway_timeout_caused(.., budget.cause)` without sending. Otherwise race the + helper's fetch/response conversion **and**, + in `Buffered` mode, the body drain against `worker::Delay::from(remaining)` + (worker 0.8.5 — `Delay` has private fields, so `worker::Delay(remaining)` tuple + construction does NOT compile; use the `From` impl). **On expiry, + abort through the guard.** Dropping the underlying fetch future alone does not + cancel the in-flight subrequest; dropping the guarded send future cancels it through + the guard's `Drop`. Return `gateway_timeout_caused(.., budget.cause)` on expiry. + The existing gzip/deflate/br + decompression path is kept; the independent decoded-output cap is enforced + incrementally for identity and EdgeZero-decoded output (§3.4.1). The final Buffered + collection cap is enforced separately for every coding disposition. +- **Raw-response option placement is mandatory.** Workerd's + [request initializer](https://github.com/cloudflare/workerd/blob/f2300a54038995b2ceaf0851447aa5b5b36cda13/src/workerd/api/http.h) + defaults to automatic response decoding; `encodeResponseBody: "manual"` returns raw + encoded bytes. Worker 0.8.5's high-level `RequestInit` does not expose this option, + and `Fetch::send_with_signal` constructs final options containing only the signal. + Setting the property on an earlier Request is insufficient: the pinned workerd + [Request-copy path](https://github.com/cloudflare/workerd/blob/f2300a54038995b2ceaf0851447aa5b5b36cda13/src/workerd/api/http.c%2B%2B) + can reset its response-encoding mode. The helper must set it at the final fetch call. + It is a top-level request option, not a `cf` property, and is independent of the + downstream `EncodeBody::Manual` setting. Keep ordinary `worker::Request` construction; + no raw Request re-wrapping or post-construction mutation is needed. Worker re-exports + `web_sys`, `js_sys`, `wasm_bindgen`, and `wasm_bindgen_futures` with the necessary + bindings, so this bridge adds no direct dependency. Phase 4 pins a workerd harness + that implements this option and verifies actual raw bytes; successful `Reflect::set` + alone does not prove the host honors it. +- **Host-event fairness is required, not just repeated clock reads.** In deployed + Cloudflare Workers, `performance.now()` (and therefore `MonotonicInstant`) advances + only after I/O, unlike the normal local workerd clock + ([Cloudflare timing semantics](https://developers.cloudflare.com/workers/runtime-apis/performance/)). + `worker::Delay` depends on a `setTimeout` callback. An always-ready empty source can + otherwise keep the clock frozen, starve that callback, and never hit the byte cap. + Apply a fixed, nonzero adapter-private ready-item quota of at most 64 items between + explicit host-event yields, counting empty items. The Phase 4 authoring gate must select + a primitive that a deployed timing probe proves both yields to another host event and + permits the production clock to advance. The adapter uses a cancellable + `globalThis.setTimeout(0)` future because worker 0.8.5's `Delay` binding does not execute + under the pinned Node WASM runner; the future owns its callback and timer handle and clears + the timer when dropped. After the yield, recheck the original absolute + deadline before processing another item. Count state persists across Rust polls, stream + yields, and `next()` calls; reset it only after the proven host-event yield completes. A + self-wake, `Poll::Pending` round trip, microtask-only yield, or fake clock that advances + independently of the host event is insufficient. + + This discipline applies to request-source draining, raw response input **before** the + stream-to-reader bridge, and decoded-output processing, including the completion + protocol in §3.4.1. Raw-input enforcement must interrupt ready-empty loops even when + the decoder emits no output. Before returning/yielding terminal success, EOF, or error, + and before an adapter-owned cap error, perform a host-event yield and recheck expiry; + a terminal result must not use a clock snapshot frozen throughout its CPU work. + The generic streamed consumer's cap decision after a within-budget yield retains + §3.4.1's narrower deadline ownership; this does not add request-budget state to core + helpers. Existing per-read remaining-budget races may remain: neither progress nor + host yielding reanchors the absolute deadline. All yield futures and native handles + remain owned, so cancellation during a yield follows the same cleanup rules below. + This is cooperative scheduling between bounded processing steps, not preemption of + arbitrary synchronous work inside one source poll. +- **One abort guard owns every in-flight subrequest.** Arm an adapter-private guard + before polling fetch; it owns the `AbortController` and calls `abort()` on early + termination. In Buffered mode it remains owned through metadata normalization, + bodyless/205 settlement, decoding, and bounded draining. Cap overflow (including + identity `Content-Length` rejection), malformed metadata, decoder/read failure, + timeout, and an early 205 abort disposition all abort the subrequest before return. + Dropping the send future while it owns a live subrequest also aborts via the guard's + `Drop`. Preserve each restored `EdgeError` unchanged. Native transport and genuine + codec/trailing-data failures are `bad_gateway`, cap overflow is `response_too_large`, + and the already-expired adapter budget wins with + `gateway_timeout_caused(.., budget.cause)`. An early 205 disposition still returns an + empty 205 after aborting, as specified in §3.4.1. Disarm only after normal native-body + settlement and successful decoding; headers or decoded EOF alone do not prove that + the native body has completed. Abort is idempotent, and the guard records settlement + so explicit abort followed by `Drop` does not repeat it. Native transport failures use + `BadGatewayReason::Transport`; protocol/completion failures use `Protocol`; shared + codec/trailing-data failures use `Decode(Gzip)` or `Decode(Brotli)`. In worker 0.8.5, + `AbortController::abort(self)` consumes the controller: store it as an `Option` and + `take()` it when aborting or disarming; `Drop` aborts only a remaining `Some`. + A successful Streamed return moves the still-armed guard into the decoded-output + wrapper without an ownership gap. +- **205 host suppression.** If workerd exposes a null body for 205, apply the §3.4.1 + branch: positive visible `Content-Length` aborts immediately; absent/zero length plus + null body is accepted as host-suppressed clean EOF without calling `stream()`. A non-null + body receives the ordinary one-read disposition. The adapter does not claim visibility + into bytes workerd may have suppressed before the guest boundary. +- **Streamed responses honour the deadline AND cancel the subrequest.** Wrap the + response body as `Body::Stream`, with a per-chunk race against a `worker::Delay` + bounded by `budget.deadline`. **The abort guard moves into the decoded-output wrapper**, + because the subrequest is still live while the body streams. The guard aborts in + **all three** cases, but what it yields differs by cause, so the status is not + conflated: (a) the deadline `worker::Delay` fires → abort **+ a + `gateway_timeout_caused(.., budget.cause)` (504) error chunk**; (b) a read, decoding, + or completion failure → abort **+ the restored `EdgeError` unchanged**; native + transport and genuine codec/trailing-data failures map to typed-reason `bad_gateway` + (502), while expiry of the adapter deadline wins over either class; (c) the **consumer drops the stream early** (`Drop` on the wrapper) → + **abort only, no error chunk** (the consumer that dropped is not waiting for one). + Merely yielding an error chunk without `abort()` (an earlier draft) leaves the CF + subrequest running after EdgeZero has stopped reading — the same bug the buffered path + fixes. So the deadline is honoured end-to-end and a mid-stream transport failure maps to + 502 (not 504). The guard invokes `abort()` on every early-exit case. Origin-observed + cancellation is required only for probes that deliberately keep the origin active after + that trigger; a transport/completion failure after the origin already closed cannot + produce a second observable disconnect and is not used as cancellation evidence. +- Errors: `worker::Delay` expiry → + **`gateway_timeout_caused(msg, budget.cause)`** (attributed — §3.3.2); + `worker::fetch` DNS/TLS/connection-establishment failures before a response head → + `bad_gateway_with_reason(.., Unreachable)` when provider evidence establishes that + category, otherwise `Unspecified`; later transport failures → `Transport`; invalid + visible upstream framing/completion → `Protocol`; shared JSON/gzip/deflate/Brotli failures → + `Decode(Json)` / `Decode(Gzip)` / `Decode(Brotli)`; **request**-body over-cap → + `bad_request` (400); response-resource failures preserve their exact + `ResponseLimitReason`. A provider error with no defensible narrower category uses + `Unspecified`. Any completed exchange (incl. non-2xx) → `Ok`. +- **Method / body preflight — no silent coercion.** The historical adapter mapped + unsupported methods to `GET`; the outbound implementation removed that behavior. Per §3.1.4, + a non-portable method + or a `GET`/`HEAD` carrying a body is rejected in **core preflight** with + `bad_request`, identically on every adapter. The CF adapter never rewrites the + method to satisfy `fetch`'s restrictions. +- **Multi-value response headers — EdgeZero-owned behavior, not a platform limit.** + `worker::Headers` has both `append(&self, ..)` and `get_all(&self, ..)`, and this + repo's pinned `compatibility_date = "2023-05-01"` already enables workerd's + per-`set-cookie` `entries()` behaviour. `set-cookie` is therefore **fully + preservable** on CF. The outbound and response converters append every value rather than + replacing prior values, and the shared header bridge decodes `HeaderValue::as_bytes()` with + `std::str::from_utf8` instead of calling the stricter `HeaderValue::to_str()`. Valid UTF-8 + such as `café` survives; invalid UTF-8 returns the typed request-validation error before + fetch, never an unwind or lossy conversion. Contract tests pin repeated `set-cookie` values + in both directions and the non-ASCII request path. +- **What stays irrecoverable (§3.1.4):** repeated **non**-`set-cookie` headers are + comma-joined by workerd (`x-foo: a, b`) with no API to recover the separate field + lines, and raw upstream bytes / invalid UTF-8 are lost before the guest sees them. + **Never call `get_all` with any name but `"set-cookie"`** — the binding lacks `catch` + and workerd throws, unwinding across the wasm boundary. +- **Encoded passthrough must stay byte-preserving.** When the portable content-encoding + policy chooses passthrough (unknown, parameterized, or stacked/repeated encoding), the + core response retains the raw bytes and visible `content-encoding`. The Cloudflare + response-out converter MUST then select `worker::EncodeBody::Manual` (via + `Response::with_encode_body`) whenever it forwards an already encoded body; leaving the + default automatic mode lets Workers transform bytes that EdgeZero promised to pass + through unchanged. Known gzip/deflate/br responses decoded by EdgeZero have their encoding + header removed and use the normal automatic/identity path. Workers can recompute or + ignore a user `content-length` for a streamed response, so exact downstream-wire + `Content-Length` retention is part of Cloudflare's documented + `outbound-header-fidelity = BestEffort` deviation; the app-visible header decision and + raw encoded payload remain deterministic. +- `capability()` follows the twelve-row matrix in §3.5.2. `outbound-http`, + `outbound-flexible-phase-budget` (single `worker::Delay` for the total race, no per-phase + split), `outbound-batch-slot-isolation`, `outbound-batch-completion-order`, + `outbound-cache-bypass`, and `lazy-streamed-response-passthrough` are `Native`. + `outbound-deadlines` and + `streamed-upload-deadlines` are `BestEffort` because the owned abort path lacks deployed + host-observed cancellation evidence. `outbound-header-fidelity` is independently + `BestEffort` because workerd removes raw-octet/field-line information before the guest, + and `outbound-complete-resource-accounting` is `Unsupported`. + Cloudflare's WASM single-threaded guest carries no + `Send` constraint, so `worker::Body::from_stream` consumes the core `Body::Stream` + directly **in the response-out direction** + (`lazy-streamed-response-passthrough` — see §7 `src/response.rs`). The + **outbound-request upload direction** still drains `Body::Stream` to `Bytes` + first (bounded by `max_request_body_bytes`, raced against `budget.deadline`), + because `send_async`-style streamed uploads aren't part of this migration and + the worker SDK's request-body shape differs from `Body::from_stream`. Don't + conflate the two — `send_one`'s flow above is the request side; this bullet is + the response side. + +### 4.3 Fastly — `crates/edgezero-adapter-fastly` + +The Fastly outbound implementation uses **dispatch-all-then-select**. This replaced the +historical `proxy.rs` flow, which called `send_async_streaming()` and then +`pending_request.wait()` inside one `send()` and therefore serialized a `join_all` batch. + +Confirmed `fastly` 0.13.1 API: + +```rust +// fastly::http::request +pub fn select>(pending_reqs: I) + -> (Result, Vec); // no index returned +pub fn select_handles>(pending_reqs: I) + -> ( + Result<(ResponseHandle, BodyHandle), SendErrorCause>, + usize, + Vec, + ); // reports the selected position +pub enum PollResult { Pending(PendingRequest), Done(Result) } +// PendingRequest::poll(self) -> PollResult (non-blocking) +// PendingRequest::wait(self) -> Result (blocks on one) +// Request::send_async(self, backend) -> Result +``` + +The high-level `select` discards the selected position, so the adapter uses `select_handles`. +Before each call it records every unique handle's original slot index; the returned selected +position identifies the terminal slot, and returned remaining handles are re-associated by handle +identity because their order is not stable. Provider request/response handles never cross the +core API. The selected response handles are immediately reconstructed as a Fastly `Response` and +drained under that slot's policy: + +```rust +// Dispatch consumes `OutboundRequest`, so each pending slot retains every setting needed to +// finish and bound its buffered response. Provider handles remain adapter-private. +struct PendingSlot { + metadata: PendingSlotMetadata, + pending: PendingRequestHandle, +} + +struct PendingSlotMetadata { + index: usize, + budget: DispatchBudget, + max_decoder_bytes: u64, + max_brotli_window_bits: u8, + max_decoded_response_bytes: Option, + max_encoded_response_bytes: Option, + max_response_header_bytes: Option, + max_response_header_count: Option, + request_method: Method, + response_mode: ResponseMode, +} + +fn start_batch_until( + &self, + requests: Vec, + observation_cutoff: Deadline, +) -> OutboundBatch { + let batch_started_at = self.clock.now(); + let slot_count = requests.len(); + if observation_cutoff.is_expired_at(batch_started_at) { + return OutboundBatch::cutoff(slot_count); + } + + // Validate and synchronously issue every eligible request before selecting any response. + // `validate_for_dispatch` runs before the buffered-batch-only checks, preserving the shared + // GET/HEAD body-error precedence. A preflight/dispatch failure becomes a terminal item only + // when it is observed before the observation cutoff. + let mut completed = Vec::new(); + let mut pending = Vec::new(); + let mut cutoff_reached = false; + for (index, request) in requests.into_iter().enumerate() { + match self.prepare_batch(request, batch_started_at, observation_cutoff) + .and_then(|prepared| self.dispatch_batch_slot(index, prepared)) + { + Ok(slot) => pending.push(slot), + Err(error) => { + let Some(item) = finish_batch_item( + index, + batch_started_at, + self.clock.now(), + observation_cutoff, + Err(error), + ) else { + cutoff_reached = true; + break; + }; + completed.push(item); + } + } + } + + let clock = self.clock.clone(); + OutboundBatch::from_driver(slot_count, stream! { + for item in completed { + yield OutboundBatchDriverEvent::Item(item); + } + if cutoff_reached { + yield OutboundBatchDriverEvent::Cutoff; + return; + } + while !pending.is_empty() && !observation_cutoff.is_expired_at(clock.now()) { + // `select_pending_slot` calls `select_handles`, restores metadata by raw handle + // identity, and supports bounded groups up to Fastly's MAX_PENDING_REQS. + let (selection, metadata) = match select_pending_slot(&mut pending) { + Ok(selected) => selected, + Err(error) => { + yield OutboundBatchDriverEvent::Failed(error); + return; + } + }; + let index = metadata.index; + let outcome = finish_selected(selection, metadata, &clock).await; + let Some(item) = finish_batch_item( + index, + batch_started_at, + clock.now(), + observation_cutoff, + outcome, + ) else { + yield OutboundBatchDriverEvent::Cutoff; + return; + }; + yield OutboundBatchDriverEvent::Item(item); + } + if !pending.is_empty() { + yield OutboundBatchDriverEvent::Cutoff; + } + }) +} +``` + +- **Selection begins only after dispatch.** Every eligible request is handed to `send_async` before + the first `select_handles` call. Fastly dispatch remains sequential and synchronous, so this is + attempt-before-harvest rather than a guarantee of simultaneous overlap. Preflight/dispatch + failures are yielded before selected host completions; completion ordering therefore remains + `BestEffort`. +- **Deadline:** each request's host timeouts are set to the effective budget at dispatch, + so connect+headers cannot block past it. The body phase checks `budget.deadline` + **after every blocking body read returns, including the EOF read** (per §3.3.4 — + the read that discovers EOF can itself cross the deadline and would otherwise + slip through with `Ok(resp)`). Streamed bodies are wrapped to check before and + after each underlying read. Bounded overshoot per §3.3.4. +- **Cancellation / drop semantics.** Dropping `OutboundBatch` drops every retained pending handle + and stops guest observation, but Fastly exposes no finite host-cancellation proof for those + already-issued requests. A selected response body is still drained synchronously, so cancellation + cannot preempt a blocking host body read. A sibling request deadline never actively cancels + another slot. The observation cutoff ends the driver and leaves remaining slots unresolved; a provider-selection + failure that prevents the driver from preserving its slot invariants is a batch-level `Internal` + error and is never represented as unresolved work. +- **Dynamic backends.** Arbitrary HTTPS hosts use Fastly dynamic backends + (`Backend::builder`). Per Fastly's + [`BackendBuilder` docs](https://docs.rs/fastly/latest/fastly/backend/struct.BackendBuilder.html), + the **session-uniqueness rule is unconditional** — a dynamic backend name must + not match the name of any static service backend nor any other dynamic backend + built during this session. `NameInUse` carries no property-comparison + semantics: the SDK signals only "this name is taken in this session," and its + documented recovery (`Backend::from_str(name)`) returns a handle without + exposing the registered properties. EdgeZero therefore owns the entire + uniqueness story **at the guest layer**: a **session-scoped** adapter-local cache + (a `Mutex>` field on the + request-context `FastlyOutboundClient` — `Mutex` not `RefCell`, because the trait is + `Send + Sync`; see *Cache ownership* below) holds the identity → + backend mapping, and a hit + reuses the cached `Backend` while a miss calls `Backend::builder(..).finish()` + exactly once. Because EdgeZero hashes every relevant property into the + backend name (`ez_{sha256_128(identity)}_{client_namespace}`), distinct identities map to + distinct names — so a 50 ms slot and a 3 s slot to the same host get distinct + backends by construction, not by SDK-side property comparison. A + process-local atomic counter assigns a distinct namespace to every client instance; + this prevents two separately constructed clients in one host session from reusing + an uninspectable registration. Clones of one client retain the same cache and namespace. + `NameInUse` on a name **not** in the adapter's collision map can therefore + only mean an externally-registered backend (a static service backend, or another + component in **this** session — NOT a prior session, whose names are gone) is squatting the name — fail-closed `EdgeError::internal` because + the SDK does not let us prove identity match. The precise collision-detection + protocol is in the §4.3 algorithm later in this section. + + Identity tuple: + `scheme + ":" + host + ":" + resolved_port + ":" + tls_mode + ":" + budget_ms`, + where: + - `resolved_port` is the URI port or scheme default (`80`/`443`). + - `tls_mode` is `"tls"` for `https` or `"plain"` for `http`. + - `budget_ms` is the **exact true-ceil-to-ms** of `dispatch_budget(req).duration` — + `((duration.as_nanos() + 999_999) / 1_000_000).max(1)`, lint-clean form in the + `fastly_timeout_ms` helper. **No bucketing.** The cache is **per-session** (below), + so it cannot grow unbounded across requests — earlier drafts bucketed the budget to + bound a *cross-request* thread-local cache, but that cache model was wrong. Dynamic + backend registration names are session-scoped. Connection pooling is a separate SDK + feature. Fastly 0.13.1 reuses a connection only when both the backend name and every + backend setting are identical; the name below incorporates the exact rounded + budget, host, port, scheme, TLS mode, and client namespace. EdgeZero leaves the SDK + default pooling enabled, but a newly constructed client does not intentionally reuse a + previous client's connection by name. Within one client instance, + `send_all_until` + shares a single `batch_now`, so same-budget slots to the same host compute the + **same** `budget_ms` → one backend; distinct budgets → distinct backends. The cache + is therefore bounded by the number of distinct `(host, budget)` pairs in the + session's fan-out (≤ batch size), with **no** millisecond drift to bucket away. + - The **host timers use `budget_ms` exactly** (`connect-timeout` / + `first-byte-timeout` / `between-bytes-timeout` derive from the exact value), so the + headers phase is bounded by `budget.duration` (+ ms-rounding + `BATCH_DISPATCH_SLACK_MAX`), + **not** a looser bucket. The body phase is additionally enforced to the millisecond by + the cooperative `budget.deadline.is_expired()` check against the original `Deadline`. + Removing the bucket removes the ~10 % headers-phase overshoot an earlier draft + introduced; the net guarantee is again exactly what §4.3 "Net guarantee" states. + - `budget_ms` is the **true ceil-to-ms** — `((duration.as_nanos() + 999_999) / + 1_000_000).max(1)` (lint-clean form in `fastly_timeout_ms`), since `as_millis()` + floors and would make the host timeout too tight. (Apps wanting sub-ms wall-clock + should not target Fastly — host timeouts are millisecond-granular.) §3.3.4's "host + timeouts = `budget.duration`" is an abbreviation for "= ceil-to-ms of `budget.duration`". + + A 50 ms slot and a 3 s slot to the same host get **distinct** backends (distinct + `budget_ms` → distinct identity → distinct name) — they must, since their host timeouts + genuinely differ. Within one `send_all_until`, same-budget slots share `batch_now` → identical + `budget_ms` → one backend. + + Name = `format!("ez_{:032x}_{client_namespace}", sha256_128(identity))` — the first + 128 bits of a SHA-256 digest plus a process-local per-client counter. The digest + is collision-resistant in any realistic deployment (the previous 64-bit FNV-1a + draft was not). The name fits inside Fastly's backend-name length limit and is + valid for any host. In a + homogeneous-budget batch all slots targeting the same host + share one backend — **but only because `send_all_until` takes a single `now` snapshot + and passes it to every per-slot `dispatch_budget` call** (§3.3.2). Without that, + sequential `MonotonicInstant::now()` per slot would derive slightly different `duration`s + for the same shared caller `Deadline`, which would produce slightly different + ceiled `budget_ms` values and therefore different identities for the same host + under one batch deadline. The shared-`now` snapshot is a normative requirement + of the `send_all_until` flow, not an implementation hint. In heterogeneous-budget + fan-out each distinct budget gets its own backend, by design. Per-handler + backend count is bounded by `unique(host, port, tls, budget_ms)` tuples; apps + that mix wildly varying budgets should be aware of the dynamic-backend limit on + their Fastly service. Because Fastly dispatch is sequential, a multi-host or + heterogeneous-budget batch may require several synchronous registrations. If an earlier + registration or adapter work consumes more than the 25 ms dispatch slack while a later + slot's deadline remains live, the later cache miss fails as `Internal` at the + pre-registration guard without invoking another `finish()` host call. + + **Dispatch-overhead slack — hard-bounded for a CACHED backend, fail-closed-*detected* + for first-time registration** (see the honesty note after the bullets). Because `batch_now` is captured + *before* preflight, dynamic-backend creation, and `send_async`, the `budget_ms` + baked into the backend identity is a *snapshot* timeout (computed from `batch_now`) — + not the exact remaining wall-clock at the moment the SDK timer is armed. The Fastly host enforces + `budget_ms` from the moment it sees the request, so a request can in principle + complete up to `(now_at_send_async − batch_now) ms` after the absolute fan-out batch + deadline before the host fires its timeout. To keep this slack + **deterministically bounded** for warm/cached adapter setup and host-timer arming (not + request-body transmission, whose write gap remains unbounded). The capability itself is + `BestEffort` because both the cold path below and non-empty request writes have no such + bound: + + - The adapter caps `(now_at_send_async − batch_now)` at + `pub const BATCH_DISPATCH_SLACK_MAX: Duration = Duration::from_millis(25);` + (defined alongside `DEADLINE_FAR_FUTURE` in `src/time.rs`, §7). + - On a batch cache miss, the adapter performs this check before synchronous backend + registration. Every slot performs it again immediately before `send_async`. Each check + evaluates two things **in this order**: + **(1) the absolute deadline FIRST** — `if budget.deadline.is_expired() { return + Err(EdgeError::gateway_timeout_caused("deadline expired during Fastly dispatch", + budget.cause)); }`. A cold `Backend::builder(..).finish()` can block past the deadline + (§4.3 honesty note), and when it returns *after* the deadline, that is a genuine + **504 timeout**, NOT an EdgeZero bug — so it must be an attributed `gateway_timeout`, + never `internal`. **(2) THEN, only if time still remains** (`!is_expired()`) but the + adapter overhead exceeded the slack, `MonotonicInstant::now() - batch_now > + BATCH_DISPATCH_SLACK_MAX` → the remaining slots fail closed with + `Err(EdgeError::internal("Fastly batch adapter overhead between batch_now \ + and SDK arming (preflight + dynamic-backend lookup/creation + SDK setup) \ + exceeded BATCH_DISPATCH_SLACK_MAX; refusing to arm SDK timers with stale \ + duration"))`. So `internal` is reserved for **excess adapter overhead while the + deadline still had time** — a real "our setup is too slow" signal — and never for an + actual expiry. This is an internal diagnostic about **adapter-side** work, + not a handler-side complaint — handler code runs before `start_batch_until` is even + invoked, so it runs before `batch_now` is captured and cannot exhaust this + budget. The interval measured here is adapter overhead: per-slot preflight + validation, dynamic-backend lookup/creation host calls, and SDK setup + before `send_async`. If this fires in production, the operator looks at + backend-creation hostcall latency or a noisy neighbour, not at handler + code. + - The cooperative `budget.deadline.is_expired()` check during body drain still + catches body-phase overshoot per §3.3.4 (one between-bytes-timeout bound). + + **Honesty note — what the guard can and cannot do.** Batch dispatch checks both before a cold + `Backend::builder(..).finish()` and immediately before `send_async`; single `send` retains + the latter check. The pre-registration check prevents later heterogeneous host/budget slots + from beginning more synchronous registrations once earlier adapter work has exhausted the + dispatch slack. It cannot preempt the first admitted `finish()` after that host call starts. + The post-registration check detects any overshoot. That distinction splits the claim in two: + + - **Cache hit (no `finish()` call).** The measured interval is pure adapter compute — + preflight, a map lookup, SDK setup. It is genuinely bounded, and the setup/arming + portion holds the stated `BATCH_DISPATCH_SLACK_MAX + ms_rounding` bound. This does not + turn a body-bearing exchange into a finite end-to-end bound because the subsequent + host write remains untimed. + - **First-time dynamic-backend registration.** `finish()` is a **synchronous host + call that can block** — Fastly may make it wait for a service-wide dynamic-backend + slot. Nothing guest-side can interrupt it. If it blocks past the deadline, the + wall-clock overshoot has *already happened* by the time the post-registration guard runs — so it + **checks the absolute deadline FIRST and returns an attributed `gateway_timeout` (504)** + (the honest outcome for a real expiry). It does **not** call this `internal`: `internal` + is reserved for the *other* case — adapter overhead exceeding the slack **while the + deadline still has time** (a genuine "our setup is too slow" bug), never for a + time-that-actually-ran-out. **On this path the dispatch+headers phase is `BestEffort` for + wall-clock, not bounded** — the guard detects the overshoot and surfaces it as a 504, it + does not prevent it. + + So the honest one-line statement, which the matrix footnote and §5.4 rows must match: + *Fastly declares `outbound-deadlines` = `BestEffort`. The warm/cached path enforces a + documented bound, but a first-time `finish()` registration can overshoot the deadline; the + guard checks the absolute deadline first and returns an **attributed `gateway_timeout` + (504)** for that expiry (NOT `internal` — `internal` is reserved for adapter overhead + exceeding the slack while time still remains), and `capability()` cannot tell warm from + cold — so the static value is `BestEffort`, and a `required outbound-deadlines` hard-fails + on Fastly.* Apps + needing an exact absolute deadline on the dispatch+headers phase — including the first + request to a new host — target Axum. Cloudflare and Spin also avoid Fastly's blocking + registration step, but their cooperative host-teardown semantics keep + `outbound-deadlines` BestEffort (footnote 8). + + Net guarantee, with the explicit **sub-4 ms branch** broken out separately. **Both + branches below hold on the WARM path only — an already-registered (cached) backend, no + `finish()` in the measured interval.** On the first request to a new host, + `Backend::builder(..).finish()` can block unboundedly (per the honesty note above), so + these equations do NOT apply and the phase is `BestEffort`; that is exactly why the + capability is `BestEffort` (footnote 1). The bounds: + + - **`total_ms ≥ 4` (the common case), cached backend**: a Fastly slot can complete at most + **`BATCH_DISPATCH_SLACK_MAX + ms_rounding`** past the absolute fan-out batch + deadline on the dispatch+headers phase. Because connect and first-byte are + *separate* host timers (Fastly docs), the budget is split — `connect_ms = + total_ms / 4`, `first_byte_ms = total_ms - connect_ms` — so their sum equals + `total_ms` exactly and the dispatch+headers host enforcement is bounded by + `budget.duration`. If dispatch happens at `batch_now + Δ` with + `Δ ≤ BATCH_DISPATCH_SLACK_MAX`, the host fires at + `(batch_now + Δ) + (connect_ms + first_byte_ms) = (batch_now + Δ) + total_ms`, + which is `Δ + ms_rounding` past the absolute deadline. Setting *both* timers + to the full budget would have made the worst case ~2× — explicitly *not* what + this design does (see §3.3.4 / §4.3 code block). + - **`total_ms < 4` (the sub-4 ms degenerate case)**: §4.3 sets both + `connect_ms = first_byte_ms = total_ms`, so the dispatch+headers host + enforcement is bounded by `2 × total_ms` (≤ 6 ms total at the edge). The + post-deadline slack is therefore up to `BATCH_DISPATCH_SLACK_MAX + total_ms + + ms_rounding` (strict upper bound `25 + (≤ 3) + (≤ 1) < 29 ms` wall-clock). + At this scale ms-rounding already + dominates a meaningful deadline; sub-4 ms outbound budgets are degenerate + inputs, not a normal operating point. The test row asserts the 2× bound + explicitly rather than the `=` invariant. + + The body-phase cooperative check still adds up to one between-bytes-timeout + overshoot during drain (§3.3.4) in either case, but that's the only other + source. All terms are hard adapter constants, not "scales with preflight." + + Single `send` snapshots `now` at the public method entry before preflight and passes it + into `send_one` — there is no + `batch_now` shared across slots — but time still passes between + `dispatch_budget(req, now, None)` and `send_async` (backend lookup, possible + `Backend::builder().finish()` host call, SDK request construction). The + **same TWO-CHECK guard in the SAME ORDER as batch dispatch** applies (§4.3 — this was + inconsistent before; corrected): immediately before `send_async`, the adapter checks + **(1) the absolute deadline FIRST** — `if budget.deadline.is_expired() { return + Err(gateway_timeout_caused("deadline expired during Fastly dispatch", budget.cause)); }` + (a cold `finish()` returning past the deadline is a genuine **504**, not an EdgeZero bug); + **(2) THEN, only while time remains**, `MonotonicInstant::now() - now > BATCH_DISPATCH_SLACK_MAX` + → `EdgeError::internal(..)` with the same "adapter overhead between dispatch_budget and + SDK arming" diagnostic as batch dispatch. So single `send` returns the attributed **504** for a + real expiry and `internal` only for excess overhead with time to spare — identical to + batch path. The slack window is typically narrower for single `send` (no multi-slot preflight + and dispatch loop), but the bound is the same hard constant; the previous "structurally 0" wording was + incorrect. The phase-budget split and sub-4 ms branch apply identically. + + §5.4 has a row that locks this. The test cannot use a handler-side sleep before + `start_batch_until` — that runs *before* the adapter captures `batch_now`, so it never + exercises the slack guard. The test instead uses an **adapter-internal injection + hook** (a **`#[cfg(feature = "test-utils")]`** callback on the SDK-independent + dispatch driver used by `FastlyOutboundClient`, invoked between `batch_now` capture + and per-slot dispatch) to introduce a + synthetic delay exceeding `BATCH_DISPATCH_SLACK_MAX`. **It must be feature-gated, + not `#[cfg(test)]`** — `tests/contract.rs` is an external integration test and + compiles the adapter *without* `cfg(test)`, so a `#[cfg(test)]` hook would be + invisible to it (§5.5 *Executable test seams*). With the hook set, late slots return + `internal("Fastly batch adapter overhead between batch_now and SDK arming \ + (preflight + dynamic-backend lookup/creation + SDK setup) exceeded \ + BATCH_DISPATCH_SLACK_MAX; refusing to arm SDK timers with stale duration")`; + without it, no slot ever returns that error. Apps that need exact + absolute-deadline enforcement on the dispatch+headers phase target Axum. Cloudflare and + Spin also arm from `budget.deadline.remaining()` (§4.4 step 3), but neither claims a + finite host-teardown bound. **Collision detection** is + belt-and-suspenders. + + **Cache ownership — a request-context/session map (a `Mutex` field on the client).** + This is the single authoritative statement; it governs the protocol below and the + §4.3 *Dynamic backends* discussion. **Fastly dynamic-backend names are + session-scoped, NOT global across requests** (verified against the `BackendBuilder` + docs: dynamic-backend *registration* is per-session — each session registers into its + own namespace, so a `NameInUse` applies to the active session). The SDK separately + enables same-name/exact-same-settings connection pooling across sessions by default; + EdgeZero leaves pooling enabled, but the per-client namespace prevents intentional + same-name reuse across newly constructed clients. `FastlyOutboundClient` is constructed for + each EdgeZero request context (`crates/edgezero-adapter-fastly/src/request.rs`), receives + a fresh cache, and must not be retained beyond that context. The cache is therefore a + field on the client whose intended lifetime is the active request/session: + + ```rust + struct FastlyOutboundClient { + backend_namespace: u64, + // Per-request/per-session dedup map. MUST be Mutex, not RefCell: + // OutboundHttpClient: Send + Sync (stored as Arc in http::Extensions), + // and RefCell is !Sync. The Mutex is uncontended on the single-threaded WASM + // guest — it satisfies the Sync bound, it does not serialize anything. + backends: Mutex>, + // … + } + ``` + + **This reverses two earlier drafts wrong for the same root reason** — the + belief that registration names persist across requests. One made the cache a cross-request + `thread_local!` (which would carry **stale** entries into a reused instance's next + session, where those names are unregistered); another bucketed the budget to bound + that non-existent cross-request growth. Neither is needed: the map exists only to + **dedup within a single session's fan-out** (multiple `send_all_until` slots / multiple + `send`s in one handler to the same host+budget reuse one registration), so its size + is bounded by the fan-out, and it is discarded when the request context ends. Disabling + SDK connection pooling is distinct from discarding this registration cache. The **test seam** + (§5.5) exposes this client field. + + The protocol below takes the map's **uncontended `Mutex`**. To state the one model + plainly, because earlier drafts said three different things: the `Mutex` exists solely + to satisfy `OutboundHttpClient: Send + Sync` (the handle is stored as + `Arc` in `http::Extensions`) — **not** to serialize anything. + The Fastly guest is single-threaded, so no two `send_one` bodies interleave during a + synchronous host call, and **the lock is never held across a host call** (step 3). + (Read any surviving "no lock", "thread-local", or "lock held through `finish()`" + phrasing here as superseded by this paragraph.) + + 1. Lock the map (`self.backends.lock()` — uncontended; handle any poison by treating a poisoned lock as an internal error, never by unwrapping in production). + 2. If the name maps to a stored entry `(stored_identity, cached)`: + - **`stored_identity == identity`**: clone the cached `Backend`, drop the + lock, dispatch. + - **`stored_identity != identity`** (an in-adapter SHA-256-128 collision + between two distinct identities mapping to the same name): fail closed with + `EdgeError::internal("Fastly dynamic backend name collision in this + adapter's map — two distinct identities hashed to the same backend name; + refusing to silently swap settings")`. The previous-round wording reused + the cached backend by name alone, which would have silently bound a new + request to whichever identity got cached first — that bug is fixed by the + explicit identity comparison here. Release the borrow. §5.4 has a row that + exercises this path via an injectable hash collision under the `test-utils` feature + (**not** `#[cfg(test)]` — see §5.5 *Executable test seams*). + 3. Otherwise (name is absent), **release the lock**. For `send_all_until`, run the absolute + deadline-then-dispatch-slack guard before starting more synchronous work; if it fails, + return the typed timeout or `Internal` without registering a backend. Then call + `Backend::builder(..).finish()`. The lock is **not** held across this host call: + `finish()` registers a dynamic backend and can block waiting for a service-wide + backend slot (see *Dispatch-phase deadline honesty* below), and holding a lock + across a potentially-blocking host call is a hazard for any future multi-threaded + host. Releasing it does **not** reintroduce the same-identity race an earlier draft + worried about: the guest is single-threaded and `finish()` is a synchronous + (non-`await`) call, so no other `send_one` can run in the gap — a `NameInUse` from a + genuine miss is therefore still unambiguously external (step 5). + 4. On `Ok(backend)`: **re-acquire the lock**, insert `(identity, backend.clone())` + into the map, drop the lock, and return the `Backend`. + 5. On `Err(NameInUse)`: per Fastly's + [`BackendBuilder` docs](https://docs.rs/fastly/latest/fastly/backend/struct.BackendBuilder.html), + the **session-uniqueness rule is unconditional** — "a dynamic backend name + must not match the name of any static service backend nor match any other + dynamic backend built during this session." `NameInUse` does **not** carry + property-comparison semantics ("same identity → returns Ok" was a false + premise in earlier drafts); the SDK signals only "this name is taken in + this session," period. The SDK's documented recovery pattern is to call + `Backend::from_str(name)` (alias `Backend::from_name`) to obtain a handle + to the already-registered backend — but `from_str` returns a handle only + and **does not expose the registered backend's properties** to the guest + for comparison. + + The lock is released across `finish()` in step 3 and re-acquired in step 4 (not + held continuously — see *Cache ownership*), but the reasoning is unaffected because + the Fastly guest is **single-threaded** and `finish()` is a synchronous + (non-`await`) call: nothing else can run in the gap, so any + name *we* registered in this session necessarily showed up in step 2's borrow. A + `NameInUse` here therefore means the name was registered by an **external + party in this same session**: a static service backend, or another component + of this instance (**not** a prior session — dynamic-backend names are + session-scoped and a fresh session starts with a clean namespace). Since the + SDK does not let us inspect that external + backend's properties, we cannot prove its identity matches ours. Fail + closed with `EdgeError::internal("Fastly Backend::builder returned + NameInUse for a name not in this adapter's collision map; the SDK does + not expose the externally-registered backend's properties, so we cannot + prove identity match — refusing to dispatch to a backend with possibly + mismatched TLS / timeout / SNI configuration")`. Release the borrow. + + The alternative — falling back to `Backend::from_str(name)` and trusting + the external registration — is exactly the "you should be careful to only + use this capability in situations in which you are 100% sure that this + name will always lead to the same place" caveat that Fastly's docs + attach to `from_str`. Since EdgeZero owns the `ez_{sha256_128(identity)}_{client_namespace}` + naming scheme, a `NameInUse` for a name **absent from this session's map** + can only mean one of: (a) a **static service backend** is configured with + that name (the SDK's uniqueness rule spans static + dynamic within the + session), or (b) another component **in this same session** registered it, + or (c) a SHA-256-128 collision (vanishingly unlikely given the 128-bit + identity space). None is safe to silently inherit. + + > Note: it canNOT mean "a prior session registered it." Dynamic-backend + > names are **scoped to the session** and may overlap freely across + > sessions/instances — a fresh session starts with a clean namespace. An + > earlier draft attributed the collision to a prior session / another + > EdgeZero deployment sharing an edge dictionary; that is not how the + > lifetime works. This matters for the session-scoped cache above: the map + > and the host's name registry share **exactly** the same lifetime (the + > session), which is precisely why the map is a reliable mirror of it — and + > why a *cross-request* (process-persisted) map was not: a longer-lived map would + > carry stale names into a new session whose host registry has been cleared, so it + > would stop mirroring the registry. (The map here is per-session/per-request — the + > correct, shorter lifetime; the rejected alternative is the *cross-request* one.) + 6. On any other `Backend::builder(..).finish()` error — i.e. a + **`BackendCreationError`** — **map per the exhaustive stage-1 table below, NOT + with a blanket `bad_gateway`.** This is the one authoritative mapping; earlier + prose here said "map every other creation error to `bad_gateway`", which is wrong: + `ConnectTimeoutTooLarge` / `FirstByteTimeoutTooLarge` / `BetweenBytesTimeoutTooLarge` + / `NameTooLong` / `EncodingError` mean **EdgeZero** violated its own clamp/naming + invariant → **`internal` (500)**, not a 502. Only genuine host rejections + (`Disallowed`, `HostError`) are `bad_gateway`. `Disallowed` gets the dedicated + "enable dynamic backends" diagnostic. (`Backend::builder` is `#[non_exhaustive] = + false`, so the match is exhaustive with **no** `_` arm — a future SDK variant is a + compile error to be classified deliberately.) + + > **DNS / TLS / connect failures do NOT occur at this stage.** The SDK separates + > **`BackendCreationError`** (registering a backend — this step) from + > **`SendErrorCause`** (actually performing the exchange). Registration does not + > resolve DNS or complete a TLS handshake; those happen on **send**, and are + > mapped by the send-stage `SendErrorCause` table below. An earlier draft listed + > "DNS resolution failure / TLS misconfiguration" here — that is wrong, and it + > made the corresponding §5.4 test unwritable (a fake *builder* cannot produce a + > DNS branch). Test the two stages against their own error types. + `EdgeError::internal` is reserved for **adapter contract bugs** — invariant + violations the adapter itself should have prevented (pending-handle selection/correlation + invariants, the `BATCH_DISPATCH_SLACK_MAX` overshoot, this + section's `NameInUse` external-registration case). Release the borrow. + + **Backend *creation* errors are not the transport errors.** The list above covers + `Backend::builder(..).finish()` — i.e. *registering* a dynamic backend. **DNS, TLS, + and connection failures do not surface there**; they arrive later, from the **send** + itself, as a `SendError` whose `SendErrorCause` names the failure. Mapping the two + stages together (as earlier drafts did) would mislabel a connect failure as a + "backend setup" error. The normative **send-stage** mapping — applied at + the selected pending-handle result and the single-`send` wait path, + replacing today's blanket `EdgeError::internal(..)` on `wait()` failure: + + **Per-variant policy.** The variant names below are the real `fastly` 0.13.1 enums + (`backend::builder::BackendCreationError`, `http::request::SendErrorCause`). **A + blanket "anything else → 502" is wrong**: several variants mean *EdgeZero* violated + its own invariant, and reporting those as an upstream gateway failure hides an adapter + bug behind a plausible 502. + + > **⚠️ The two enums have different exhaustiveness properties — verified against the SDK source, and + > this drives both the code and the tests:** + > + > | | `BackendCreationError` | `SendErrorCause` | + > | --- | --- | --- | + > | `#[non_exhaustive]`? | **No** | **Yes** | + > | Exhaustively matchable by us? | **Yes** — omit a `_` arm so a future SDK variant is a **compile error** | **No** — a `_` arm is *mandatory*; new variants silently fall through | + > | Constructible in a test? | **Yes** (`PartialEq` too) | **Yes**, for public known variants; hypothetical future variants cannot be constructed | + > + > So: match `BackendCreationError` exhaustively (no `_`). For `SendErrorCause` an + > exhaustive match is **impossible** — an earlier draft's instruction to "match them + > exhaustively so a future variant is a compile error" is unachievable there, and the + > mandatory `_` arm is exactly why its default must be the *narrow* `Custom`-style + > 502 rather than a blanket. By contrast, the `SendError` wrapper is unconstructible (private fields, + > no public ctor), though `SendError::root_cause() -> &SendErrorCause` lets the + > adapter read the cause. See *Send-stage test seam* below for how this is tested. + + **Stage 1 — `BackendCreationError` (registration):** + + | Variant | EdgeError | Status | Why | + | --- | --- | --- | --- | + | `Disallowed` | `bad_gateway`, reason `Unspecified` | 502 | Dynamic backends off on the service — operator action; carries the dedicated diagnostic but is not a transport/protocol/decode failure. | + | `NameInUse` | *(not an error here)* | — | Handled by the cache protocol above (identity compare → reuse or fail closed). | + | `ConnectTimeoutTooLarge`, `FirstByteTimeoutTooLarge`, `BetweenBytesTimeoutTooLarge` | **`internal`** | **500** | **EdgeZero bug.** The `DEADLINE_FAR_FUTURE` clamp (7 d) plus `fastly_timeout_ms`'s u32-ms clamp exist precisely to make these unreachable. Reaching one means our clamp is broken — not an upstream failure. | + | `NameTooLong` | **`internal`** | **500** | **EdgeZero bug.** We own the naming scheme (`ez_` + 32 hex = fixed length); too-long is impossible unless the scheme changed. | + | `EncodingError` | **`internal`** | **500** | **EdgeZero bug.** We construct the backend strings; invalid UTF-8 is ours. | + | `HostError(FastlyStatus)` | `bad_gateway`, reason `Unspecified` | 502 | Genuine host-side registration rejection with no narrower portable category. | + + **Stage 2 — `SendErrorCause` (the exchange):** + + | Variant(s) | EdgeError | Status | + | --- | --- | --- | + | `DnsTimeout` | `gateway_timeout`; `Unspecified` before absolute expiry, selected cause at/after expiry | **504** | + | `ConnectionTimeout`, `HttpResponseTimeout` | `gateway_timeout` with selected cause because EdgeZero configures those phase timers from the selected budget | **504** | + | `DnsError`, `DestinationNotFound`, `DestinationUnavailable`, `DestinationIpUnroutable`, `ConnectionRefused`, `ConnectionLimitReached` | `bad_gateway`, reason `Unreachable` | 502 — the exchange did not establish a usable upstream response path. | + | `TlsProtocolError`, `TlsCertificateError`, `TlsAlertReceived`, `TlsConfigurationError` | `bad_gateway`, reason `Unreachable` | 502 — TLS establishment failed before a response head. | + | `ConnectionTerminated` | `bad_gateway`, reason `Transport` | 502 — the connection made progress and then terminated. | + | `HttpIncompleteResponse`, `HttpResponseHeaderSectionTooLarge`, `HttpResponseBodyTooLarge`, `HttpResponseStatusInvalid`, `HttpUpgradeFailed`, `Http2StreamError`, `HttpProtocolError` | `bad_gateway`, reason `Protocol` | 502 — malformed/oversized **upstream** response. | + | `IoError` | `bad_gateway`, reason `Transport` | 502 | + | `ImageOptimizerUnsupported` | `bad_gateway`, reason `Unspecified` | 502 — no narrower portable category. | + | `HttpRequestUriInvalid`, `HttpRequestCacheKeyInvalid`, `HttpCacheLimitExceeded`, `HttpCacheApiUnsupported` | **`internal`** | **500** — **EdgeZero bug.** We build the request/URI and don't use the cache API; a *locally-invalid request* is ours, not the upstream's. (§3.1.3/§3.1.4 validate the URI long before dispatch.) | + | `InternalError(..)` | **`internal`** | **500** — the SDK's own unexpected host-internal fault: not the *origin's* failure (so not 502) and not a malformed request WE built (so not kind-(i)), but a platform-internal error. `internal` (500) covers this kind-(ii) fault per the broadened taxonomy (§5.4). | + | `Custom(..)` | `bad_gateway`, reason `Unspecified` | 502 — unknown/extension cause; the only defensible default, and it is *narrow* rather than a blanket. | + + **Send-stage test seam — test known SDK causes and SDK-independent policy.** + Public known `SendErrorCause` variants are constructible outside the SDK; enum-level + `#[non_exhaustive]` prevents exhaustive external matching, not construction of those + variants. Only the `SendError` wrapper requires an SDK-produced value. Retain the + two-stage classification so native policy tests remain SDK-independent, and directly + test the SDK boundary for every known cause: + + ```rust + // 1. A locally-defined, CONSTRUCTIBLE classification of what went wrong. + // Unit tests build these directly — no SDK types involved. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) enum SendFailure { + BudgetedTimeout, // ConnectionTimeout | HttpResponseTimeout + LocalInvariant, // HttpRequestUriInvalid | HttpRequestCacheKeyInvalid | HttpCacheLimitExceeded | HttpCacheApiUnsupported + PlatformInternal, // InternalError: Fastly host/runtime internal fault, not caller input + ProviderTimeout, // DnsTimeout: no Fastly SDK DNS-timeout setter + Transport, // ConnectionTerminated | IoError + Unknown, // Custom, and any future #[non_exhaustive] variant + Unreachable, // DnsError | Destination* | Connection{Refused,LimitReached} | Tls* + UpstreamProtocol, // HttpIncompleteResponse | Http*TooLarge | HttpStatusInvalid | Http2StreamError | ... + } + + // 2. The POLICY: pure, total, unit-testable, zero SDK dependency. Native policy tests + // assert one case per SendFailure, constructed directly. It receives the absolute deadline, + // selected cause, and an injected observation instant. `BudgetedTimeout` is always attributed + // because EdgeZero configured Fastly's connect/response phase timer from that budget. + // `ProviderTimeout` is unattributed before the absolute deadline because Fastly exposes no + // DNS-timeout setter; at/after the deadline, deadline precedence attributes any result. The + // other arms select `BadGatewayReason::{Protocol, Transport, Unreachable, Unspecified}` + // from `failure` when observed in budget. Injecting the instant makes the boundary deterministic and avoids + // a second clock read inside the classifier. + pub(crate) fn classify( + failure: SendFailure, + deadline: Deadline, + cause: BudgetSource, + observed_at: MonotonicInstant, + ) -> EdgeError { /* per the table above */ } + + // 3. The BOUNDARY: known SDK variants are directly tested. A `_` arm is + // MANDATORY (#[non_exhaustive]); future variants map to `Unknown` -> narrow 502. + fn cause_to_failure(cause: &SendErrorCause) -> SendFailure { /* match … , _ => Unknown */ } + ``` + + Tier 2 retains native + **`classify(SendFailure::X, deadline, cause, observed_at)`** tests and adds SDK-gated, + in-crate tests constructing every pinned public `SendErrorCause` variant. Assert both + `cause_to_failure` classification and the composed error's status/kind/reason, including + all budget-source values for both timeout classes before, exactly at, and after the + absolute deadline, plus the exact `BadGatewayReason` for every 502 bucket. These are + Fastly adapter tests, not core + Tier 1 tests; execute the SDK-gated tests through the Fastly WASM `--lib` gate (§5.5). + Only hypothetical future variants use cross-crate compilation and review coverage + for `_ => Unknown => 502`; never fabricate them with unsafe code. Tier 3 retains + real-failure end-to-end coverage of the SDK-produced `SendError` wrapper. + + **Consequence for the "internal is legal on only three paths" assertion (§5.4):** that + claim is now **wrong** and must be widened — `internal` is also correct for the + invariant and platform-internal variants above. The §5.4 row asserts `internal` appears + **only** + for: (a) `BATCH_DISPATCH_SLACK_MAX` overshoot, (b) pending-handle + selection/correlation invariants, (c) the `NameInUse` external-registration case, **(d) the + clamp/name/encoding `BackendCreationError` variants, (e) `SendFailure::LocalInvariant`, + and (f) `SendFailure::PlatformInternal`**. Cases (a)–(e) are EdgeZero-invariant + violations; case (f) is the separately classified Fastly host/runtime internal fault. + Neither class includes an origin transport/protocol failure. + + **`DnsTimeout` is 504, not 502, but early DNS timeout provenance is unspecified.** It names DNS + (transport-shaped, which reads 502) but it **is a fired timer**. Classify by + **"did a timer fire?"**, not by which subsystem reported it. A DNS answer of "no such + host" is 502; a DNS lookup that ran out of time is 504. Fastly exposes no DNS-timeout + setter, so a `DnsTimeout` observed before EdgeZero's absolute deadline uses + `BudgetSource::Unspecified`; at/after the deadline, the deadline wins and carries the + selected cause. Retry policy remains an application decision and is not encoded by this + mapping. Live characterization records which provider cause each configured phase + produces but does not replace the deterministic boundary tests. + + Rationale: a fired host timer is a **deadline** outcome (504) and must be + distinguishable from an upstream that was unreachable (502) — the fan-out caller + retries those differently. `EdgeError::internal` **is** correct for a **narrow** set + of send-stage causes: the `SendFailure::LocalInvariant` group + (`HttpRequestUriInvalid`, `HttpRequestCacheKeyInvalid`, `HttpCache*`) means EdgeZero + built an invalid request, while `SendFailure::PlatformInternal` represents Fastly's + explicit host/runtime `InternalError`. It is **never** correct for a + *transport/upstream* cause + (those are 502/504). (An earlier draft said `internal` is never correct for any + send-stage failure — that contradicts the `LocalInvariant` row of the §4.3 table, + which is authoritative.) A completed exchange, including any non-2xx, is `Ok`. + + **`BackendCreationError::Disallowed`** (dynamic backends not enabled on the + service) is the one creation error that gets its own Rust-side diagnostic: map to + `EdgeError::bad_gateway_with_reason(DYNAMIC_BACKENDS_DISABLED_MESSAGE, BadGatewayReason::Unspecified)`, + so application diagnostics can point at the deployment fix. Rendering that error as an + HTTP response still emits only `"bad gateway"`; the provider-specific text is never a + wire message. + `DYNAMIC_BACKENDS_DISABLED_MESSAGE` is the one shared exact diagnostic: + `"Fastly dynamic backends are not enabled on this service; enable them in the service configuration"`. + + There is no `BackendSlot::Building` / `Failed` variant and no condvar. There **is** an + uncontended `Mutex` (the field is `Mutex>`, required for `Send + Sync`), but + it exists only to satisfy that bound — the Fastly guest is **single-threaded**, so there + is no concurrency for a state machine to guard, and **the lock is never held across the + `finish()` host call** (the *Cache ownership* protocol releases it, then re-acquires to + insert). Nothing can observe an intermediate state because nothing else runs while the + lock is held, and the lock isn't held across the one call that could block. The race the + round-34 review flagged is structurally impossible for that reason. (Earlier drafts + variously said "no lock", "hold the `Mutex` across the host call", and used finer-grained + per-name reservations; all are superseded by the single model in *Cache ownership*.) The protocol applies + to: + + - **`send_all_until`** — each slot looks up its name; if the name already maps to its own + identity, reuse; if it maps to a *different* identity, fail closed with + `EdgeError::internal("dynamic backend name collision — refusing to reuse")`. + - **Single `send`** — same lookup path; same fail-closed behaviour. + - **Across calls within ONE request context — a session-scoped + `Mutex>` field.** Fastly dynamic-backend registration names are + session-scoped. The cache is therefore a field on the request-context + `FastlyOutboundClient`, fresh with that client — + it exists to dedup **within** a single session's fan-out (multiple `send_all_until` slots / + multiple `send`s in one handler to the same host+budget reuse one registration), and + is discarded when the request context ends. **It MUST be a `Mutex>`, NOT a + `RefCell`:** `OutboundHttpClient: Send + Sync` (the handle stores `Arc` in `http::Extensions`, which require `Sync`), and `RefCell` is + `!Sync` — a `RefCell` field would fail to compile. The `Mutex` is **uncontended** on + the single-threaded WASM guest (it exists to satisfy `Sync`, not to serialize), and + the lock is never held across a host call. A SHA-256-128 collision against an earlier + registration in this session is still caught. *(Two earlier drafts got this wrong for + the same root cause — the false belief that registration names persist across requests: one made + the cache a cross-request `thread_local!`, another used a non-`Sync` `RefCell`. Both + are superseded by this request-context `Mutex` field. Connection pooling remains at the + Fastly SDK default because reuse requires the same name and exact same settings; the + deterministic identity/settings prevent cross-budget or cross-TLS reuse.)* + - **`Backend::builder` returns `NameInUse`** — the adapter cannot fully verify + the registered identity. Fastly's `Backend::from_name` returns a handle to the + existing backend but its public getters do not round-trip every builder field + (SNI hostname / certificate hostname are notably opaque per the + `BackendBuilder` / `Backend` docs). So the adapter **fails closed** with + `EdgeError::internal("Fastly Backend::builder returned NameInUse for a name \ + not in this adapter's collision map — refusing to reuse an externally \ + registered backend")`. Names already in the adapter's own map are reused + cheaply with no `Backend::builder` call (the in-memory `Backend` handle is + already present); only an *external* registration of a colliding name + triggers this path, and the safest response is to surface it rather than + guess. This makes the adapter's collision map authoritative. + + Backends are deduplicated by full identity within and across calls. Requires + dynamic backends enabled on the service (surfaced via the `outbound-http` + capability and the service prerequisite below). +- Requests in `start_batch_until` / `send_all_until` are required to have buffered request bodies AND buffered + response mode per the trait contract (§3.1.1). A `Body::Stream` request body + yields `out[i].outcome = Err(EdgeError::bad_request(..))`; a request with + `response_mode = Streamed` also yields + `out[i].outcome = Err(EdgeError::bad_request(..))`. + This removes unbounded application **source pulls** from Fastly's + dispatch-all-then-select model and removes the cross-slot streamed-response + deadline-lifetime problem (§3.1.1), identically on every adapter. It does not + prevent a non-empty buffered body from blocking in Fastly's untimed host-write + interval; that separate limitation is footnotes 1/4. +- **Streamed request bodies in single `send`.** The single-request path accepts + `Body::Stream` and uses `Request::send_async_streaming(&backend) -> + Result<(StreamingBody, PendingRequest), SendError>`. Classify an initial send error + through `SendFailure` (§4.3); on success, feed chunks from the core stream to the + `StreamingBody`, with these rules: + - **Byte count cap.** Pre-append checked accounting against + `req.max_request_body_bytes` (default 8 MiB). Over-cap → `bad_request` (400) — + the `StreamingBody` is dropped without `finish()`, the `PendingRequest` is + dropped, and the slot returns the error. + - **Upload ownership and terminal results.** Keep both SDK handles owned until an + explicit terminal branch. Check `budget.deadline.is_expired()` before each source + pull and immediately after it returns, including EOF or a source error. Expiry + drops both handles without `finish()` and returns + `gateway_timeout_caused(.., budget.cause)`. A source `EdgeError` observed before + expiry is preserved unchanged, with the same cleanup. For every `write_all` / + `flush()` result, recheck the deadline before interpreting success or failure; + otherwise an I/O failure maps to `bad_gateway` reason `Transport` and drops both + handles. + On clean source EOF within budget, consume the `StreamingBody` with **exactly one + `finish()` call**. Fastly 0.13.1's `finish(self) -> io::Result<()>` can flush buffered + bytes and fail; dropping the body instead aborts an otherwise successful upload. + Recheck the absolute deadline after `finish()` returns, before interpreting its + result: expiry wins with the attributed 504; an in-budget finish error is 502 reason + `Transport`. + Either error drops `PendingRequest` without calling `wait()`. Only an in-budget + successful finish advances to the response phase. `finish()` consumes the body + even on error, so it is never retried. Dropping the entire send future before + finalization also drops both owned handles without a successful finish. + - **Deadline enforcement has two phases with different bounds:** + - *Source-stream yield* (`stream.next().await`): **unbounded on Fastly** — no + guest async primitive can preempt a stalled `stream.next()` waiting for the + app's source stream to yield. This is the `BestEffort` aspect of + `streamed-upload-deadlines` on Fastly. Apps that need real-time enforcement + against an untrusted upload source must pass a buffered request body + (`Body::Once`) where the bytes are already in hand and no `stream.next().await` + is involved. **This removes only the SOURCE-PULL stall.** It does not bound the + host write: a `Body::Once` is still pushed to the origin in the untimed window + between `connect` and `first_byte`, so a slow-reading origin stalls a buffered + upload too. Buffering is not a remedy for the write path — only a different + adapter is. + - *Host write* (`StreamingBody::write_all` / `flush()` / terminal `finish()`): these + are synchronous host calls. **Fastly's `between_bytes_timeout` applies only to + received bytes (the gap between bytes Fastly receives from the origin), not + to guest-to-origin writes** — see the [Fastly Backend API + docs](https://www.fastly.com/documentation/reference/api/services/backend/), + which describe `between_bytes_timeout` as "maximum duration … that Fastly will + wait while receiving no data on a download from a backend." No published + Fastly backend-timeout field bounds the host-side write of guest-supplied + bytes to origin. **BestEffort** for the write phase: a `StreamingBody::write_all` + whose host TCP buffer is full because origin stopped acking has no + adapter-configurable timeout. The adapter's only recourse is the + `budget.deadline.is_expired()` checks around source pulls, writes, and finalization + described below; these checks cannot interrupt a blocked synchronous host call. + Apps that need real-time enforcement against a slow + origin **read path** rely on `between_bytes_timeout` once the response body + starts flowing. Apps that need real-time enforcement against a slow origin + **write path** must target a different adapter. `max_request_body_bytes` bounds + bytes and memory, not elapsed write time: even one small `write_all` can block + without a documented finite bound, so no cap value creates a wall-clock guarantee. + - *Around each chunk*: the pre-pull and post-ready checks above prevent further + source work after an observed expiry. The post-ready check also prevents a + source that stalled past the deadline from writing the chunk it eventually + yielded. Recheck after each host write/flush, including errors, and after + terminal `finish()`. These cooperative checks detect expiry only once the + synchronous call returns; finalization has the same unbounded host-write gap. + + Net: the capability matrix entry `streamed-upload-deadlines = BestEffort` for + Fastly reflects **both** phases — **source-stream yield AND host write are each + unbounded** on the guest side. There is **no** `BoundedCooperative` write-side + bound: `between_bytes_timeout` is documented as **receive-side only** (it bounds the + gap between bytes *received from origin*) and does **not** bound guest-to-origin + writes. (An earlier draft claimed the write phase was `BoundedCooperative` via + `between-bytes-timeout` and that only source-yield was the "worst phase" — both are + wrong; footnote 2 and §8 risk 7 are correct.) The only adapter-side bound on either + phase is the cooperative `budget.deadline.is_expired()` check around source pulls, + writes, and finalization described above. + - **Response phase: host timeouts are *not* adjustable mid-flight.** The Fastly + SDK sets connect / first-byte / between-bytes timeouts once before `send_async` + (§3.3.4) and does not expose post-dispatch mutation. For + `send_async_streaming`, dispatch happens **before** chunks are fed, so the + response-phase host timeouts are locked to the phase-split values computed at + dispatch (`first_byte_ms` for the headers wait, `between_ms` for inter-chunk + gaps once the response body flows). After the upload `finish()`es the adapter + checks `budget.deadline.remaining()` cooperatively before calling `wait()` — + if `None`, drop the `PendingRequest` and return `gateway_timeout` without + waiting. **If the upload leaves a tiny positive remaining budget**, the + cooperative check at this boundary passes, and the host then waits up to + its dispatch-time `first_byte_ms` for headers even though only the tiny + remainder of batch budget is left. **The headers wait is bounded by at + most one dispatch-time `first_byte_ms` interval past `budget.deadline`** — + a single, one-shot overshoot, not a per-chunk accumulator. + + Once headers arrive, the **response body** flows through the cooperative + deadline-aware wrapper (§4.3 "Streamed-response wrapping"), whose + `is_expired()` check fires before and after **each** underlying read. + Because the wrapper checks after the read that delivered the first body + chunk, and the deadline is already expired by construction in this + scenario, **the very next deadline-check yields `Err(gateway_timeout)`** — + the wrapper does **not** wait another `between_bytes_timeout` per chunk + indefinitely. **After an upload finalizes within budget, response-phase + post-deadline overshoot is bounded by `first_byte_ms` + (the headers wait) plus one `between_bytes_timeout` (the worst-case + interval during which the host is mid-read of the *first* body chunk + when the wrapper fires)** — a closed-form bound, not a per-chunk + accumulator. The previous "plus one between-bytes-timeout per body-chunk + gap" wording in earlier drafts was wrong; the wrapper preempts after the + first post-deadline read returns. This bound excludes the unbounded source pulls, + host writes, and `finish()` call; it is not an end-to-end upload bound. + + This is a deliberate, documented Fastly-specific behaviour of streamed uploads. + Passing `Body::Once` removes only the source-pull stall and avoids this post-upload + timer-staleness shape; it does **not** bound Fastly's host write of those buffered bytes. + Apps that require a strict end-to-end wall-clock bound for any non-empty upload must + target an adapter whose upload path is Native. +- `capability()` per §3.5.2: `outbound-http` = `BestEffort` (footnote 9: adapter + support depends on a service entitlement the static gate cannot verify), + `outbound-complete-resource-accounting` = `Unsupported`, + `outbound-header-fidelity` = `Native`, `outbound-deadlines` = + **`BestEffort`** (footnote 1 — a warm zero-body path has documented partial bounds, but + every non-empty request retains an unbounded host-write interval and the FIRST request + to a new host calls `Backend::builder(..).finish()`, which can also overshoot before the + guard runs; since `capability()` is static and cannot distinguish those paths, + the honest value is `BestEffort`, so a `required outbound-deadlines` correctly hard-fails + on Fastly rather than fooling the gate), + `outbound-flexible-phase-budget` = `BestEffort` (footnote 5 — rigid 1/4 connect + + 3/4 first-byte split per §4.3 can fail a request that would have fit within the + total budget), `outbound-batch-slot-isolation` = `BestEffort` (footnote 4 — sequential + cold registration can delay sibling dispatch and a selected buffered response body can delay + the next selection/result observation), + `streamed-upload-deadlines` = `BestEffort` (footnote 2 — no preemption of a + stalled `stream.next().await`), `lazy-streamed-response-passthrough` = + `BestEffort` (footnote 6 — the send-owning entrypoint preserves lazy streaming, but + synchronous source polls/host writes cannot be preempted and close is only a host-acceptance + observation). + This is the exact outbound tuple `Adapter::capability()` returns on Fastly. + +**Streamed-response wrapping.** Even without a guest async timer, the Fastly adapter +wraps streamed response bodies with a **cooperative deadline-aware stream**. Each +`Stream::next` checks `budget.deadline.is_expired()` **both before issuing the +underlying body read and again after it returns** (including the read that +discovers EOF and would otherwise complete the stream cleanly). On expiry at +either check it yields `Err(EdgeError::gateway_timeout_caused(.., budget.cause))` instead of `Ok(chunk)` +or stream-end. This applies to *every* consumer of the wrapped body — +`into_bytes_bounded`, `into_bytes_bounded_until`, `into_response()` proxy +passthrough — so the deadline cannot be bypassed by choosing a non-helper +consumption path or by riding the final blocking read to EOF. Bounded-cooperative +semantics apply: a chunk gap (including the gap before EOF) is bounded by the +host's `between-bytes-timeout` (set to `budget.duration` at dispatch), so per-gap +overshoot ≤ one between-bytes-timeout interval. + +**Limitation, stated explicitly.** `select_handles` blocks the single-threaded guest until one +handle in the active bounded group completes. Every eligible request is issued before selection, +but sequential dispatch does not prove simultaneous host overlap. Once selected, that response's +buffered body drains synchronously before the next selection. There is no busy-polling, but guest +application work cannot run inside either blocking host boundary. + +**Service prerequisite — dynamic backends.** Fastly outbound HTTP to arbitrary hosts +requires **dynamic backends to be enabled on the Fastly service**. That is a +deployment-time service configuration, not adapter code, and the adapter itself cannot +turn it on. EdgeZero handles the gap as: + +1. **Build / deploy:** the static CLI cannot prove this account/service entitlement. The + Fastly adapter therefore reports `outbound-http = BestEffort`, not `Native`; a required + declaration fails closed, while an optional declaration emits the standard degradation + warning. Here `optional` is an explicit application contract that the handler tolerates + an outbound dispatch failure when the deployment prerequisite is absent; it is not a + promise that every selected service can issue the request. An application whose behavior + requires outbound success cannot use this escape hatch and must select a deployment whose + capability check passes. EdgeZero deliberately does not pretend an informational reminder + is admission control. +2. **Runtime:** if dispatch fails because dynamic backends are disabled, the adapter + surfaces `EdgeError::bad_gateway_with_reason(DYNAMIC_BACKENDS_DISABLED_MESSAGE, + BadGatewayReason::Unspecified)` using the exact shared constant defined in the creation + error mapping above. Apps can inspect/log the Rust-side diagnostic before response + conversion; an HTTP response remains the fixed category-only 502 contract. + +Fastly can be promoted to `Native` only when a separately reviewed deploy-time prerequisite +can prove dynamic-backend enablement for the selected service before publication. Phase 6 +still runs a trusted live-host characterization against one enabled disposable service and +one disabled disposable service: the enabled probe must reach arbitrary canonical +destinations, while the disabled probe must produce the exact typed diagnostic above. That +evidence proves adapter behavior but does not make the static gate deployment-aware, so the +current matrix remains `BestEffort`. + + + +#### 4.3.1 Closed custom-entrypoint lifecycle + +Applications that need Fastly-only request metadata, late response finalization, or work after +client transmission must not call the router and `stream_to_client` directly. That bypasses +EdgeZero ingress admission and response-egress accounting. The adapter instead exposes one closed +service operation for an already-received request: + +```rust +impl FastlyService { + pub fn send_request_with_hooks( + self, + request: fastly::Request, + prepare_request: RequestHook, + finalize_response: ResponseHook, + ) -> Result, fastly::Error> + where + RequestHook: FnOnce(&mut fastly::Request, &mut Extensions), + ResponseHook: FnOnce(&mut Response) -> PostSend; +} +``` + +The exact order is normative: + +1. Borrow the received Fastly request for sanitization and native metadata extraction; merge the + scratch extensions into the core request. +2. Convert through EdgeZero, resolve the route, perform ingress admission, and dispatch the + admitted request. +3. Run the routed-response hook before response-egress policy and framing are finalized. Detached + conversion, framing, and admission-refusal responses skip this application hook. +4. Revalidate response framing, begin the egress attempt with the application clock/deadline, and + let the adapter alone commit headers, stream bytes, close or abandon the body, and record one + terminal observation. +5. Return the hook-produced `PostSend` value only after the adapter's strongest transport boundary + has completed. The caller may then perform post-send work. + +Hooks borrow the platform/core values. They cannot take the response, access the egress envelope, +or invoke the platform sender. `run_app` remains the simple generated entrypoint and is built on +this operation. `run_app_with_hooks` is the advanced auto-receiving wrapper. The former +`run_app_with_request_extensions` symbol is removed in the hard cut. + +### 4.4 Spin — `crates/edgezero-adapter-spin` + +- `SpinProxyClient` → `SpinOutboundClient` (stays stateless). +- `start_batch_until` first snapshots `let batch_now = self.clock.now()` once, then runs a + **preflight** per slot: call `validate_for_dispatch(&request)` first; only a request that + passes that portable validator reaches the batch-only checks. Then any request with `Body::Stream` + OR `response_mode = Streamed` is converted to `Err(EdgeError::bad_request(..))` + per §3.1.1 before provider construction. It passes that entry snapshot and the observation cutoff to + every `dispatch_budget` call. Buffered survivors become complete-exchange futures in a + `FuturesUnordered`; each drives the hand-built `wasi:http` request plus + `wasip3::http::client::send`. The adapter polls every eligible future before yielding and + preserves original indices. Concurrency materializes only under the real Spin/WASI executor — + see §5.3 for the test consequence. +- A public single `send` calls `validate_for_dispatch(&req)` exactly once immediately after + its method-entry snapshot, then invokes the same private already-validated flow as batch + survivors. The private prepared-request flow computes the single-send budget via the core helper + `dispatch_budget(req, now, None)` (§3.3.2) before consuming the request into parts, then builds + the hand-built `wasi:http` request (§4.4 — all body kinds, buffered and streamed); race + the **whole** operation + (send **and**, in `Buffered` mode, body collect) against a wasi monotonic-clock + timer for **`budget.deadline.remaining()` at the moment the race starts** — + *not* the snapshot-time `budget.duration`. The two differ by however long + preflight + builder construction took since `batch_now`; using `remaining()` + pins the SDK timer to the absolute batch deadline, matching Axum/CF (§4.1 / + §4.2 step 4). If `remaining()` is `None`, return + `gateway_timeout_caused(.., budget.cause)` without + issuing the request. Single `send` snapshots `now = MonotonicInstant::now()` at its + public method entry before preflight. +- **Streamed responses honour the effective-budget deadline — STREAMED MODE ONLY.** This + is the second phase for `Streamed` mode, where `to_core` returns `Body::Stream` **without + draining** (the single exchange race covered upload + headers only). Wrap that + `Body::Stream` with a per-chunk race against a wasi monotonic-clock timer bounded by + `budget.deadline`; the wrapper yields a `gateway_timeout` (attributed via `budget.cause`, §3.3.2) error chunk past the deadline so + the streamed body honours the deadline end-to-end per §3.3.3. **BUFFERED mode does NOT get + this second race:** there, `to_core` `.await`s the full body drain **inside `exchange`**, + which is already bounded by the single deadline race below — the buffered body is consumed + once, under one race, never twice. (So the design is: one exchange race covering + upload+headers+*buffered*-body; for *streamed* bodies the exchange race stops at headers and + this per-chunk wrapper takes over. No path drains the same body under two races.) +- **ALL uploads — including BUFFERED — use the hand-built `wasi:http` request, not + `spin_sdk::http::send`.** `spin_sdk::http::send`'s `IntoRequest` conversion spawns a + **detached, uncancellable** body pump (§4.4) even for a *buffered* `Body::Once`: a + finite buffer still blocks on **host backpressure** if the origin reads slowly, and + dropping the `send` future does not cancel that pump — so a buffered upload could block + past the deadline. Therefore buffered bodies also go through the hand-built request + + owned in-race pump below (a buffered body is just a one-shot stream of a single + chunk), which lets the timer select a guest-visible 504 for both body kinds without an + unowned pump. It does **not** prove bounded host teardown; `streamed-upload-deadlines` + therefore remains `BestEffort` for Spin (footnote 8). + +- **Streamed request bodies — hand-built `wasi:http` request (SDK 7 / WASI 0.3).** + + > **⚠️ Corrected against verified SDK source.** Earlier drafts of this section + > prescribed a WASI-**0.2** loop — `OutputStream::subscribe()` → `Pollable`, + > `check_write()` for a permitted byte count, then `write()`. **That API does not + > exist in Spin SDK 7.** WASI 0.3 deletes `wasi:io` entirely: there is no + > `pollable`, `output-stream`, `check-write`, or `subscribe` anywhere in `wasip3`'s + > WIT or in spin-sdk 7's Rust, and `wasi:http@0.3.0`'s `request.new` takes + > `contents: option>` (a component-model stream), not an `OutputStream` + > resource. The old algorithm was not merely deprecated — it was **unimplementable**. + > + > **Nor can the upload go through `spin_sdk::http::send`.** Its `IntoRequest` impl + > (`http_into_wasi_request`) hands a streaming `http_body::Body` to a **detached, + > uncancellable** pump (`wit_bindgen::spawn`; the runtime's own docs: *"cannot be + > cancelled or monitored"*). Dropping the `send` future cancels the **subtask** but + > **not the pump** — so a *stalled source* (precisely what this capability exists to + > bound) leaves the pump parked in `poll_frame` forever, and the export executor + > will not exit until spawned tasks drain. That is not a weak timing guarantee; it + > **pins the component task alive indefinitely**. Routing streamed uploads through + > the SDK's high-level `send` would make Spin `BestEffort` *and* leak. + + The adapter therefore **builds the `wasi:http` request by hand** and keeps every + request-body and completion handle inside the raced exchange. This avoids the SDK's + detached pump, but it does **not** create a documented synchronous host-teardown bound; + that limitation is why Spin is `BestEffort` for the two deadline capabilities. All of + this uses public API re-exported by `spin_sdk`: `wasip3`, `wit_stream`, and `wit_future`. + + ```rust + use spin_sdk::wasip3::{http::{types, client}, wit_stream, wit_future}; + use futures::future::{poll_fn, select, Either}; + use std::task::Poll; + + let (mut writer, contents_rx) = wit_stream::new::(); + // Dropping an unwritten FutureWriter writes its DEFAULT asynchronously. The default + // must therefore be failure, never a false clean completion. + let (trailers_tx, trailers_rx) = wit_future::new(|| { + Err(types::ErrorCode::InternalError(Some( + "outbound request body producer dropped before completion".into(), + ))) + }); + + // The pinned wasip3 API has TWO error shapes. Request component setters return + // `Result<(), ()>`; RequestOptions timer setters return + // `Result<(), types::RequestOptionsError>`. Do not route both through one closure. + let bad_request_component = |()| { + EdgeError::internal(anyhow::anyhow!("invalid outbound request component")) + }; + let opts = types::RequestOptions::new(); + // Set every available host transport/response timer from a nonzero nanosecond + // snapshot of the remaining effective budget. Ignoring a setter result or leaving + // first-byte/between-bytes at host defaults can weaken those fallback bounds. Even when + // a setter is accepted, an early WASI timeout variant does not prove which timer caused + // it and remains `BudgetSource::Unspecified`; only absolute expiry attributes the selected + // cause. The outer absolute race below remains authoritative and re-reads `remaining()` + // immediately before `select`. + let Some(options_remaining) = budget.deadline.remaining() else { + return Err(EdgeError::gateway_timeout_caused( + "deadline expired before upload setup", budget.cause)); + }; + // `Deadline` is clamped to seven days, so this conversion cannot overflow u64; + // keep the checked conversion so violating that invariant fails locally and loudly. + let transport_ns = u64::try_from(options_remaining.as_nanos()) + .map_err(|_| EdgeError::internal(anyhow::anyhow!( + "outbound deadline exceeds WASI duration range" + )))? + .max(1); + let mut unsupported_timer_option = false; + let mut apply_timer_option = |option_result| { + match option_result { + Ok(()) => Ok(()), + // WASI explicitly permits a host not to implement an option. The outer + // monotonic race still provides the guest-visible deadline, so this is the + // documented BestEffort fallback rather than a request-fatal 500. + Err(types::RequestOptionsError::NotSupported) => { + unsupported_timer_option = true; + Ok(()) + } + Err(types::RequestOptionsError::Immutable) => Err(EdgeError::internal( + anyhow::anyhow!( + "WASI outbound request options unexpectedly immutable" + ), + )), + Err(types::RequestOptionsError::Other(detail)) => Err(EdgeError::internal( + anyhow::anyhow!( + "WASI outbound request option failed: {detail:?}" + ), + )), + } + }; + apply_timer_option(opts.set_between_bytes_timeout(Some(transport_ns)))?; + apply_timer_option(opts.set_connect_timeout(Some(transport_ns)))?; + apply_timer_option(opts.set_first_byte_timeout(Some(transport_ns)))?; + if unsupported_timer_option { + log::warn!( + "Spin host does not support one or more WASI HTTP timeout options; \ + retaining the outer EdgeZero deadline race" + ); + } + + // Bound as `wasi_req`, NOT `req` — the WASI request must not shadow the OUTBOUND + // request `req`, whose `max_request_body_bytes` / `body` we read below. + let (wasi_req, request_done) = + types::Request::new(headers, Some(contents_rx), trailers_rx, Some(opts)); + wasi_req.set_method(&method).map_err(bad_request_component)?; + wasi_req.set_scheme(scheme.as_ref()).map_err(bad_request_component)?; + wasi_req.set_authority(auth).map_err(bad_request_component)?; + wasi_req.set_path_with_query(pq).map_err(bad_request_component)?; + + // The pump lives INSIDE the raced future — no `wit_bindgen::spawn`. + // `max_req` carries the portable body cap for both body kinds. **`parts` is the + // `OutboundRequestParts` from `req.into_parts()`** — the adapter is a SEPARATE crate and + // cannot read `OutboundRequest`'s PRIVATE fields, so it destructures into the pub-field + // `OutboundRequestParts` first (this also gives it `method`/`uri`/`headers`/`body` used + // to build `wasi_req` above). `max_req` is **`u64`** (the cap type, §3.1.3) and is always + // `parts.max_request_body_bytes`. A buffered `Body::Once` rides this same pump for + // cancellability (§4.4), re-expressed as a one-shot single-chunk stream, and must fail before + // its first write when its known length exceeds the cap. + let max_req: u64 = parts.max_request_body_bytes; + // Force one scheduler boundary after each accepted chunk, even when both the + // source and host writer are continuously ready. Without this, an async `while` + // loop over empty/immediately-ready chunks can monopolize one poll and prevent + // both `client::send` and the outer deadline timer from being polled. + async fn cooperative_yield_once() { + let mut yielded = false; + poll_fn(move |cx| { + if yielded { + Poll::Ready(()) + } else { + yielded = true; + cx.waker().wake_by_ref(); + Poll::Pending + } + }).await + } + + let upload_deadline = budget.deadline; + let upload_cause = budget.cause; + enum PumpCompletion { Complete, ReaderGone } + let pump = async move { + let mut sent: u64 = 0; // u64 accounting vs the u64 cap (`max_req`) — usize is u32 + // on wasm32, so a usize counter could wrap below the cap. + // `source` yields `Option>` + // (the error-type change). The item MUST be unwrapped — a source error is a real + // failure (`bad_gateway` from the wrapped stream, or a `gateway_timeout` chunk), not + // a `Bytes`. Dropping it would silently upload a truncated body. + loop { + if upload_deadline.is_expired() { + return Err(EdgeError::gateway_timeout_caused( + "deadline expired during request upload", upload_cause)); + } + let next = source.next().await; // cancellable + // Absolute post-ready check: timeout outranks a chunk, EOF, or source error + // that becomes ready at the deadline. + if upload_deadline.is_expired() { + return Err(EdgeError::gateway_timeout_caused( + "deadline expired during request upload", upload_cause)); + } + let Some(item) = next else { break }; + let chunk: Bytes = item?; // propagate source error + // pre-append cap check against max_request_body_bytes (u64; no `as`, use try_from) + let chunk_len = u64::try_from(chunk.len()).unwrap_or(u64::MAX); + if sent.checked_add(chunk_len).is_none_or(|total| total > max_req) { + return Err(EdgeError::bad_request("request body exceeded max_request_body_bytes")); + } + // checked: bare `+=` trips `clippy::arithmetic_side_effects` (denied). + sent = sent.saturating_add(chunk_len); + let unwritten = writer.write_all(chunk.to_vec()).await; // backpressure; cancellable + if upload_deadline.is_expired() { + return Err(EdgeError::gateway_timeout_caused( + "deadline expired during request upload", upload_cause)); + } + if !unwritten.is_empty() { + // READER GONE is NOT an error. The origin stopped reading — almost always + // because it is about to send (or already sent) an EARLY FINAL response + // (413 Payload Too Large, 401, a redirect, …). Returning an error here + // would DISCARD that valid response and report 502 instead, violating + // "a completed exchange, including non-2xx, is Ok". So end the pump + // cleanly and let `send` surface the response. + drop(writer); + let _ = trailers_tx.write(Ok(None)).await; + return Ok::(PumpCompletion::ReaderGone); + } + cooperative_yield_once().await; + } + drop(writer); // EOF + match trailers_tx.write(Ok(None)).await { // completion signal + Ok(()) => Ok::(PumpCompletion::Complete), + // The host dropped the completion reader. Treat this exactly like an origin + // that stopped consuming the request: preserve the response path and never + // poll `request_done`. + Err(_future_write_error) => Ok(PumpCompletion::ReaderGone), + } + }; + + // `run_exchange` is an ORDERED state machine, NOT `join!`. `join!` would delay an + // already-available response behind a stalled source and could let a moot upload + // override an early final response. `request_done` is the request-transmission result + // returned by Request::new; it is load-bearing and must not be discarded. + // `client::send` resolves to `Result` — the + // LOW-LEVEL WASI response, NOT core `OutboundResponse`. Each success arm must (a) map the + // transport `ErrorCode` via `map_spin_send_err`, THEN (b) convert to core via + // **`to_core`, which is `async`** — `.and_then(to_core)` does NOT compile because WASI + // body collection is asynchronous. Before request fields move into the WASI request and + // upload pump, `req.into_parts()` also produces an owned `SpinResponsePolicy` containing + // `request_method`, `response_mode`, and all five response-resource settings + // (`max_brotli_window_bits`, `max_chunk_bytes`, encoded bytes, header bytes, and header + // count). `to_core` wraps status + `Fields`→multi-value + // `HeaderMap` synchronously, then: **Streamed mode** wraps the body as `Body::Stream` + // and returns immediately (no drain); **Buffered mode** `.await`s the FULL body drain + + // incremental decompress + `max_response_bytes` cap + EOF **inside `exchange`**, so the + // whole collection is bounded by the outer deadline race below (a slow buffered body + // cannot outlive the deadline). This is the hand-rolled equivalent of the SDK's + // `Response::from_response`, done here because we bypass the high-level `send` (§4.4). + // Header/resource enforcement and method-aware body disposition consume the policy; + // no value is reconstructed from the native response. + // `to_core: async fn(wasip3::http::types::Response, SpinResponsePolicy) + // -> Result`. + // Upload completion protocol (the normative transition table follows this snippet): + // - `Uploading` polls one pump step first, then `send`. A ready source/cap/deadline + // failure therefore wins over a send result ready in the same poll. + // - `Complete` transitions to `AwaitingRequestDone`; keep polling `send` for host + // progress, but retain any ready result without accepting it until `request_done` + // succeeds. + // - `ReaderGone` transitions to `ReaderGone`; retain but never poll `request_done`, and + // wait for the early response/error from `send`. + // - if `send` is ready after the pump returned Pending, it is authoritative. Drop the + // pump and `request_done` before response conversion. + // - source/cap/deadline failure does not write success to trailers. Dropping + // `trailers_tx` writes its default Err while the original EdgeError is returned. + // `client::send`, `pump`, and `request_done` are owned by one exchange; no detached task + // exists. Both WASI result sites pass through + // `map_spin_send_err(err, budget.deadline, budget.cause)`. + let exchange = run_exchange( + client::send(wasi_req), + pump, + request_done, + budget.deadline, + budget.cause, + move |native_response| to_core(native_response, response_policy), + ); + + // Response completion is a separate two-way WASI protocol, not just a trailers await. + // Create a caller-result future whose default is Err(InternalError), pass its reader to + // `Response::consume_body`, and retain its writer together with the returned body stream + // and trailers future. The body itself is a component `stream`, not an `ErrorCode` + // result. Its clean EOF/termination is handled by the stream bridge. After body EOF, await + // the response trailers/completion future; an `ErrorCode` from that future is the THIRD + // `map_spin_send_err(err, budget.deadline, budget.cause)` site, alongside `client::send` + // and `request_done`, so response-side timeout codes remain 504 rather than collapsing to + // 502. Only clean trailers permit writing `Ok(())` to the caller-result writer. That write + // returns `Result<(), FutureWriteError<_>>`, not `ErrorCode`: rejection means the host + // dropped its result reader. While a consumer is still waiting, classify that failed + // completion handshake as protocol 502; during wrapper drop, perform cleanup only because + // no consumer remains to receive an error chunk. The absolute deadline still wins and produces the + // attributed gateway_timeout (504). A local deadline/decode/cap failure or early consumer + // drop leaves or explicitly writes Err, never a false clean completion. Buffered conversion + // completes this protocol inside `exchange`; Streamed conversion stores all three handles + // in the `Body::Stream` wrapper until terminal EOF or drop. §5.4 pins clean EOF, + // truncated-response, and early-drop cases. + + // `remaining` is Option, NOT Result — `?` here would not compile in a + // Result-returning fn. An already-expired budget must become gateway_timeout + // explicitly, matching the "expiry before dispatch" contract above. + let Some(race_remaining) = budget.deadline.remaining() else { + // Attribute via the budget's cause — every budget timeout is caused (§3.3.2). + return Err(EdgeError::gateway_timeout_caused( + "deadline expired before upload dispatch", budget.cause)); + }; + + match select(pin!(exchange), pin!(spin_sdk::time::sleep(race_remaining))).await { + // RECHECK the absolute deadline for BOTH arms (deadline-wins, §3.4.1). `select` + // polls `exchange` first, so on *simultaneous* readiness (exchange completes exactly + // as the timer fires) it would return a result produced at/after the deadline — + // whether that result is `Ok` OR a decode/transport `Err`. An expired deadline + // outranks both, so a completed-at-deadline exchange becomes a 504 timeout, never a + // success and never a simultaneously-ready 502. A §5.4 boundary test drives exactly- + // simultaneous readiness (success AND failure) and asserts 504 for each. + Either::Left((resp, _)) => { + if budget.deadline.is_expired() { + Err(EdgeError::gateway_timeout_caused("deadline expired during upload", budget.cause)) + } else { + resp // Ok(within deadline) or a real Err (e.g. bad_gateway) passes through + } + } + Either::Right(_) => Err(EdgeError::gateway_timeout_caused("deadline expired during upload", budget.cause)), + } + ``` + + **`run_exchange` state machine (normative).** The helper owns pinned `send`, `pump`, + and `request_done` futures. “Poll first” is an explicit biased poll order, not merely + source-code order passed to a fairness-rotating selector. + + | State | Polling and transition | Request-side ownership boundary | + | --- | --- | --- | + | `Uploading` | Poll `pump` first for one cooperative step, then poll `send`. `pump = Err(e)` returns `e`; this includes source, cap, and upload-deadline failures. `pump = Complete` transitions to `AwaitingRequestDone` without accepting a simultaneous send result. `pump = ReaderGone` transitions to `ReaderGone`. A send result is authoritative only when that pump poll returned `Pending`. | All three futures remain owned. On authoritative send success/error, drop `pump` and `request_done` before mapping/converting the send result. On pump error, drop `send` and `request_done`; the request trailers writer defaults to failure. | + | `AwaitingRequestDone` | Poll `request_done` first, then poll `send` when its result has not already been retained. A ready send response/error is stored, not returned, so polling can continue to drive host progress. A `request_done` error is mapped with `map_spin_send_err(err, budget.deadline, budget.cause)` and wins over any stored send result. On `request_done` success, consume/drop it, then use the stored send result or continue polling `send`; map its error with the same three arguments or run async `to_core` on success. Only a successful request-trailers completion write enters this state; `FutureWriteError` transitions to `ReaderGone`. | `send` and any ready low-level response remain owned while transmission completion is established. No response conversion or caller-visible send error occurs before `request_done` succeeds. | + | `ReaderGone` | Do not poll `request_done`; poll `send` until its response/error is ready. That result is authoritative because the peer ended request consumption. | Retain `request_done` only until `send` resolves to response headers or error, then drop it **before** `to_core`. It is never stored in the response body wrapper. | + + The cooperative pump boundary after every accepted chunk is part of this state machine: + an immediately-ready or empty-chunk source cannot monopolize one poll, so `send` and the + outer timer are polled. A pump failure already ready in the same poll wins; a later source + failure after an authoritative send result is moot. For `Streamed` responses, the returned + `Body::Stream` owns only response-side body/trailer/caller-result handles described below; + no request-side writer, pump, or `request_done` handle crosses the header-return boundary. + + **Response decoder fairness.** The request pump's one-step boundary does not protect a + decoder that consumes continuously ready raw input without producing output, or decoded + output that remains continuously ready. Wrap both raw decoder input and decoded output + with independent finite, nonzero ready-item quotas no greater than 64; empty items count. + At quota exhaustion, preserve the counters across stream calls, self-wake, and return + `Poll::Pending` so `run_exchange`/the outer monotonic timer and sibling work are polled + before the next item. This self-wake is sufficient on Spin because the WASI monotonic + clock is not frozen by guest CPU work; unlike Cloudflare, no claim depends on a JS host + event advancing `performance.now()`. Recheck the original absolute deadline before + accepting the next item and before terminal EOF/error/cap decisions. Never reanchor it. + The quotas are scheduling guarantees, not byte or allocation caps. + + **Why this remains `BestEffort`.** Before response headers, dropping `client::send` + requests cancellation of its canonical-ABI subtask. After headers, that subtask is + complete; cancellation instead drops/cancels the response stream and trailers handles + and resolves the response-result protocol with `Err`. These are the correct cooperative + Component Model operations, but neither establishes synchronous host teardown or a + one-monotonic-tick bound. In addition, dropping an unwritten `FutureWriter` schedules its + default write rather than completing synchronously. The timer can select a 504 result, + but host work may outlive that selection, so both deadline capabilities stay + `BestEffort` until runtime evidence proves a finite teardown bound. + + **Completion signalling.** Clean request EOF or reader-gone explicitly writes + `Ok(None)` to the trailers future; every other drop defaults to `Err`. The separate + `request_done` result is awaited on full upload and retained on reader-gone as described + above. The `max_request_body_bytes` cap (default 8 MiB) is enforced with pre-append + checked accounting inside the pump loop, `bad_request` on overflow. + + **`RequestOptions` do not bound the upload.** WASI 0.3 keeps `set-connect-timeout` / + `set-first-byte-timeout` / `set-between-bytes-timeout`, but these are transport / + response-side only — the WIT states they are *"separate from any the user may use to + bound an asynchronous call."* They are **not** a substitute for the race above. Attempt + to set all three to the ceiled remaining effective budget and classify every + `RequestOptionsError`: `NotSupported` logs one degradation warning and proceeds under + the authoritative outer race; `Immutable` and `Other(..)` are internal setup failures. + When all three are accepted, the host's connect, first-byte, and between-bytes phases use + the configured budget. Other host timeout classes such as DNS and connection-write have + no corresponding WASI setter and may still fire independently. When one is unsupported, + the already-declared Spin BestEffort deadline limitation also covers the unsupported + phase's host default; the guest still observes the outer race if EdgeZero's absolute + deadline wins. + + **This applies to STREAMED response mode ONLY.** In **Buffered** mode `to_core` already + drained the full response body **inside** `exchange` (bounded by the outer deadline race), + so there is **no** second response race — the exchange result IS the finished response. + In **Streamed** mode, `to_core` returned `Body::Stream` without draining, so after the + exchange completes the adapter re-reads `budget.deadline.remaining()`; if `None`, the + streamed response is dropped and the slot returns `gateway_timeout` — no response wait; + otherwise the remaining duration bounds the per-chunk streamed-body wrapper race (the + §3.3.3 wrapper), so upload time is included in the batch budget rather than added on top. + (An earlier draft prescribed this second race unconditionally, contradicting the buffered + single-race flow — corrected to Streamed-only here.) +- Existing gzip/deflate/br decompression is kept; the independent decoded-output cap is enforced + incrementally for identity and EdgeZero-decoded output (§3.4.1), while the final + Buffered cap remains separate. `Streamed` mode wraps the response body as `Body::Stream`. +- **Errors — `map_spin_send_err(err, deadline, cause)` classifies the WASI `ErrorCode`, + mirroring Fastly's deadline-aware `classify` policy (§4.3) but without inventing + provenance.** At the mapping instant it checks `deadline.is_expired()` first. If true, + timeout precedence yields `gateway_timeout_caused(.., cause)` for **any** simultaneous + SDK failure. If false, one of WASI's five provider timeout variants yields bare + `gateway_timeout(..)` and therefore `BudgetSource::Unspecified`: DNS and + connection-write timeouts cannot be configured through `RequestOptions`, and no host + timeout that fires early proves which EdgeZero input selected it. The caller passes all + three values and never stamps `budget.cause` onto a provider event solely because its + variant contains `Timeout`. The `send` future (`wasip3::http::client::send` for all + body kinds — buffered and streamed both go through the hand-built request, §4.4) + fails with a `wasi:http` `ErrorCode`. **The match lists EVERY known variant explicitly and + has NO `_` arm** — so a future SDK/WIT variant **breaks the build** and *forces* the + implementer to classify it (fail-loud, the point of a security/behaviour-relevant + classifier). This resolves the earlier waffle ("keep `_` only if reachable, otherwise + drop"): with `-D warnings`, an `_` arm over an *exhaustive* enum is an + `unreachable_patterns` error anyway, so the no-`_` exhaustive match is the only form that + passes. The pinned `wasip3 = 0.6.0` generated enum is verified exhaustive and is not + `#[non_exhaustive]`; dependency upgrades must update this match and its table in the same + change. Mapping: + - **Timeout variants — ALL FIVE** (verified against wasip3): `DnsTimeout`, + `ConnectionTimeout`, `ConnectionReadTimeout`, `ConnectionWriteTimeout`, + `HttpResponseTimeout` → **`gateway_timeout` (504)**. At/after the absolute deadline the + result carries `budget.cause`; before it, the result carries + `BudgetSource::Unspecified`. **None may fall through to the generic 502.** An exhaustive + classifier unit test against the pinned SDK exercises every timeout before and after + deadline expiry. + - DNS resolution failure, connection refused, TLS/certificate establishment errors, + destination-not-found/unavailable before a response head → + **`bad_gateway_with_reason(.., Unreachable)` (502)**. Connection termination/read/write + failure after connection progress uses **`Transport`**. + - Caller-controlled request-policy/size failures → **`bad_request` (400)**: + `HttpRequestDenied`, `HttpRequestBodySize`, `HttpRequestUriTooLong`, + `HttpRequestHeaderSectionSize`, and `HttpRequestHeaderSize`. These can be caused by + a requested target or limits exposed through the outbound API; reporting 500 would + misclassify caller input as an EdgeZero invariant failure. + - Adapter/core invariant failures → **`internal` (500)**: + `HttpRequestLengthRequired` (the adapter owns framing), + `HttpRequestMethodInvalid` / `HttpRequestUriInvalid` (core preflight must reject + these), request-trailer size errors (EdgeZero emits no request trailers), + and `ConfigurationError` (the adapter constructed the request options). + - Upstream response framing/protocol variants → + **`bad_gateway_with_reason(.., Protocol)` (502)**. Shared decoder/JSON failures use + `Decode(Gzip)` / `Decode(Brotli)` / `Decode(Json)`; they do not enter this WASI + classifier. + - Host/runtime catch-all `InternalError` → + **`bad_gateway_with_reason(.., Unspecified)` (502)**. WASI defines this as the fallback + when no specific code fits; it is not evidence that an EdgeZero invariant failed. The + locally generated default `InternalError` used by request/response + completion writers is not reclassified through this arm: source/cap/deadline failures + preserve and return their original typed `EdgeError` while the default only tells the + host that completion was not clean. + - **Future/unknown variant handling.** **Every variant in the pinned + `wasi:http@0.3.0` `ErrorCode` is explicitly named** in one of four outcome buckets (504 + timeout / 502 upstream+transport+protocol+host catch-all / 400 caller-controlled / + 500 locally-invalid), so the match is + exhaustive over the pinned enum with **NO `_`** — a future WIT variant then *breaks the + build* and forces classification (the intended fail-loud behaviour). There is no `_` + arm for the pinned exhaustive enum. If a future binding changes the enum to + `#[non_exhaustive]`, that dependency upgrade must explicitly revise this contract rather + than silently adding a wildcard. The pinned + enum has ~30+ variants — `DNS-error`, `destination-{not-found,unavailable,IP-prohibited, + IP-unroutable}`, `connection-{refused,terminated,timeout,read-timeout,write-timeout, + limit-reached}`, the `TLS-*` errors, the `HTTP-request-*` / `HTTP-response-*` size/format + errors, `HTTP-response-incomplete`, `HTTP-{upgrade-failed,protocol-error}`, + `loop-detected`, `configuration-error`, `internal-error`, … Assignment rule: the five + timeouts → 504; the explicitly named caller-controlled request variants → 400; the + explicitly named adapter/core invariants → 500; **everything else + upstream/transport/protocol** → 502. The **exhaustive classifier + test enumerates every pinned variant** and asserts its bucket, so an added SDK variant + that we forgot to map trips the test (via a `#[deny(unreachable_patterns)]`-style + round-trip or an explicit variant list), not silently defaults. + - The separate wasi-timer we race the exchange against (§4.4) also yields + `gateway_timeout` on expiry. **request**-body over-cap → `bad_request` (400); + **response**-body over-cap (decompressed) → `response_too_large` (distinct kind, 502, §3.4.1). Any + completed exchange (incl. non-2xx) → `Ok`. +- Spin requires `allowed_outbound_hosts`; the adapter renders it from + `[capabilities.outbound].hosts` per §3.5.4 when generating `spin.toml`. +- `capability()` per §3.5.2 reports the exact twelve outbound-cell tuple (additional + non-outbound capability variants are owned by their respective specifications): + `outbound-http` = `Native`, `outbound-header-fidelity` = `Native`, + `outbound-complete-resource-accounting` = `Unsupported`, + `outbound-deadlines` = `BestEffort` (footnote 8), + `outbound-flexible-phase-budget` = `BestEffort` (footnote 5: request-option setters may + be unsupported and leave earlier host defaults), `outbound-batch-slot-isolation` = `Native`, + `streamed-upload-deadlines` = `BestEffort` (footnote 8), and + `lazy-streamed-response-passthrough` = `BestEffort` (footnote 7). +- **Response-out passthrough is lazy and coordinator-owned.** Spin returns the raw + `SpinResponse`; one spawned coordinator owns the WASI response, body, and result writers through + host handoff. It races pending source/write/result operations against the absolute response-write + deadline, accounts accepted prefixes, and destroys the response on failure. Host teardown and + end-client completion remain unproved, so the response-egress cells and this lazy-passthrough + cell remain `BestEffort`. The hand-built **outbound streamed-upload** path above remains + independently `BestEffort` per footnote 8. + +## 5. Test plan + +Tests are split by what they can actually prove. A core mock proves portable semantics; an +adapter seam proves conversion/classification; only a live runtime can prove host behavior. + +### 5.1 Tier 1 — core contract, no platform runtime + +Colocated `edgezero-core` tests use `MockOutboundClient`, scripted streams, and injected +monotonic instants. They must not claim platform cancellation or wire behavior. + +Required coverage: + +- Builder defaults and overrides, including buffered vs streamed mode, request and response + caps, timeout, absolute deadline, all HTTP methods, and mutually exclusive body/json + setters. +- Body constructors prove the non-overlapping error contract: `from_stream` preserves + typed `bad_gateway`, `gateway_timeout`, and `response_too_large` chunks exactly; + `from_external_stream` maps arbitrary source errors to `internal`; `stream` remains + infallible; `Bytes` and the existing buffered input types convert to `Body::Once`. +- `OutboundRequest::from_request` preserves the supplied body and exact method, including + DELETE and HEAD. Repeated normalization is idempotent and does not rewrite the method. +- URI preflight rejects unsupported schemes, missing authority, userinfo, fragments, and + invalid method/URI combinations before any adapter work. WHATWG canonicalization covers + dot segments, percent-encoded delimiters, numeric IPv4 aliases, IDNA, empty paths, and + query characters in addition to DNS names, default/non-default ports, bracketed IPv6, + and backend-target/Host/host-only/SNI/certificate values. App-visible and + adapter-visible serializations are equal. Empty userinfo is rejected through both raw-string + and typed-`Uri` constructors before WHATWG normalization can erase it. +- Request normalization strips standard hop-by-hop fields plus every field nominated by + each visible `Connection` value, case-insensitively and across repeated field lines. + Empty or invalid nomination tokens fail the whole request as 400; valid prefixes are not + partially honored. Native-fidelity raw non-UTF-8 `Connection` is also a 400. + Cloudflare's portable baseline is tested over the visible normalized strings. The fixed + request and response strip sets include `Proxy-Connection`. +- Valid non-ASCII UTF-8 custom header values survive on Native-fidelity adapters; + forbidden controls reject at construction, and invalid UTF-8 introduced through + `headers_mut` is dropped except for the fail-closed `Connection` case. +- Response normalization performs the same hop-by-hop and nomination stripping before + `Content-Encoding`/`Content-Length` interpretation. `Connection: content-encoding` and + `Connection: content-length` cannot influence decode/cap logic. Every adapter classifies + malformed visible nomination syntax as 502; Native adapters also classify malformed + raw/non-UTF-8 `Connection` as 502. Cloudflare is tested only to its BestEffort raw-header + fidelity contract. A non-UTF-8 `Content-Length` remains visible until strict framing parsing + and is therefore a protocol 502 rather than a silently absent length. +- `OutboundResponse` construction and `into_parts` round-trip request method, status, + normalized headers, body, and effective Content-Type provenance. The six-argument constructor + requires the paired clock and explicit original evidence; the five-tuple never reconstructs + provenance from normalized headers. Adapters apply bodyless handling for HEAD, every 1xx, 204, + and 304 before content decoding or streaming. For 205, clean EOF completes normally while + a positive `Content-Length` or non-empty body bytes abort the remaining native body; + a visible empty stream item also aborts conservatively without a second read. Byte-read + SDKs use their EOF result and do not attempt to reconstruct invisible frame boundaries. + Spin's caller-result protocol never reports false clean completion. Output bodies are + empty and framing metadata is normalized according to §3.4.1. `into_response` re-runs + normalization idempotently rather than owning the first pass. +- `normalize_response_headers` returns `ResponseHeadNormalization` with independently asserted + body disposition and pre-filter provenance. Cover missing, one textual value, valid non-ASCII + UTF-8, identical/conflicting duplicates, a textual value plus invalid UTF-8, a lone invalid + UTF-8 value, and `Connection: content-type` removal. Assert exact retained bytes, no trimming, + no MIME validation, and no comma splitting: invalid MIME text, a known single comma-containing + value, and a quoted comma can all remain `SingleTextUnchanged`. Dropping bad values never + upgrades original multiplicity. No body polling or copying is needed for this metadata. +- Provenance ownership tests cover the accessor, five-tuple decomposition, and converted + `Response` extension. Merely calling `headers_mut()`, an unrelated-field edit, Content-Type + replacement, and restoring the original value all invalidate a formerly eligible result as + `Ineligible(Modified)` without mutating private original evidence. Originally ineligible + reasons and `Unknown` remain unchanged despite a now-valid normalized map. The final + normalization cannot upgrade them. The converted extension is historical evidence only; + later ordinary `http::Response` edits cannot be certified by it. +- Content decoding covers gzip, br, stacked/repeated encodings, mixed case, unknown + encodings, and a repeated field set with one malformed raw value. Native-fidelity + adapters preserve/pass through a malformed encoding set as specified; Cloudflare asserts + only visible-value behavior. +- Completion tests cover two concatenated gzip members in one chunk and split across + chunks, empty members, cumulative cap overflow in a later member, corrupt/truncated + later members, and trailing garbage. Brotli trailing bytes/concatenated streams reject, + including read-ahead bytes already held by the recovered buffered reader. For both + codecs, a complete encoded payload followed by a typed source error preserves that + error; a payload followed by a pending native EOF cannot report successful completion. + Limiter, decoder, native-EOF, and framing errors release their source/native state before the + terminal `Err` item becomes visible, without requiring a second poll or wrapper drop. +- Response resource tests cover exact-limit and first-byte/entry-over boundaries for raw + encoded body bytes, decoded bytes, header bytes, and header count. They exercise repeated + fields, checked-`u64` overflow/conversion, passthrough encodings, cleanup, deadline + precedence, and exact `ResponseLimitReason` preservation. Before any body poll, a + payload-bearing identity `Content-Length` is compared with both encoded and decoded caps; + a compressed/passthrough-coded length is compared only with the encoded cap. Tests cover + exact/over values, malformed/comma/conflicting lengths, no-poll rejection, and bodyless/205 + exclusions after nomination stripping. Brotli tests cover every + standard WBITS encoding plus the large-window extension, fragmented/empty prefixes, + truncation, replay fidelity, and zero decoder-constructor calls when the window rejects. +- `OutboundRequest::into_parts` / `from_parts` round-trip every budget and response-resource + setting without replacing configured values with defaults. Adapter conversion tests set + all controls together and prove the post-dispatch response path receives each one; the + Fastly batch test inspects the pending response policy after `OutboundRequest` is consumed. +- Rechunk tests cover disabled behavior, an oversized source chunk, bounded emitted items, + lazy source pulls, error order, early drop, and deadline/fairness behavior. They explicitly + retain the source-allocation term rather than claiming the wrapper caps RSS. +- The decoder error carrier restores the exact original `EdgeError` variant/status/kind. + Raw decompressor failures become `bad_gateway` reason `Decode(Gzip)` or + `Decode(Brotli)`; over-cap becomes `response_too_large`; no path stringifies a typed + error. +- The deadline wrapper around **decoded output** covers: compressed input that stalls before + first decoded output, stalls after output, stalls at terminal EOF/trailer validation, and + yields an error concurrently with deadline expiry. It checks every yield and terminal + result; deadline/cancellation wins the simultaneous terminal race. +- Pre-append accounting rejects a chunk that would exceed either request or decompressed + response limits without first allocating/appending it. Exact-limit success and + `u64`/platform-size conversion boundaries are covered. +- `dispatch_budget` covers timeout-only, deadline-only, both orders, equal bounds, + already-expired deadline, synthetic default, overflow clamp, and one shared `now` for + `send_all_until`. `BudgetSource` asserts only selected-input provenance, never timer phase + or retry/abandonment semantics. +- Every timeout path carries `budget.cause`; bare inner/caller deadlines use + `Unspecified`. JSON does not serialize the cause. +- Every `BadGateway` path carries the narrowest available `BadGatewayReason`; the bare + constructor uses `Unspecified` only when no producer-specific origin is known. + `ResponseTooLarge` also maps to 502 but carries `ResponseLimitReason`, not + `BadGatewayReason`. Tests cover every reason and prove the JSON wire shape does not expose + `reason` for either variant. +- `HttpClient::start_batch_until` delegates the complete input and observation cutoff to its injected client. + `HttpClient::send_all_until` collects that same driver into index-aligned `Option` slots, + including empty input, mixed success/error results, and slots unresolved at the observation cutoff. Tests assert + `Completed` for complete and empty batches, `Cutoff` for explicit observation-cutoff termination, and + `OutboundBatchFailure` carrying batch-level `Internal` plus previously collected slots for an + explicit driver failure, premature EOF, or duplicate/out-of-range index. Successful-result + `None` occurs only with `Cutoff`; failure-result `None` marks a slot not collected before the + separate typed failure. These core/mock tests establish handle delegation and collection only; + adapter batch behavior is required separately by the Tier 2 batch row. +- Shared preflight tests pin the GET/HEAD streamed-body diagnostic. Tier 2 verifies each + adapter invokes that validator before its batch-only streamed-body rejection. +- Buffered and streamed response drains enforce caps and deadlines, recheck after EOF, and + preserve typed 502/504/response-too-large outcomes. +- Manifest parsing rejects unknown/duplicate capability names, required/optional overlap, + misplaced nested `capabilities` tables at every array/table depth, and every mixed-case + spelling of the reserved key outside the exact lowercase top-level location. Runtime TOML + and baked JSON run the same reserved-key policy before typed deserialization, then the same + validation/finalization path. Invalid host grammar and non-canonicalizable ports/authorities + also fail. Canonical rendering preserves the scheme of every atomic wildcard: + the absent https-only default renders exactly `https://*:*`, while explicit bare `*` + renders separate http and https entries. Baked JSON runs the same validate/finalize + pipeline and distinguishes Absent from Malformed. + +### 5.2 Tier 2 — adapter contracts, no external network + +Each adapter crate tests its shipped conversion and classification seams. + +| Surface | Required assertions | +| --- | --- | +| Capability metadata | All four adapters return the exact twelve **outbound** cells in §3.5.2, including completion order, cancellation, slot isolation, cache bypass, authority override, complete resource accounting = Unsupported everywhere, Fastly outbound HTTP = BestEffort, Cloudflare header fidelity = BestEffort, and Spin deadline/upload/flexible-phase-budget = BestEffort. Tests do not assert that the shared enum has only twelve global variants; non-outbound cells belong to their own specs. A fixture adapter that relies on the trait default returns Unsupported. Because each adapter crate matches core's non-exhaustive `Capability` enum across a crate boundary, normal adapter compilation requires a wildcard; review asserts that its result is `_ => Unsupported`. A hypothetical future variant is a structural fail-closed invariant, not a value current Rust code can safely construct at runtime. | +| Request conversion | Method/body/headers/full canonical URI survive conversion; normalized hop-by-hop fields cannot reappear; buffered and streamed request caps map to 400. Dot-segment/percent/numeric-host/IDNA cases use the exact core serialization rather than adapter reconstruction. Typed `EdgeError` request chunks survive adapter conversion; in-tree paths never route them through `from_external_stream`. | +| Deadline anchoring and clock propagation | Every standard adapter installs its outbound client with the exact `App::monotonic_clock()` clone used by ingress; every low-level response constructor requires its caller to supply that clock explicitly. Every adapter captures one stored-clock snapshot as the first operation in `send`/`start_batch_until`, before normalization, preflight, or builder work. An injected clock advances during preparation and proves that elapsed time consumes the valid request's original budget; no path re-anchors or bypasses the client clock. Preflight slot elapsed, provider-error precedence, post-ready expiry, deferred upload/response streams, returned `OutboundResponse` bounded-until collection, and response-egress coordinator checks use that same handle. A backwards clock cannot enlarge `budget.duration`; a backwards terminal sample produces zero elapsed plus an internal invariant outcome. Invalid-request precedence remains the shared validator's result because validation still runs before budget selection. | +| `start_batch_until` / `send_all_until` on every adapter | Run the production batch orchestration with injected transport/clock seams for Axum, Cloudflare, Fastly, and Spin. Empty input returns empty without dispatch; mixed valid/invalid slots retain exact input indices; preflight failures dispatch no work for that slot and never poll rejected source streams; GET/HEAD body errors precede batch-only errors; transport errors, cap failures, timeouts, and non-2xx responses preserve sibling outcomes without cancelling siblings. Assert every emitted slot carries its own elapsed time from the one method-entry snapshot through that slot's terminal point; advance the injected clock during preflight to prove that time is included. A same-tick terminal result may legitimately report zero. A slow sibling's later completion must not overwrite an earlier slot's elapsed value. Valid one-slot buffered batches match single-send outcome semantics while elapsed is asserted independently. Script reverse completion order on concurrent adapters; assert every eligible exchange is polled before a stalled sibling finishes. Assert cancellation retains already emitted items, reports unresolved indices, and does not consume a ready item when a `next()` future is dropped. Fastly tests dispatch-before-selection, selected-handle identity reassociation, all three reassociation failure diagnostics, exact error-to-driver-event propagation with previously collected slots, bounded groups, and immediate elapsed sampling after each preflight/dispatch/selected-body terminal result while retaining its documented BestEffort timing caveats. Backwards injected time produces zero plus an internal outcome, distinguishable from legitimate zero by outcome. | +| Streamed fan-out usage | On Axum/Cloudflare/Spin, join per-request tasks containing both `send` and body consumption. Script a fast response whose body can finish before its deadline while a sibling's headers remain pending beyond it. Assert the fast body is consumed and succeeds before those sibling headers arrive. Joining only sends and delaying all body consumption must fail this regression. Fastly is excluded from this non-portable usage pattern. | +| Response conversion | Every adapter enforces adapter-visible upstream header limits before normalization, including fields normalization later strips, accounts the synthetic proxy marker after normalization, then applies body/decode caps, calls the shared four-state content-encoding classifier, passes the originating method and retained clock into `OutboundResponse`, and settles native body handles for framing-bodyless and 205 responses; repeated `Set-Cookie` survives. HEAD/304 malformed, conflicting, comma-list, and `u64`-overflow `Content-Length` fail as protocol 502 before body polling while valid representation lengths are retained; 1xx/204 remove the field. Effective identity includes absent or exactly one bare `identity`, and its decoded over-cap `Content-Length` rejects before body polling. Encoded, decoded, header-byte/count, and Brotli-window failures preserve typed reasons and cleanup. | +| Original Content-Type provenance | Drive actual response-conversion paths for buffered and streamed payloads plus HEAD/204/205/304. Axum/Fastly/Spin carry original normalization evidence through all constructor branches. Cover missing, textual, non-ASCII UTF-8, identical/conflicting duplicates, textual plus non-UTF-8, lone non-UTF-8, invalid MIME text, a known single comma-containing value, quoted commas, and connection-nominated removal; assert exact retained bytes and original reasons despite lossy filtering. An Axum local raw-TCP response with one textual and one non-UTF-8 Content-Type must retain `MultipleValues` even when the filtered map has one value. Cloudflare always supplies `Unknown`, even for absent/single/merged values, because its Fetch iterator cannot recover original bytes or field boundaries. Mutable header access never upgrades evidence. These tests establish conversion behavior, not MIME validity, debug safety, or a new capability. | +| 205 settlement | Test declared-body immediate abort, one in-budget clean EOF, observed non-empty bytes, read failure, and deadline precedence. Where the SDK exposes empty items, one empty item aborts without another read; on Fastly a non-empty read buffer returning zero is EOF. Cloudflare additionally tests null-body host suppression with absent/zero and positive visible lengths without claiming hidden-byte visibility. Assert native-handle cleanup and Spin completion signalling using observable results, never synthetic wire-frame visibility. | +| Header fidelity | Axum/Fastly/Spin preserve repeated outbound request field lines and exercise raw malformed response nomination/encoding lines. Cloudflare tests request list semantics and the visible response-string baseline without asserting unavailable octets/line boundaries. Cloudflare encoded passthrough uses `EncodeBody::Manual`; its streamed downstream `Content-Length` is asserted only to the documented BestEffort scope. | +| Decoder integration | Each adapter uses the shared decoder/carrier/deadline pipeline; gzip/deflate/br stalls before output, midstream, and after codec completion but before native EOF produce attributed 504 rather than hanging or degrading to 502/500. Exercise both Buffered and Streamed modes, multi-member gzip/cumulative caps, trailing data, and late typed source/completion errors. No guard disarm or Spin caller-result success occurs solely because a decoder reached its end marker. | +| Response-egress fallback | Every adapter uses the same live attempt for a bounded precommit fallback, preserves the original cause and fallback metadata, and never replaces a response after commit. | +| Axum response-out scheduling | A connection-local `LocalSet` drives the private non-`Send` Hyper body without a blocking bridge. Raw-socket tests prove lazy frames, a zero-read client's independent deadline close, disconnect/drop handling, and deterministic collateral HTTP/1 pipeline attribution. | +| Lazy response-out | Every adapter's local pump emits the first chunk before source EOF. Axum and Cloudflare have target-appropriate proof and report Native. Fastly and Spin remain BestEffort pending the production-runtime evidence in footnotes 6/7. | +| Axum timeout | Fake-time/transport seams prove the remaining budget is armed once and timeout errors preserve provenance. | +| Cloudflare raw fetch | The constructed Request has `RequestRedirect::Manual`; separately, the actual final fetch initializer contains `encodeResponseBody: "manual"` and the guard's abort signal together without resetting redirect. Property-set exception or false returns `internal` without dispatch. The helper uses the checked response conversion and absolute-deadline precedence from §4.2. Native tests assert the exact canonical string supplied to the Web request; they do not call it the final wire URL. WASM compilation verifies the worker 0.8.5 re-exported bindings; SDK tests inspect both boundaries. Effective origin-observed WHATWG target/Host behavior is a future Tier 3 promotion gate. | +| Cloudflare cancellation | The production guard's injected abort operation fires on timeout, buffered cap overflow (early length rejection and incremental decoded overflow), normalization/decode/read failure, early 205 disposition, send-future cancellation, streamed decode/read failure, and early consumer drop. Explicit abort plus drop fires once; normal completed bodies disarm; a streamed response transfers guard ownership. Preserve 502/504/response-too-large/empty-205 outcomes and absolute-deadline precedence. A bare dropped Rust future is insufficient. | +| Streamed request deadline boundary | Axum and Cloudflare check expiry before every pull and after every ready source result. Fake-time streams cover a chunk, EOF, and source error becoming ready exactly at expiry, plus always-ready empty chunks. Cloudflare's clock fixture remains frozen until the injected host-event yield completes: assert the ready-item quota is finite/nonzero and no greater than 64, survives polls/stream yields, counts empty items, and permits no next item before the quota-triggered host yield. Ready-only terminal success/error/cap paths also yield before their expiry decision. Assert attributed 504 without reanchoring the deadline. | +| Cloudflare decoder fairness | With the same frozen clock, test ready-empty raw input that never produces decoded output, continuously ready decoded output, and native-EOF validation after codec completion. Both input and output quotas force host-event progress; terminal EOF/error and adapter-owned cap decisions at expiry yield attributed 504. Generic streamed-consumer caps after a within-budget yield retain §3.4.1's narrower ownership rule. Dropping during a host yield cleans up the owned subrequest guard. An independently advancing fake clock alone cannot establish these invariants. | +| Timeout provenance | Each adapter's actual timed-out result covers timeout-wins, deadline-wins, and synthetic-default input selection in single send, buffered fan-out, and streamed error chunks; no adapter emits a bare un-attributed timeout **for an EdgeZero-owned budget timer**. Spin separately proves that an early WASI provider timeout is 504 with `BudgetSource::Unspecified`, while an error observed at/after absolute expiry is attributed to the selected budget. Fastly proves configured connection/response phase timers retain the selected cause, an early unconfigured DNS timeout is `Unspecified`, and absolute expiry wins every simultaneous result. | +| Fastly stages | Backend identity/canonical host/TLS/SNI inputs, true ceil-to-millisecond phase rounding, cold registration, dispatch-all-before-selection, selected-handle identity reassociation, bounded selection groups, and streamed-upload cooperative checks match §4.3. Heterogeneous-host/budget tests prove distinct backend identities; a scripted cold-registration test proves an over-slack later cache miss is rejected before another registration. Target-neutral tests prove selection metadata follows provider-returned handle identity, and the Fastly contract gate proves streamed-slot preflight failures do not poison eligible siblings. The feature-gated overhead seam proves the final arming guard. `SendFailure` distinguishes budgeted phase timeout from unconfigured provider timeout; both map to 504, with the exact provenance boundary above. Pre-response DNS/connect/TLS establishment -> 502 reason `Unreachable`, later transport -> 502 reason `Transport`, upstream protocol -> 502 reason `Protocol`, unknown/registration rejection -> 502 reason `Unspecified`, local invariants -> 500, and the separately named Fastly platform-internal class -> 500. SDK-gated in-crate tests construct every known `SendErrorCause` and assert both `cause_to_failure` and composed status/kind/reason, including before/at/after deadline provenance; the Fastly WASM `--lib` gate executes them. Only hypothetical future variants remain compile/review coverage. | +| Fastly upload finalization | Empty-source EOF and multi-chunk success call `finish()` exactly once before response wait. Source/cap failure and pre-finish expiry drop both handles without finishing; a typed source error is preserved. Write/flush/finish errors within budget are 502; success or failure returning at expiry is attributed 504. A finish failure or post-finish expiry never waits on `PendingRequest`, and dropping the send future settles its owned handles without successful finalization. Tests use the production upload driver with a scripted writer/pending-handle seam; they do not claim a finite host-write bound. | +| Spin request protocol | Exercise every `run_exchange` transition and ownership boundary: after full upload, `send` continues to be polled but a ready result is retained until `request_done` succeeds; a `request_done` error wins over that stored result and is mapped; reader-gone retains but never polls `request_done` until `send` resolves, then drops it before response conversion; send-first drops all request handles; clean EOF/reader-gone writes `Ok(None)` trailers; source/cap/deadline failure leaves the default `Err`. Biased simultaneous readiness makes an already-ready pump failure beat `send`, while send ready before a later source failure remains authoritative. An always-ready empty-chunk source yields between chunks so send/timer polling cannot starve, and no request-side handle enters a streamed response wrapper. The target-neutral injected RequestOptions suite covers all setters accepted, any setter NotSupported (warn once and retain the outer race), Immutable, and Other. The real SDK-resource suite invokes each setter and asserts only the pinned host's actual result. | +| Spin response protocol | `consume_body` receives the caller-result reader; stream/trailer handles retain the writer; clean EOF/trailers writes `Ok`; body/decode/deadline failure writes or defaults to `Err`; no handle is dropped before its terminal branch. Ready-empty raw input and continuously ready decoded output hit finite input/output quotas, return `Pending`, poll the outer timer/siblings, and retain counters across calls. | +| Spin error classifier | Enumerate every pinned `ErrorCode` with no wildcard. At/after absolute expiry every simultaneous SDK result -> attributed 504. Before expiry, the five provider timeout variants -> 504 with `BudgetSource::Unspecified`; caller-controlled request denied/body/URI/header-size variants -> 400; demonstrated length/method/URI/trailer/config invariants -> 500; pre-response DNS/connect/TLS establishment -> 502 reason `Unreachable`; later connection/read/write failure -> 502 reason `Transport`; upstream response framing/protocol -> 502 reason `Protocol`; host `InternalError`/forced future fallback -> 502 reason `Unspecified`. Shared decode failures separately assert the exact coding reason. Test the three actual `ErrorCode` sites: `client::send`, `request_done`, and the response trailers/completion future. Test component-stream termination and caller-result `FutureWriteError` through their distinct policies; synthetic setter outcomes stay in the injected suite because the real host controls them. | +| Spin timer | Timer selection returns guest-visible 504 and drops owned guest handles. Tests do **not** claim bounded host teardown; that remains Tier 3 characterization and an upgrade criterion. | +| Simultaneous terminal race | For decoder, transport, and Spin exchange seams, a result becoming ready at the absolute deadline yields attributed 504 whether the competing result is success or error. | +| CLI runtime gates | Table-drive every constructible ladder state: required Native/BoundedCooperative succeeds; required BestEffort/Unsupported fails; optional degradation warns and proceeds. Cover missing-registry empty, optional-only, and required manifests; `ManifestContract::None` proceeds while Malformed fails closed. Normal CLI compilation matches core's non-exhaustive `CapabilitySupport` and `ManifestContract` across the crate boundary and therefore requires wildcard arms; review asserts that required-support and contract wildcards return errors, while the optional-support wildcard warns. Hypothetical future variants are not fabricated with unsafe runtime construction. Build/serve/deploy/`deploy --staging` gate before shell dispatch through both `execute_runtime` and `execute_capture_runtime`, and demo gates before startup. Auth/version/healthcheck/rollback remain on the existing exempt dispatcher and never construct `ResolvedRuntime`. Both outbound-scoped dispatchers derive adapter/action from the owned runtime, so no caller-supplied identity can diverge between gate and side effect. Provision/config commands are outside this spec and are not asserted. | +| Runtime resolver | Nested and descendant apps, competing targets, explicit relative/absolute `EDGEZERO_MANIFEST`, missing/malformed input, genuine manifestless targets, and containment/symlink failures resolve one adapter/action/target/contract unit for build/serve/deploy/`deploy --staging` only. Both outbound-scoped dispatchers gate that contract exactly once and execute that target without cwd/env mutation or rediscovery; shell overrides remain rooted at the selected manifest. Registry dispatch receives only typed `AdapterExecutionTarget`, never synthetic path args; conflicting cwd fixtures prove every in-tree `execute_target` honors the pinned app root/platform manifest/component and the default implementation fails closed. Fixture adapters prove the canonical adapter and action captured by the resolver are exactly those observed by capability lookup and final dispatch. Operational actions reject this resolver and retain their existing path. | +| Spin host drift | Build/serve/deploy resolve explicit/single/ambiguous component selection and compare only the selected component's canonicalized set before shell dispatch. Equivalent spelling/order passes; actual drift reports the manifest, component, and expected canonical list without writing files. Rendering `None`/`https://*:*` must not produce bare `*` or any `http` atomic; explicit input `*` produces the two scheme-specific entries. | + +### 5.3 Tier 3 — live host behavior + +- **Axum:** a loopback origin verifies real methods/bodies/headers, gzip/deflate/br decode stacks, + bodyless responses, non-2xx pass-through, response stalls, upload stalls, cap errors, + timeout provenance, and deterministic request/body/source future drop. HTTP/1 closure and + HTTP/2 stream reset are tested separately; pooled-connection teardown and bounded origin + observation are not portable promises. Deployed HTTP/2, and HTTP/3 when enabled, is + optional characterization only. +- **Cloudflare future promotion fixture:** a pinned workerd-compatible harness executes real Worker SDK response + conversion, including the Workers-only `Headers.getAll("set-cookie")` boundary and + repeated `Set-Cookie`, then verifies AbortController cancellation + from the origin's point of view on timer expiry, buffered over-cap/decode failure, + send-future cancellation, and early streamed-body drop, plus lazy streamed-response + delivery. Use origins that keep sending or stall after the triggering result so the + test distinguishes cancellation from normal EOF. In both Buffered and Streamed modes, + gzip/deflate/br decode exactly once and respect decompressed caps; unknown, parameterized, + stacked, and repeated visible encodings preserve the original wire bytes and visible + encoding/length metadata through the portable policy. Pin the harness version and + compatibility settings, including the production template's exact compatibility date/flags + and support for the final-fetch raw-response option; commit the standalone fixture's own + Cargo lock and assert Worker 0.8.5 from that graph before building; + verify encoded downstream passthrough separately from upstream raw-byte receipt. A + runtime that ignores the raw-response option fails this acceptance gate. Passing this + deployed fixture is required evidence before Cloudflare's deadline/cancellation cells can + be promoted to `Native`; the fixture is not present evidence in this PR. Raw + malformed-header fidelity is not asserted because workerd does not expose it. + Exercise the selected production yield primitive with a frozen-clock injection matching + deployed semantics for ready-only upload/raw/decode loops. Observe another host event and + clock advancement before the next quota of items, then verify terminal timeout + precedence. Run one deployed timing probe with the pinned compatibility settings. A + zero-duration `worker::Delay` is accepted only if that probe proves the property; local + workerd's freely advancing clock alone is not evidence. +- **Spin:** a pinned `spin up` harness and external origin record whether stalled upload + and response work is eventually cancelled and whether any component work remains. + Current capability values stay BestEffort regardless of a single passing run. Promotion + to Native requires a documented finite bound, repeatable host-observed tests, and the + corresponding matrix/spec update. +- **Fastly:** a protected manually dispatched job exercises one disposable service with + dynamic backends enabled and one with them disabled. It proves arbitrary canonical + destinations on the enabled service and the exact typed 502/no-origin-contact behavior on + the disabled service, plus records timeout/upload/streaming observations. Phase 6 owns a + standalone, independently locked probe crate because app-demo does not migrate until + Phase 7. The protected workflow builds its fixed package before exposing secrets, and the + driver can deploy only that package. The result + characterizes the `BestEffort` cell; it cannot make the static capability gate aware of + selected-service entitlement. + +### 5.4 Required test-case index + +The Tier 1 bullets, Tier 2 table, and Tier 3 runtime list above are the required test-case +matrix referenced throughout §§3–4. A reference to “the §5.4 row” means the matching +surface in those lists; implementation plans may split one surface into multiple focused +tests, but may not weaken its tier or substitute a mock for a host-observed claim. + +### 5.5 Executable test seams and CI impact + +Integration tests that need deterministic adapter states use a narrowly feature-gated +`test-utils` seam rather than `#[cfg(test)]`: external `tests/*.rs` crates do not see +the library's `cfg(test)` items. Seams inject clocks, transport results, or documented +adapter-stage delays; they do not duplicate the behavior being tested or expose platform +SDK types to core Tier 1 tests. + +**Feature and target contract.** Each of the four adapter `Cargo.toml` files declares +`test-utils = []`. It exposes only the injection accessors and SDK-independent production +drivers needed by these tests; it does not enable `axum`, `cloudflare`, `fastly`, `spin`, +or `cli`, and does not change existing default features. Production adapters call those +same drivers with real clock/transport/handle operations. Tests inject below the behavior +under test; copied algorithms and a core mock client are not adapter contract coverage. +Platform SDK bindings remain behind their existing feature/target gates. Public test-only +callbacks retained by a `Send + Sync` client must preserve those bounds. + +Add Axum's `tests/contract.rs`. In the existing Cloudflare, Fastly, and Spin contract files, +move the whole-file platform/WASM gate onto the SDK-test module and add a native +`test-utils` module. Otherwise the commands below can succeed while running zero tests. +Gate injection-dependent tests on `test-utils`; each native contract target must execute +the shared `send_all_until` cases and its applicable adapter-driver rows in §5.2. SDK-dependent +conversion tests run with a supported host harness, not by calling WASM imports on a native +host. Cloudflare's ordinary browser WASM runner is limited to portable Web APIs and bridge +construction because Worker 0.8.5's uncaught `Headers.getAll()` binding is Workers-only; +its real response conversion runs under workerd. Compile checks alone do not substitute for +test execution. + +**Explicit execution gates**, added to `.github/workflows/test.yml` when each adapter is +implemented (after the existing locked dependency fetch): + +```sh +scripts/run_test_nonzero.sh batch_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test contract +scripts/run_test_nonzero.sh batch_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils --test contract +scripts/run_test_nonzero.sh batch_preflight_rejects_streamed_slots_without_poisoning_siblings cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils --test contract +scripts/run_test_nonzero.sh batch_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features test-utils --test contract +``` + +These execute the no-network native seams; Axum's command explicitly enables its native +runtime. `scripts/run_test_nonzero.sh [--ignored] ` lists the selected +suite first, requires the exact sentinel and at least one listed test, then runs the same +selection; every native/WASM/SDK-resource/CLI suite in CI uses it. Workerd/deployed probes +instead require exact probe IDs plus a positive summary count in their own driver. These +gates become runnable as the features and test modules land, not in the design/Phase 1a +change set. Extend the existing WASM contract matrix's feature argument to +`--features "${{ matrix.adapter }},test-utils"`. Retain the Cloudflare and Fastly target +and runner settings. **Spin's bare `wasmtime run` is not an SDK-resource execution gate.** +The validated runner is +[Wasmtime 48.0.2](https://github.com/bytecodealliance/wasmtime/releases/tag/v48.0.2) with +`-W component-model-async=y -S http=y -S p3=y`; CI reads that exact version from +`.tool-versions`. Spin SDK 7's stable WASI HTTP 0.3 imports do not link against the former +Wasmtime 44/45 runner line. The compatibility gate must execute the real contract and +SDK-resource suites, including `Fields` and `RequestOptions` construction/setters. A compile +check, native fake-resource tests, or zero-test success is not a substitute. Any runner-pin or +flag change must rerun both suites; this gate remains separate from Tier 3's +origin-cancellation characterization. +GitHub delivers `workflow_dispatch` only when the workflow file exists on the default +branch ([GitHub workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#onworkflow_dispatch)). Before adapter implementation starts, a standalone infrastructure bootstrap PR must +therefore land inert protected dispatchers for both Cloudflare and Fastly. They publish no +runtime/API/capability change, have no automatic secret-bearing trigger, and only check out a +maintainer-reviewed exact input SHA proven reachable from the fixed repository-owned +`refs/heads/outbound-probe-reviewed` ref before running that tree's probe driver. This bootstrap +is the sole permitted intermediate merge in the otherwise branch-only implementation series. +The reviewed ref is protected against force pushes and deletion; evidence commits advance it +only by fast-forward, and the protected run/job name displays the exact input SHA for the +environment reviewer. +Each provider dispatcher uses a fixed concurrency group with cancellation disabled. Fastly's +driver records and restores both services' prior active versions, removes only versions that +its own run created, and deletes only its own origin-observation namespace. + +Cloudflare's deployed probe uses that default-branch `workflow_dispatch` workflow bound to a protected +`outbound-cloudflare-probe` environment, never `pull_request_target`. It verifies an exact +input commit SHA before exposing disposable-account secrets +`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_WORKERS_SUBDOMAIN`, +`OUTBOUND_PROBE_ORIGIN_URL`, and `OUTBOUND_PROBE_ORIGIN_TOKEN`. The driver creates a unique +Worker and origin-observation namespace per run, requires named probe IDs and a positive +count, and deletes both in `finally`; assertion and cleanup failures fail the job. The +observable origin exposes authenticated arm, case, observation, and delete endpoints for +raw bytes, stalls, EOF, and disconnect facts. Untrusted forks run workerd only; after review, +a maintainer reproduces the exact tree on `refs/heads/outbound-probe-reviewed` and dispatches the protected +job for that SHA. Phase 4 specifies the concrete commands and origin protocol. + +Fastly WASM gates assert repository-pinned Viceroy 0.21.0 and run from the adapter crate so +its committed runner, including the demo `fastly.toml`, is used without an environment +override. Its default-branch deployed entitlement dispatcher follows the same exact-SHA, +protected-environment, disposable-resource, positive-probe, and cleanup rules in Phase 6. +It validates and builds Phase 6's independently locked fixture without secrets, then the +secret-bearing driver deploys only the fixed prebuilt package path. Keep ordinary production +feature/target checks and the Fastly WASM `--lib` gate. SDK +conversion checks and native fake-transport tests do not establish host cancellation; +the Tier 3 jobs still provide that evidence. + +`#[non_exhaustive]` future variants are a special case: stable Rust cannot safely construct +a variant that does not exist in the pinned enum. Tests therefore exercise every known +state; normal cross-crate compilation forces the production wildcard arms, and review pins +their fail-closed result (`Unsupported` or an error). They must not use `unsafe` discriminant +fabrication or add test-only public enum variants, which would alter serialization and the +macro crate's textually included manifest definitions. The pinned Spin/WASI `ErrorCode` is +an exhaustive enum and is therefore the opposite case: its classifier has no wildcard, so +an SDK variant addition fails compilation and must be assigned explicitly. When a real variant is added, it +becomes a normal table-driven case in the same change. + +Required gates for implementation changes: + +1. Repository `CLAUDE.md` format, test, clippy, feature-combination, and Spin WASM checks. +2. Per-adapter WASM target checks and no-network contract tests. +3. Generated-project build and the excluded `examples/app-demo` build. +4. Published-doc link/navigation verification. +5. Cloudflare host-observed cancellation is a promotion gate for its current `BestEffort` + deadline cells; it is not present evidence for a `Native` claim. +6. Fastly enabled/disabled dynamic-backend characterization for actual adapter behavior; + the static cell remains BestEffort. +7. Spin host-observed characterization job when available; failure preserves BestEffort + and blocks only a proposed promotion to Native. + +Version-specific behavior is a manifest contract, not merely a property of whichever lockfile +was inspected. The implementation phases pin `async-compression = "=0.4.43"`, +`compression-codecs = "=0.4.38"`, `compression-core = "=0.4.32"`, +`flate2 = "=1.1.9"`, `miniz_oxide = "=0.8.9"`, `adler2 = "=2.0.1"`, +`crc32fast = "=1.5.0"`, and `simd-adler32 = "=0.3.9"` for the shared +gzip/deflate decoder graph. The fixed flate decoder charge and checksum/source contract are +recorded in `docs/audits/2026-09-29-flate-decoder-memory-accounting.md` and enforced by the +separate flate dependency audit. +`reqwest = "=0.13.4"`, `worker = "=0.8.5"`, `spin-sdk = "=7.0.0"`, `fastly = "=0.13.1"`, +`fastly-shared = "=0.13.1"`, `fastly-sys = "=0.13.1"`, and +`log-fastly = "=0.13.1"` in the workspace manifests that own those dependencies, refresh +both the root and excluded app-demo lockfiles, and assert both resolved graphs. Spin uses the +SDK's `wasip3` re-export rather than a second direct dependency; the locked SDK 7 graph selects +`wasip3 0.7.1+wasi-0.3.0`. Adapter manifests inherit the corresponding workspace dependency +rather than declaring a broader compatible range. Viceroy is pinned to 0.21.0 because older +repository/local releases reject imports emitted by Fastly SDK 0.13.1 before tests execute. An +SDK enum/table or raw bridge may not be published from a graph that can silently select a later +compatible release. + +The spec and Phase 1a plan themselves are documentation changes, so review-time +verification is Markdown structure, internal-link/anchor consistency, scoped terminology, +and `git diff --check`; Rust builds are required when an implementation plan is executed. + +## 6. Migration impact + +No compatibility shims are introduced for the outbound API rename. + +| Before | After | +| --- | --- | +| `crates/edgezero-core/src/proxy.rs` | `crates/edgezero-core/src/outbound.rs` | +| `ProxyClient` | `OutboundHttpClient` | +| `ProxyHandle` | `HttpClient` | +| `ProxyRequest` | `OutboundRequest` | +| `ProxyResponse` | `OutboundResponse` | +| `ProxyService` | removed; use `HttpClient` | +| `RequestContext::proxy_handle()` | `RequestContext::http_client()` | +| adapter `*ProxyClient` | adapter `*OutboundClient` | + +Current response APIs are a hard cut, including migrations from intermediate PR revisions: + +| Retired outbound API | Required replacement | +| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| four- or five-argument `OutboundResponse::new` | six-argument `new(method, status, headers, body, clock, content_type_provenance)` requiring the application clock and original evidence | +| four-element `OutboundResponse::into_parts()` | five-tuple `(method, status, headers, body, provenance)` with effective `ContentTypeProvenance` | +| `normalize_response_headers(..) -> Result` | `Result`; use `.body_disposition` and retain original `.content_type_provenance` | + +Outbound-facing changes: + +- `OutboundRequest` and `OutboundResponse` continue to use the unified core `Body` + type. Buffered responses remain the default; `stream_response()` opts into a streamed + response. `OutboundRequest::from_request` preserves the source method, normalized + headers, and supplied body without requiring any inbound extractor or body-state + redesign. +- `OutboundResponse` carries the originating request method through construction and + `into_parts` so HEAD and other bodyless-response rules remain enforceable at the final + conversion boundary. Construction now requires six arguments, including the paired clock + and explicit `content_type_provenance`; decomposition returns five elements, ending in + effective provenance. `normalize_response_headers` returns `ResponseHeadNormalization`, + not just `ResponseBodyDisposition`. No compatibility signatures remain. Conversion carries + the enum as a historical outbound-boundary response extension, not a guarantee about later + ordinary `http::Response` edits. +- `ProxyHandle::client()`, outbound request/response `body_mut()`, and outbound + `extensions()` / `extensions_mut()` are removed. `HttpClient` exposes only + `send` / `send_all_until`; the new request and response values are builder-style and do + not carry `Extensions`. +- `PROXY_HEADER` and the observable `x-edgezero-proxy: ` response header are + preserved. The constant moves to `outbound.rs` without changing its value. +- `Body::Stream` changes its chunk error from `anyhow::Error` to `EdgeError` so + deadline, gateway, and over-cap outcomes survive decoder and adapter boundaries. + `Body::from_stream` now accepts only streams already returning `EdgeError`; + callers with arbitrary external errors use `Body::from_external_stream`, which + deliberately maps them to `internal`. `Body::into_stream` returns the exact typed + stream. These signatures are public breaking changes. Every existing + `Body::from_stream`, `Body::into_stream`, and direct `Body::Stream` call site must be + audited: EdgeZero-owned outbound/decoder/deadline producers use the typed constructor; + platform inbound-body sources that previously relied on the generic `anyhow` mapping + use `from_external_stream` to preserve their existing behavior. That mechanical + constructor migration is required for compilation but does not redesign inbound body + extraction, caps, or state. +- `EdgeError` gains `BadGateway { reason: BadGatewayReason }`, + `GatewayTimeout { cause: BudgetSource }`, and + `ResponseTooLarge { reason: ResponseLimitReason }`. Their JSON wire shape remains the + existing error envelope and does not serialize any reason/provenance field. Their wire + messages, plus `Internal`, are fixed category strings; detailed provider diagnostics and + request targets are not serialized. +- `Manifest` gains the twelve outbound capabilities and + `[capabilities.outbound].hosts`. Existing non-outbound capability and store schemas are + unchanged. The depth-independent misplaced-`capabilities` rejection is intentionally + fail-closed. +- `Adapter` gains the defaulted `capability()` method. Each in-tree adapter returns all + twelve matrix values and ends its non-exhaustive match with + `_ => CapabilitySupport::Unsupported`. +- Capability enforcement uses outbound-scoped `execute_runtime(..)` and + `execute_capture_runtime(..)`, which share one helper that gates `build` / `serve` / `deploy` / + `deploy --staging` exactly once before shell or registry dispatch, and `run_demo` gates + the Axum demo from its baked manifest. `auth *`, version emission, healthcheck, + rollback, provision, and config commands are not changed by this outbound specification. +- Build/serve/deploy resolve one `ResolvedRuntime` containing the action, a pinned canonical + adapter identity, a paired adapter target, and an optional `ResolvedManifest`. Dispatch + accepts no separate adapter/action arguments: the selected contract is gated exactly once + and the selected target is executed without reparsing, rediscovery, or process-wide + cwd/env mutation. +- Scaffolding, `examples/app-demo`, and public proxying docs migrate to the renamed + outbound types. Portable multi-adapter examples declare `optional = ["outbound-http"]` + because Fastly's service entitlement is not statically provable; single-target apps may + declare it required on an adapter whose matrix cell satisfies the gate. + Spin's generated platform manifest consumes the canonical outbound host list. +- `docs/guide/capabilities.md` documents all twelve outbound capabilities, the support + matrix, and each BestEffort caveat, and is linked from the VitePress sidebar because + runtime diagnostics point to that published page. + +Repository-wide completion sweeps cover `ProxyClient`, `ProxyHandle`, +`ProxyRequest`, `ProxyResponse`, `ProxyService`, `proxy_handle`, and the four +adapter `*ProxyClient` names across Rust sources, examples, templates, and published +docs. A separate compile-driven sweep covers every `Body::from_stream`, +`Body::into_stream`, and direct `Body::Stream` construction/consumer so no typed +`EdgeError` is accidentally erased at the new explicit external-error boundary. A +generated-project build verifies that scaffold templates compile against the new outbound +API. Inbound body extraction/state and store/config lifecycle behavior remain owned by +their dedicated designs and are not migration prerequisites here. + +## 7. File-by-file change summary + +Exact filenames should follow the current tree at implementation time; function names are +the durable anchors. + +**`crates/edgezero-core`** + +- `src/lib.rs` exports `body`, `compression`, `outbound`, and `time`, plus root re-exports + `BodyStream`, `ContentEncoding`, `ContentTypeIneligibility`, `ContentTypeProvenance`, + `Deadline`, `DispatchBudget`, `HttpClient`, `OutboundHttpClient`, + `OutboundRequest`, `OutboundResponse`, `OutboundSlotResult`, `ResponseBodyDisposition`, + `ResponseHeadNormalization`, `ResponseHeaderLimiter`, `ResponseMode`, `BROTLI_DECODER_FIXED_CHARGE_BYTES`, + `brotli_decoder_memory_charge`, `classify_content_encoding`, `collect_response_stream`, + `decode_brotli_stream`, `decode_gzip_stream`, + `dispatch_budget`, + `enforce_payload_content_length`, `limit_decoded_stream`, `limit_encoded_stream`, + `normalize_response_headers`, + `rechunk_stream`, `validate_for_dispatch`, and the outbound capability types; remove proxy exports. Adapter crates + use these root re-exports as their stable import surface. +- `src/proxy.rs` becomes `src/outbound.rs`. It owns request construction and + validation, canonical URI accessors, the private `BudgetInputs` accessor consumed by + `time.rs`, `HttpClient::send` / `send_all_until`, bounded response drains, hop-by-hop + normalization, response-bodyless rules, and the preserved `PROXY_HEADER`. + `OutboundResponse::new(method, status, headers, body, clock, content_type_provenance)` + requires pre-filter evidence. `content_type_provenance()` accounts for separate header-dirty + tracking without changing private original evidence, and `into_parts` returns + `(method, status, headers, body, provenance)`. The normalization helper returns both body + disposition and original provenance; `into_response` inserts effective provenance as a + historical-boundary extension without upgrading original ineligible/unknown evidence. +- The root workspace and `edgezero-core` manifests add a direct pinned `url` dependency. + URI construction canonicalizes once through `url::Url`, stores the resulting `http::Uri`, + and adapters transmit that exact serialization rather than reparsing it. +- `src/body.rs` changes streamed chunk errors to `EdgeError`, adds + `From`, and implements pre-append checked accounting. + `Body::from_stream` is the typed `EdgeError` constructor used by adapters, + decoders, and deadline wrappers; `Body::from_external_stream` is the explicit + arbitrary-error boundary and maps every source error to `internal`. +- `src/compression.rs` keeps one shared gzip/deflate/br decoder implementation. An exact typed + carrier crosses the `io::Error`-based decoder boundary and restores the original + `EdgeError`. Extend the helpers for concatenated gzip members, strict Brotli/trailing + data handling, and native-EOF validation without losing buffered read-ahead or late + source errors. The absolute deadline/cancellation wrapper sits **outside decoded + output and completion validation** and checks every yield plus terminal EOF/error. +- `src/error.rs` adds `BadGateway { message, reason }`, + `GatewayTimeout { message, cause }`, `ResponseTooLarge { message, reason }`, + `BadGatewayReason`, `BudgetSource`, and `ResponseLimitReason`; update every exhaustive + match and preserve the current JSON envelope. Phase 1a is specified by + `docs/superpowers/plans/2026-07-10-outbound-http-phase1a-error-time.md`. +- `src/time.rs` adds `Deadline` and the three budget constants with `web-time`. + Phase 1b adds `DispatchBudget` and `dispatch_budget` here as the independently testable + value/arithmetic layer; they consume `OutboundRequest::budget_inputs()` without owning + request construction or platform behavior. +- `src/app.rs` adds the three-state baked-manifest accessor to `Hooks`; update both + handwritten in-core `Hooks` implementations and the macro-generated implementation. +- `src/context.rs` performs only the outbound handle rename + `proxy_handle()` -> `http_client()`. Inbound request/body state is unchanged by + this specification; any local test fixture affected by the global `Body::from_stream` + signature change switches to the appropriate explicit constructor without changing + behavior. +- `src/manifest.rs` adds the twelve outbound capabilities, + `ManifestCapabilities`, `ManifestOutboundCapability`, host validation, misplaced + nested-`capabilities` rejection, and the three-state baked-manifest contract. + Runtime and baked parse paths run the same validation/finalization logic. + +**`crates/edgezero-macros`** + +- `src/app.rs` bakes the validated manifest JSON and emits the per-app + `manifest_json()` / `manifest()` accessors. A per-implementation `OnceLock` + prevents cross-app cache sharing. Its raw TOML parse path runs the shared + depth-independent `reject_misplaced_capabilities` scan before typed deserialization; + compile-fail coverage rejects misplaced `capabilities` blocks at multiple depths. + +**`crates/edgezero-adapter`** + +- `Cargo.toml` adds `edgezero-core = { workspace = true }`. The registry trait's + public `capability(Capability) -> CapabilitySupport` signature uses types owned and + re-exported by core, so the adapter crate can no longer remain dependency-free from + `edgezero-core`. This creates no cycle: core depends on macros, while neither core nor + macros depends on `edgezero-adapter`. +- `src/registry.rs` adds the defaulted `Adapter::capability()` method. In-tree + overrides return all twelve outbound matrix cells and use a final + `_ => Unsupported` arm for the non-exhaustive enum. Update the trait rustdoc that + currently promises the crate remains dependency-free from core. No store/provision + API changes belong to this spec. + +**`crates/edgezero-adapter-{axum,cloudflare,fastly,spin}`** + +- Each adapter imports EdgeZero-owned `MonotonicClock`/`MonotonicInstant` timing types from + core and adds `test-utils = []` to `[features]`. Production outbound code does not take + direct process-global snapshots. An adapter client's `Default` implementation may select + the process source, but every response conversion must pass that client's retained clock + explicitly into `OutboundResponse::new`. Keep the adapter test feature independent of + runtime/CLI features and preserve current adapter-client defaults (§5.5). +- `tests/contract.rs` gains the native adapter-driver cases and SDK-specific test modules + described in §5.5. `.github/workflows/test.yml` explicitly executes the native commands + and enables `test-utils` in the existing WASM contract matrix in the same adapter phase. +- Rename each outbound provider module/client from `proxy` / `*ProxyClient` to + `outbound` / `*OutboundClient`; implement both response modes, request preflight, + method-aware `OutboundResponse` construction, request and response normalization, + independent encoded/decoded/final-buffer caps, typed decoder errors, and the exact twelve outbound-cell + capability tuple. Before the native request consumes `OutboundRequestParts`, retain the + originating method, response mode, and every response-resource setting in the owned + response-conversion state; no adapter may reconstruct or default those values after send. +- Adapter outbound-response builders retain request method, status, headers, and body. + They retain pre-filter Content-Type metadata separately (always `Unknown` on Cloudflare), + use `ResponseHeadNormalization::body_disposition`, and pass explicit provenance as the + sixth `OutboundResponse::new` argument on every path. They normalize raw upstream headers + and settle bodyless responses before content + decoding/capping and `OutboundResponse` construction, then reapply normalization + idempotently before final platform conversion. + Every response-egress coordinator preserves a forwarded `Body::Stream` lazily. Axum and + Cloudflare are Native for that outbound capability; Fastly and Spin remain BestEffort pending + their documented target-runtime evidence. +- Do not change inbound platform-request buffering or `RequestContext` construction; + those are owned by the inbound-body design. The one allowed inbound-side edit is the + compile-required, behavior-preserving constructor migration from generic + `Body::from_stream` to `Body::from_external_stream` (or an explicit `EdgeError` map + followed by typed `from_stream`) at existing platform-body conversion sites. + +Adapter-specific work: + +- **Axum:** use the remaining `DispatchBudget` for reqwest's whole-operation timeout; + race every streamed request-source pull and apply the absolute post-ready deadline + check before accepting chunk/EOF/error; disable reqwest auto gzip/deflate/br; use the shared + decoder and normalization. Keep header fidelity Native. +- **Cloudflare:** add the private `fetch_raw_with_signal` bridge using worker's existing + JS/Web API re-exports. Set `encodeResponseBody: "manual"` and the guard's signal on the + final fetch options so the shared decoder receives raw bytes. Use an owning abort + guard plus `worker::Delay` for send/body cancellation on every early exit; use the same + pre-pull/post-ready absolute checks and bounded host-event yield quotas for request + sources, raw decoder input, decoded output, and terminal decisions (§4.2); stream + response output lazily; and classify header fidelity + BestEffort because workerd exposes normalized strings rather than raw malformed field + lines. Set `EncodeBody::Manual` when forwarding already encoded passthrough bytes so + Workers does not transform the payload. Replace the crate-local stale Preview-1 Cargo + config with `wasm32-unknown-unknown` plus `wasm-bindgen-test-runner`, and execute one + crate-local nonzero WASM gate without target/runner overrides in CI. +- **Fastly:** derive client-namespaced dynamic-backend names; configure connect, + first-byte, and between-bytes host timers from the remaining budget; retain the + documented service-entitlement, cold-registration, upload-write, pending-handle selection, and + selected-body-drain + BestEffort gaps. `outbound-http` remains BestEffort until a reviewed deployment + prerequisite can prove dynamic-backend enablement for the selected service. + Native tests cover `SendFailure`; SDK-gated tests construct every known public + `SendErrorCause` and check the boundary mapping as well as composed status/kind and + timeout provenance. Only `SendError`'s private wrapper and hypothetical future enum + variants are not directly constructed. `PlatformInternal` is pinned separately from `LocalInvariant` even though both + currently map to 500, so a future policy change cannot silently conflate their causes. + Add an independently locked standalone deployed-probe crate under the adapter's test + fixtures; build its fixed package before protected-workflow secrets are exposed, and make + the live driver reject an absent or alternate package rather than compiling during deploy. +- **Spin:** use the hand-built WASI HTTP request for buffered and streamed uploads. + Implement the biased `run_exchange` state machine and cooperative per-chunk yield; + retain or consume `request_done` at the exact state boundaries in §4.4; explicitly + resolve request trailers on clean EOF/reader-gone; use the response `consume_body` + caller-result protocol; own every future/reader/writer until its terminal branch; and + classify all three `ErrorCode` sites: `client::send`, `request_done`, and the response + trailers/completion future. Component-stream termination and caller-result + `FutureWriteError` follow their separately specified policies rather than entering that + classifier. + Caller-controlled request policy/size variants map to 400, core/adapter invariants to + 500, provider timeouts before absolute expiry to unattributed 504, errors observed at or + after absolute expiry to attributed 504, and upstream failures to 502. The monotonic race + returns a guest-visible timeout, while deadline and upload cancellation remain + BestEffort until host-observed tests establish a finite bound. + Replace the adapter-local buffered `src/decompress.rs` path with the shared core + streaming decoder; remove direct production `brotli` / `flate2` dependencies if no + remaining Spin-only use exists (retain only dependencies still required by tests). + +**`crates/edgezero-cli`** + +- The `run_build`, `run_serve`, and `run_deploy` command entry points capture the invocation + directory, resolve one target/contract pair, and pass the resulting `ResolvedRuntime` to + `src/adapter.rs::execute_runtime` or `execute_capture_runtime`. A shared private action gate runs exactly + once at the start of both outbound-scoped dispatch functions and gates `Build` / `Serve` / + `Deploy` / `DeployStaging` before either function's shell-command branch or registry + lookup. It executes the pinned target without rediscovery or reparsing. Auth, + `EmitVersion`, `Healthcheck`, and `Rollback` keep the existing dispatcher and resolution + behavior; they do not construct `ResolvedRuntime`. +- `has_manifest_command` and pre-dispatch adapter-manifest selection are folded into the + action-specific target resolver. Callers inspect the borrowed `ResolvedAdapterTarget` + when deciding which built-in-only arguments to add; they do not repeat shell-command or + platform-manifest discovery before handing the owned pair to the dispatcher. +- `src/manifest_source.rs` (or the existing manifest-loading module) adds the exact shell and + registered `ResolvedAdapterTarget` variants, `ResolvedManifest { loader, path }`, and + `ResolvedRuntime { action, adapter, contract, target }`. The registry crate adds the exact + private-field `AdapterExecutionTarget { app_root, component, platform_manifest }` handoff + plus `Adapter::execute_target`; every in-tree registered adapter uses it without cwd + discovery, and the default fails instead of delegating to `execute`. The runtime pins the canonical adapter + name and resolved action; outbound-scoped dispatcher signatures accept no + duplicate identity arguments. Explicit `EDGEZERO_MANIFEST` is authoritative + relative to the captured invocation directory. Default discovery applies the existing + ancestor/workspace-descendant target domain to paired app candidates; malformed, + ambiguous, mismatched, or containment-invalid candidates fail closed. Genuine + target-associated contract absence alone yields `contract: None`. Deploy's version + fallback remains on the existing operational dispatcher and is not part of this resolver. +- `src/demo_server.rs::run_demo` gates Axum against the baked manifest before startup. +- The Spin build/serve/deploy pre-dispatch path validates only the selected component's + canonical `allowed_outbound_hosts` before shell overrides can bypass it. Drift is + reported with the manifest/component/expected list and repaired manually; generation + initializes new projects but does not rewrite existing user-owned `spin.toml` files. +- Provision and config command implementations are unchanged by this outbound spec. + +**Templates, examples, and docs** + +- Rename generated proxy APIs in Rust and Handlebars sources. The portable root scaffold + and `examples/app-demo` declare `optional = ["outbound-http"]` so their shared manifest + remains usable with Fastly's BestEffort service prerequisite; a single-target app can + promote it to `required` when its selected adapter satisfies the matrix. Spin's generated + platform manifest renders the canonical outbound host list. +- Update public proxying, handler, architecture, streaming, and adapter docs. Add + `docs/guide/capabilities.md` with all twelve outbound capabilities and sidebar + navigation. +- Build one generated project and the excluded `examples/app-demo` workspace so + template/example drift cannot hide behind the root workspace build. The generated + project gate explicitly runs `cargo test -p scaffold-probe-core --lib`; a workspace + build alone does not execute those core fixture tests. + +**Tests and CI** + +- Core colocated tests cover builders, URI validation, normalization, bodyless rules, + typed versus external body-stream constructors, budget selection/provenance, bounded + drains, typed decoder errors, decoded-output deadlines, and `send_all_until` + index/partial-failure semantics. +- Each adapter has no-network contract tests for request conversion, response + conversion, error classification, capability metadata, and platform-specific + timeout mechanics. +- Axum loopback tests prove native wire behavior. Cloudflare's future deployed runtime + cancellation fixture is the promotion gate for its current BestEffort deadline cells. Spin host-observed + tests characterize cancellation and are the criterion for a later BestEffort -> + Native upgrade; they are not a prerequisite for the current BestEffort claim. + Fastly local runtime tests remain conditional on a supported harness, but the protected + enabled/disabled deployed characterization is required before its BestEffort row is + published. +- Required local gates are the repository `CLAUDE.md` commands, all adapter WASM + target checks, the generated-project compile plus explicit nonzero core-test execution, + and the app-demo build. Phase 7 adds deterministic legacy-API and published-capability + document checks; the latter compares all twelve rows/support values and requires exactly + one VitePress sidebar link. Documentation-only edits use Markdown/link/diff verification + rather than rebuilding Rust. + +## 8. Open questions / risks + +1. **`DEFAULT_MAX_RESPONSE_BYTES` = 1 MiB.** Trivially overridable per request via + `max_response_bytes`. Confirm the default suits expected target responses. +2. **Tier 3 CI runtimes.** Viceroy / `workerd` / `spin` jobs add CI cost and + maintenance. The design degrades safely (Tier 1 + Tier 2 always run); the risk is + schedule, not correctness. +3. **Cloudflare cancellation — IMPLEMENTED; DEPLOYED EVIDENCE OPEN.** A timed-out subrequest is cancelled via + the guard's `worker::AbortController`, whose signal is included in the final options + passed by `fetch_raw_with_signal` (§4.2). Dropping the underlying fetch future alone + leaves the subrequest running; dropping the guarded send future aborts through the + guard's `Drop`. The required Tier 3 fixture is not present in this PR, so the deadline + capabilities remain `BestEffort`. Promotion to `Native` requires a deployed probe that keeps + the origin active after timeout/cap/decode/drop triggers and observes cancellation with the + pinned compatibility settings; an origin that already closed before a transport/completion + error is not valid evidence. +4. **Fastly active response-drain overshoot.** Once an individual warm-path slot is + actively draining its response, that read-phase overshoot is bounded by one + between-bytes-timeout interval (§3.3.4). This does not bound cold backend registration, + request-body writes, or time spent behind an earlier selected response-body drain; those + gaps are owned by footnotes 1/2/4 and risks 7/8. If a stricter active-drain guarantee is + ever required, the adapter would need to cap total body-read attempts — out of scope here. +5. **Naming.** `OutboundHttpClient` (trait) vs. `HttpClient` (handle) are close. They + never co-occur in app code — handlers see only `HttpClient` — so the overlap is + low-risk, but a rename of the handle is cheap if preferred. +6. **Axum transport-observed completion follow-up.** The connection-local Hyper body now + preserves a non-`Send` `Body::Stream` lazily without a channel or whole-body collection. + Frame acceptance still does not prove socket or client acceptance, and one expired response + closes its HTTP/1 connection, affecting unsettled pipelined responses. Promotion from + `BestEffort` requires a stronger attributable transport boundary or deployed evidence; the + current implementation reports the limitation explicitly. +7. **Fastly streamed-upload write-phase has no SDK-configurable bound.** + Fastly's `between_bytes_timeout` is documented as receive-side only — it + bounds the gap between bytes received from origin, not the host-side write + of guest-supplied bytes to origin (Fastly Backend API docs; round 50). No + published Fastly backend-timeout field bounds the guest-to-origin write + direction. Streamed-upload write-phase is therefore `BestEffort` on + Fastly (alongside the source-stream-yield `BestEffort`); the cooperative + `budget.deadline.is_expired()` check **between** chunks is the only + adapter-side bound. Apps that need real-time enforcement against a slow + origin on the write path must **target a different adapter**. (Passing a buffered + `Body::Once` does *not* fix this: it removes the source-pull stall but the host still + writes those bytes in the untimed `connect`→`first_byte` window. The write-side gap is + a property of Fastly's timeout model, not of `StreamingBody`.) If a future + Fastly platform release adds a documented guest-write timeout, it would close the + write-side gap only; the capability would remain BestEffort until source-pull + preemption also has a documented bound. Track Fastly host docs. +8. **Fastly buffered-body-drain serialization in batches.** `select_handles` removes input-order + header blocking, but EdgeZero still drains each selected buffered body before selecting the + next completion. Total guest work therefore includes the sum of selected body-drain times, + while Axum/Cloudflare/Spin poll complete exchanges concurrently (§3.3.4). + For small JSON bodies the response-drain term alone is usually negligible; cold backend + registration and unresolved request writes remain separate gaps. For multi-MiB responses + Fastly's serialized drain is suboptimal. **There is no current EdgeZero mitigation** — and + Streamed mode is not the workaround (it is rejected by batch preflight + per §3.1.1, and even via single `send` Fastly has no concurrent + chunk-consumption primitive). Apps that need concurrent large-body fan-out on + Fastly should (a) target a different adapter for that workload, (b) restructure + the topology so parallel large-body drains aren't required, or (c) wait for the + interleaved-drain follow-up. The follow-up — interleaved chunk reads across + selected Fastly `Response` bodies, coordinated from one guest driver — is + non-trivial without an async reactor and is **deferred**. The + `outbound-batch-slot-isolation` capability (§3.5.1 footnote 4) lets apps declare the + requirement explicitly and get a hard build failure on Fastly until this lands. +9. **Fastly configurable phase split.** The fixed 1/4 connect + 3/4 first-byte + split (§4.3) produces premature connect failures for slow-connect upstreams + even when the total budget would have sufficed. Apps that hit this require + `outbound-flexible-phase-budget` (§3.5.1 footnote 5) and fall through to the + hard build failure on Fastly. The follow-up would either expose a per-request + `fastly_phase_split(connect_ratio: f32)` setter, a per-`OutboundRequest` + configuration field, or a per-adapter config knob on `FastlyOutboundClient`. + Each option has a memory-model and capability impact, so it's left **deferred** + pending a real use case. +10. **Spin target documentation drift.** Implementation verification uses + `wasm32-wasip2` for Spin SDK 7. Any remaining comment that associates Spin with + `wasm32-wasip1` should be corrected when the implementation touches that file, but + this documentation-only cleanup is not a prerequisite for the outbound design. +11. **Per-batch transient-memory cap against adversarial chunking — PARTIALLY + RESOLVED.** §3.4.1's + `current_chunk.len()` term is source-controlled — an upstream peer that + yields one large `Bytes` produces a transient resident footprint equal to + that chunk size plus the persistent buffer cap. The design now includes opt-in + `OutboundRequest::max_chunk_bytes(NonZeroU64)` and a lazy consumer-side rechunker + (§3.4.5), with no cost when unset. This bounds app-visible item size and preserves + ordering, errors, deadlines, and cancellation. It does **not** bound transport-side + source allocation or process RSS: splitting `Bytes` can retain the original backing + allocation, and a copied slice may coexist with it. A true transport allocation cap + would require an adapter/provider boundary that refuses an oversized frame before + materialization; that remains unavailable on some targets and must not be inferred + from `max_chunk_bytes`. +12. **Fastly send-owning response entrypoint — RESOLVED.** Generated and demo applications now + use an undecorated no-request entrypoint. EdgeZero initializes the ABI, receives the request, + commits with `stream_to_client`, and owns write/close/abandon. No response-returning scaffold or + whole-body compatibility path remains. Synchronous source polls and hostcalls still prevent a + Native capability claim; footnote 6 records that provider limitation. +13. **Spin streamable response surface — RESOLVED.** The old full-response alias and converter are + removed. `run_app` returns `SpinResponse`, whose spawned coordinator owns the raw WASI response, + body, and result writers and preserves lazy production. The generated project and demo use that + hard-cut signature. The capability remains `BestEffort` only because finite host teardown and + end-client completion are unproved, not because EdgeZero buffers the response. Spin's outbound + request deadline remains independently `BestEffort` per footnote 8. +14. **`outbound-deadlines-exact` capability — not needed.** No adapter reports + `BoundedCooperative` for `outbound-deadlines`; a plain required declaration is currently + accepted only on Axum and hard-fails on Cloudflare, Fastly, and Spin. The support ladder + already expresses the required distinction. +15. **Spin deadline promotion criterion.** The hand-built WASI protocol prevents an + unowned detached pump and the monotonic timer can select a guest-visible 504, but + Component Model cancellation and default `FutureWriter` completion remain + cooperative. A Tier 2 test that observes dropped guest handles is insufficient for + Native. Promotion requires repeatable live-runtime evidence of a documented finite + teardown bound for stalled request upload, response body, and completion-future + paths, followed by an explicit matrix and footnote update. diff --git a/docs/superpowers/specs/2026-06-16-blob-app-config.md b/docs/superpowers/specs/2026-06-16-blob-app-config.md index 0d03c408d..a307e59d9 100644 --- a/docs/superpowers/specs/2026-06-16-blob-app-config.md +++ b/docs/superpowers/specs/2026-06-16-blob-app-config.md @@ -1,12 +1,18 @@ # Blob App Config — Design Spec **Date:** 2026-06-16 -**Status:** v1 — Plan-ready (twenty-three reviewer passes complete; reviewer cleared for plan authoring at round 24) +**Status:** v1 — Plan-ready; typed extraction and bounded-read amendment incorporated 2026-09-08 **Author:** Aram Grigoryan **Related branches:** `feature/extensible-cli` (current baseline) ## v1 changelog +**Cross-contract amendment (2026-09-08):** typed app-config reads now terminate in the +inspectable `StoreExtractionReason` contract from §6.3 and use the one-deadline bounded APIs +from §6.3.2. This amendment supersedes historical changelog entries below that describe +direct `ConfigOutOfDate`/`ServiceUnavailable` variants or unbounded `get`/`require_str` +inside `AppConfig`; those entries remain only as the review history of earlier drafts. + Initial draft after twenty-four review rounds — one author self-review, twenty-three reviewer passes against the current branch's code. Round 24 cleared the spec for plan authoring; @@ -126,12 +132,12 @@ framework-resolved:** distinguish "deploy is incomplete" from "backend is flaky". - **§9.1 Axum reader uses `ConfigStore::get` returning - `Option`.** Previous draft made the extractor parse + `Option`.** Previous draft made the extractor parse the Axum file-map shape directly, bypassing the provider-neutral trait contract. Fixed: the file holds per-key envelope JSON STRINGS, the `AxumConfigStore::get` impl returns the matching string, and the extractor sees - the same `Option` every adapter exposes. + the same `Option` every adapter exposes. - **§3.3.2 secret existence claim dropped.** The earlier draft claimed ` config validate --strict` could verify the named secret exists; the typed validator @@ -392,7 +398,7 @@ ValidationErrorsKind>`. Rewrote the sketch to call example showed envelope OBJECTS as map values; the normative text said each value is an escaped JSON STRING. Picked the string form (matches - `ConfigStore::get -> Option`'s contract across + `ConfigStore::get -> Option`'s contract across all four adapters) and rewrote the §9.1 example to show escaped JSON strings only. The object form was removed. @@ -741,19 +747,18 @@ runtime accuracy + parser fidelity:** to detect any nested reference. §12.17 extended to cover all six generic-wrap shapes plus the multi-line derive case plus the malformed-input case (exit 2). -- **§6.3.1 `EdgeError::config_out_of_date` split into - two constructors.** Round 10 had two contradictory +- **§6.3.1 typed deserialization gained a reason-bearing + constructor.** Round 10 had two contradictory signatures: secret walk called `config_out_of_date(msg, field_path)` (round 10 sketch); §6.3.1 declared the constructor takes a - `serde_path_to_error::Error`. Aligned: two + `serde_path_to_error::Error`. The intermediate draft aligned this with two constructors, `config_out_of_date(message, field_path)` for explicit-pair callers (secret walk + validator - path) and `config_out_of_date_from_serde(err)` for - the deserialise path. Extractor sketch updated to - use the serde constructor; secret-walk + validator - paths use the explicit-pair form. + path) and a serde-specific helper for the deserialise path. The final hard-cut API replaces + the latter with `store_deserialization_from_serde(err)` so typed store extraction always + exposes `StoreExtractionReason::Deserialization`; no compatibility alias remains. - **§3.2.2 + Q10 — `--exit-code` does not mask errors.** Round 10 §12.11 test asserted that without `--exit-code`, a remote-read network @@ -1170,8 +1175,7 @@ validate --help`). populated. Step 5 now wraps `Value::into_deserializer()` with `serde_path_to_error::deserialize` and maps the - error via `config_out_of_date_from_serde` per - the round-11 two-constructor split. Without + error via `store_deserialization_from_serde`. Without this, §12.6's `field_path` assertion would fail. - **Q6 + §12.10 Fastly cap stated as ONE number (64 KiB).** Q6 earlier called it ~8 KiB @@ -2690,30 +2694,58 @@ async fn extract(req: &RequestContext) -> Result where C: DeserializeOwned + AppConfigMeta + Validate + Send + 'static, { - // 1. Fetch the envelope JSON string from the adapter via ConfigStore::get. - // Missing blob maps to ConfigOutOfDate (Q3 (d) per round-18 M-2) — - // re-running ` config push` resolves the case, which is - // exactly what ConfigOutOfDate means. - let raw = config_store.get(&resolved_key).await? - .ok_or_else(|| EdgeError::config_out_of_date( - format!("missing typed app-config blob at key `{resolved_key}` — run ` config push` for this deploy"), - String::new(), - ))?; - let envelope: BlobEnvelope = serde_json::from_str(&raw)?; - envelope.verify_sha()?; + // 1. Fetch under the one extraction-wide byte/time budget (§6.3.2). + let mut budget = ConfigExtractionBudget::start( + req.config_extraction_limits(), + req.monotonic_clock(), + )?; + let read = config_store + .get_bounded( + &resolved_key, + budget.clock(), + budget.deadline(), + budget.remaining_backend_bytes(), + budget.max_blob_bytes(), + ) + .await + .map_err(map_config_store_error)?; + budget.charge_backend(read.backend_bytes)?; + let raw = read.value.ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::MissingBlob, + format!("missing typed app-config blob at key `{resolved_key}` — run ` config push` for this deploy"), + None, + ))?; + budget.charge_value(raw.len())?; + let envelope: BlobEnvelope = serde_json::from_str(&raw).map_err(|_| { + EdgeError::store_extraction( + StoreExtractionReason::MalformedEnvelope, + "typed app-config envelope is invalid", + None, + ) + })?; + envelope.verify_sha().map_err(|_| EdgeError::store_extraction( + StoreExtractionReason::IntegrityMismatch, + "typed app-config integrity check failed", + None, + ))?; let mut data: serde_json::Value = envelope.into_data(); // 2. Walk SECRET_FIELDS. For each KeyInDefault / KeyInNamedStore // entry, look up the named secret store, fetch the value, swap // it into `data[field.name]`. StoreRef entries are untouched. let data_obj = data.as_object_mut() - .ok_or_else(|| EdgeError::internal("blob `data` is not a JSON object"))?; + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::Deserialization, + "blob `data` is not a JSON object", + None, + ))?; for field in C::SECRET_FIELDS { let key_name = data_obj.get(field.name) .and_then(|v| v.as_str()) - .ok_or_else(|| EdgeError::config_out_of_date( + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::Deserialization, format!("missing or non-string value at `{}`", field.name), - field.name.to_owned(), + Some(field.name.to_owned()), ))? .to_owned(); // For KeyInDefault, resolve to a BOUND default store via @@ -2729,14 +2761,15 @@ where let (bound, resolved_store_id) = match field.kind { SecretKind::KeyInDefault => { let bound = req.secret_store_default().ok_or_else(|| { - EdgeError::config_out_of_date( + EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, format!( "secret field `{}` has kind KeyInDefault but \ no default secret store is registered (check \ [stores.secrets].default or declare a single id)", field.name, ), - field.name.to_owned(), + Some(field.name.to_owned()), ) })?; let id = bound.store_name().to_owned(); @@ -2746,22 +2779,46 @@ where SecretKind::KeyInNamedStore { store_ref_field } => { let store_id_str = data_obj.get(store_ref_field) .and_then(|v| v.as_str()) - .ok_or_else(|| EdgeError::config_out_of_date( + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::Deserialization, format!("missing store_ref `{store_ref_field}` for secret field `{}`", field.name), - field.name.to_owned(), + Some(field.name.to_owned()), ))? .to_owned(); let bound = req.secret_store(&store_id_str).ok_or_else(|| { - EdgeError::config_out_of_date( - format!("blob declared store_ref `{store_id_str}` but [stores.secrets] has no such id"), - field.name.to_owned(), + EdgeError::store_extraction( + StoreExtractionReason::UnknownStore, + format!("secret field `{}` references an unregistered store (identifier redacted)", field.name), + Some(field.name.to_owned()), ) })?; (bound, store_id_str) } }; - let secret = bound.require_str(&key_name).await + let read = bound + .get_bytes_bounded( + &key_name, + budget.clock(), + budget.deadline(), + budget.remaining_backend_bytes(), + budget.max_secret_bytes(), + ) + .await .map_err(|err| map_secret_error(err, field.name, &resolved_store_id, &key_name))?; + budget.charge_backend(read.backend_bytes)?; + let secret_bytes = read.value.ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::MissingSecret, + format!("the secret referenced by `{}` was not found in its store (identifier redacted)", field.name), + Some(field.name.to_owned()), + ))?; + budget.charge_value(secret_bytes.len())?; + let secret = String::from_utf8(secret_bytes.to_vec()).map_err(|_| { + EdgeError::store_extraction( + StoreExtractionReason::InvalidSecretValue, + format!("the secret referenced by `{}` is not valid UTF-8", field.name), + Some(field.name.to_owned()), + ) + })?; data_obj.insert(field.name.to_owned(), serde_json::Value::String(secret)); } @@ -2769,7 +2826,7 @@ where // the field-path) + validate. use serde::de::IntoDeserializer as _; let cfg: C = serde_path_to_error::deserialize(data.into_deserializer()) - .map_err(EdgeError::config_out_of_date_from_serde)?; + .map_err(EdgeError::store_deserialization_from_serde)?; cfg.validate().map_err(|err| { // The secret walk above replaced `#[secret]` fields with their // RESOLVED values, and `validator`'s params echo the rejected value, @@ -2781,7 +2838,11 @@ where } else { format!("app config failed validation for field `{field}`") }; - EdgeError::config_out_of_date(message, field) + EdgeError::store_extraction( + StoreExtractionReason::Validation, + message, + (!field.is_empty()).then_some(field), + ) })?; Ok(cfg) } @@ -2853,30 +2914,21 @@ RESOLVED value, populated by the extractor's walk. #### 3.3.6 What this means for failure modes -Current `SecretError` -(`crates/edgezero-core/src/secret_store.rs:113`) has four -variants: `NotFound`, `Validation`, `Internal`, `Unavailable`. -`require_str` (line 269) maps invalid-UTF-8 bytes from the -store to `SecretError::Internal`. The extractor wraps each -into an `EdgeError` variant based on what action the operator -can take. The mapping below is comprehensive — every -`SecretError` variant has a documented landing. - -| Extractor failure | `SecretError` | `EdgeError` | Why | -| -------------------------------------------------- | -------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Secret-store ID unknown (no `[stores.secrets].id`) | none — caught before secret call | `ConfigOutOfDate` | Manifest declares the wrong store name OR the manifest wasn't redeployed. Re-push fixes it. | -| Secret key not found in the named store | `SecretError::NotFound` | `ConfigOutOfDate` | Operator forgot to provision the secret value. Re-run secret provisioning. | -| Store rejected the key shape (length, charset) | `SecretError::Validation { .. }` | `ConfigOutOfDate` | The blob's key name is invalid for the named store (e.g. Fastly Secret Store keys are constrained). Operator either renames the secret-store key or fixes the `.toml` value. | -| Secret value is bytes, not UTF-8 | `SecretError::Internal` (from `require_str`) | `Internal` | The store CONTAINS the key but the bytes aren't a `String`. Data quality at rest; not deploy-related. Operator audits the secret-store entry directly. | -| Secret store unreachable (transient network) | `SecretError::Unavailable` | `ServiceUnavailable` | A flaky backend is not an out-of-date config — retry is the right response. Surfaces as HTTP 503; per §6.3.1 the `Retry-After: 60` header is NOT set on `ServiceUnavailable` in v1 (audit of existing producers showed too many non-retryable cases reuse the variant). | -| Any other `SecretError::Internal` | `SecretError::Internal` | `Internal` | Unexpected store-side failure (an adapter bug, a wire-format change). Pages oncall; not actionable by deploy. | - -The boundary between `ConfigOutOfDate` and `Internal` is: -**does re-running ` config push` (or its sibling -secret-provisioning step) fix the situation?** If yes → -`ConfigOutOfDate`. If no → `Internal`. The invalid-UTF-8 case -falls on the wrong side of that boundary (re-push doesn't fix -non-string bytes in the store), so it stays on `Internal`. +Bounded extraction extends `SecretError` with `DeadlineExceeded` and `ValueTooLarge`. +The extractor wraps every lower-level outcome in `EdgeError::StoreExtraction`; the stable +reason remains inspectable even when several reasons share one wire kind. + +| Extractor failure | Lower-level outcome | `StoreExtractionReason` | HTTP / kind | +| --- | --- | --- | --- | +| No default secret registry | caught before read | `MissingRegistry` | 500 / `internal` | +| Referenced named store is unknown | caught before read | `UnknownStore` | 500 / `internal` | +| Secret key not found | bounded read returns `value: None` | `MissingSecret` | 503 / `config_out_of_date` | +| Store rejects key shape | `SecretError::Validation(..)` | `InvalidKey` | 400 / `bad_request` | +| Secret bytes are not UTF-8 | caught after bounded byte read | `InvalidSecretValue` | 500 / `internal` | +| Secret store unavailable | `SecretError::Unavailable` | `SecretBackendUnavailable` | 503 / `service_unavailable` | +| Bounded read deadline expires | `SecretError::DeadlineExceeded` | `DeadlineExceeded` | 503 / `service_unavailable` | +| Per-secret or cumulative byte cap is exceeded | `SecretError::ValueTooLarge` or budget charge | `ValueTooLarge` | 500 / `internal` | +| Any other backend failure | `SecretError::Internal(..)` | `BackendFailure` | 500 / `internal` | The extractor's wrapper around the secret-store call materialises this mapping ONCE near the call site: @@ -2884,10 +2936,12 @@ materialises this mapping ONCE near the call site: ```rust // Matches the actual SecretError shape at // crates/edgezero-core/src/secret_store.rs:113: +// DeadlineExceeded // unit, added by §6.3.2 // Internal(#[from] anyhow::Error) // NotFound { name: String } // struct-like // Unavailable // unit // Validation(String) // tuple +// ValueTooLarge // unit, added by §6.3.2 // SECURITY: the stored key NAME, the store id, and the provider's // message/source are deliberately UNUSED. They are blob- or // provider-controlled strings that can reveal the secret or the @@ -2902,38 +2956,43 @@ fn map_secret_error( _key_name: &str, ) -> EdgeError { match err { - SecretError::NotFound { .. } => EdgeError::config_out_of_date( + SecretError::DeadlineExceeded => EdgeError::store_extraction( + StoreExtractionReason::DeadlineExceeded, + format!("secret resolution for `{field_name}` exceeded its deadline"), + Some(field_name.to_owned()), + ), + SecretError::Internal(_source) => EdgeError::store_extraction( + StoreExtractionReason::BackendFailure, + format!("secret resolution for `{field_name}` failed (details redacted)"), + Some(field_name.to_owned()), + ), + SecretError::NotFound { .. } => EdgeError::store_extraction( + StoreExtractionReason::MissingSecret, format!("the secret referenced by `{field_name}` was not found in its store (identifier redacted)"), - field_name.to_owned(), + Some(field_name.to_owned()), + ), + SecretError::Unavailable => EdgeError::store_extraction( + StoreExtractionReason::SecretBackendUnavailable, + format!("the secret store for `{field_name}` is unreachable"), + Some(field_name.to_owned()), ), - SecretError::Validation(_msg) => EdgeError::config_out_of_date( + SecretError::Validation(_msg) => EdgeError::store_extraction( + StoreExtractionReason::InvalidKey, format!("the secret referenced by `{field_name}` was rejected by its store (details redacted)"), - field_name.to_owned(), + Some(field_name.to_owned()), + ), + SecretError::ValueTooLarge => EdgeError::store_extraction( + StoreExtractionReason::ValueTooLarge, + format!("the secret referenced by `{field_name}` exceeds its configured byte limit"), + Some(field_name.to_owned()), ), - SecretError::Unavailable => EdgeError::service_unavailable(format!( - "the secret store for `{field_name}` is unreachable" - )), - SecretError::Internal(_source) => EdgeError::internal(anyhow::anyhow!( - "secret resolution for `{field_name}` failed (details redacted)" - )), } } ``` -**`Retry-After: 60` is NOT set on `ServiceUnavailable` in -v1.** An earlier draft extended the header from -`ConfigOutOfDate` to `ServiceUnavailable`; round-10 audit -of existing producers (KV size limits at -`crates/edgezero-core/src/key_value_store.rs:708`, missing -named KV store at -`examples/app-demo/.../handlers.rs:185`, missing default -secret store at `.../handlers.rs:285`) found that the -variant is reused for several non-retryable failure modes -where the header would mislead clients into a tight retry -loop. §6.3.1 documents the narrowed rule in detail: -header on `ConfigOutOfDate` ONLY; future v2 work may -split `ServiceUnavailable` so each producer site picks -the right variant. +`Retry-After: 60` is emitted only for the effective `config_out_of_date` kind. In this +table that is `MissingSecret`; `SecretBackendUnavailable` and `DeadlineExceeded` are +`service_unavailable` without a retry header. §6.3.1 centralizes that policy. #### 3.3.7 Sha-canonicalisation interaction @@ -3070,12 +3129,12 @@ extractor doesn't touch TOML on disk at all): | --------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `config push` / `config diff` / generated CLI `config validate` | `.toml` on disk | `deserialize_app_config_with_options(path, app_name, opts)` → `validate_excluding_secrets(&cfg)` → §3.3.2 structural checks | | Bundled raw `edgezero config validate` | `.toml` on disk | `load_app_config_raw(path, app_name)` (TOML round-trip, no `C`, no validators) | -| Runtime extractor (`AppConfig`) | Envelope JSON STRING from `ConfigStore::get(key)` | envelope parse → SHA verify → secret walk (per §3.3.3) → `serde_path_to_error::Deserializer` over the JSON `data` field → `Validate::validate(&cfg)`. The runtime path does NOT load TOML and does NOT apply the env overlay; the env overlay is a CLI-side notion that the operator's push run resolved into the blob. | +| Runtime extractor (`AppConfig`) | Envelope JSON string from `ConfigStore::get_bounded(key, clock, deadline, caps)` | bounded read → envelope parse → SHA verify → bounded secret walk (per §3.3.3) → `serde_path_to_error::Deserializer` over the JSON `data` field → `Validate::validate(&cfg)`. The runtime path does not load TOML and does not apply the env overlay; the operator's push run resolves that CLI-side overlay into the blob. | The CLI paths all share a `build_and_validate` helper in `crates/edgezero-cli/src/config.rs` (sketch in §3.3.2). The runtime extractor's path is sketched in §3.3.3 and -uses the §6.3.1 `EdgeError::config_out_of_date_from_serde` +uses the §6.3.1 `EdgeError::store_deserialization_from_serde` constructor for serde failures (preserving the `field_path` from `serde_path_to_error`). @@ -3345,7 +3404,7 @@ the rule honest, the `#[derive(AppConfig)]` macro enforces: the canonicaliser's "sort keys by UTF-8 byte order of the field identifier" rule (the field identifier isn't the key any more) and complicates `serde_path_to_error` - paths in `EdgeError::ConfigOutOfDate`. Operators who + paths in `EdgeError::StoreExtraction`. Operators who need a flat shape define it explicitly. - **`#[serde(rename_all)]` is already rejected by the existing per-secret-field policy at @@ -3511,15 +3570,10 @@ version, .. }`. Unknown envelope versions error with a pointer at the migration guide. 2. Recompute `canonical_data_sha256(&data)`. 3. If it doesn't match the stored `sha256`, return - `ConfigStoreError::internal("blob sha mismatch: stored {hex} -!= computed {hex}")` (the existing `internal(...)` - constructor at `config_store.rs:180`; see §6.3 for the - reasoning behind keeping this on `Internal` instead of - adding a new variant) and DO NOT proceed. The runtime - gives up on this request rather than silently honouring a - tampered blob. The extractor maps - `ConfigStoreError::Internal` to `EdgeError::Internal` per - existing convention. + `EdgeError::StoreExtraction { reason: IntegrityMismatch, .. }` and DO NOT proceed. The + runtime gives up on this request rather than silently honouring a tampered blob. This is + an extractor-owned integrity decision, not a generic `ConfigStoreError::Internal`, so + callers retain the stable origin without parsing a diagnostic. 4. **Secret walk (per §3.3.3).** Iterate over `C::SECRET_FIELDS`. For each `KeyInDefault` or `KeyInNamedStore` field, look up `data[field.name]` in the @@ -3533,16 +3587,15 @@ version, .. }`. Unknown envelope versions error with a `serde_json::Value::into_deserializer()` (NOT `serde_json::from_value` directly — that path discards the field-path information that - `EdgeError::ConfigOutOfDate.field_path` requires per + `EdgeError::StoreExtraction.field_path` requires per §6.3.1). The wrapper accumulates the JSON path of any deserialise failure (e.g. `"feature.new_checkout"`); the extractor maps the resulting `serde_path_to_error::Error` to - `EdgeError::config_out_of_date_from_serde(err)` per - §6.3.1's two-constructor split, which populates + `EdgeError::store_deserialization_from_serde(err)` per + §6.3.1, which sets reason `Deserialization` and populates `field_path` from `err.path()`. Without this wrapper, a - schema mismatch surfaces as a generic - `EdgeError::ConfigOutOfDate { message, field_path: "" }` + schema mismatch surfaces as a generic untyped error without field context and §12.6's field_path assertion fails. 6. Run `Validate::validate(&cfg)` per §6.2.2 — full validation including secret-bearing fields, now that they hold @@ -3823,6 +3876,16 @@ fallback is centralised in `EnvConfig` so all four adapters share identical behaviour. +A declared config id remains present in the registry when its provider binding +cannot be opened. Cloudflare and Fastly retain a typed failing handle rather +than dropping the id; extraction reports `BackendUnavailable` or +`BackendFailure`, respectively, not `MissingRegistry` or `MissingBlob`. +Provider diagnostics stay in adapter logs, not the public error message. +Spin binds labels without opening them during dispatch. It opens the store +inside `get_bounded`, after ingress admission and within the extraction timer +race, so an unavailable or never-ready config open cannot block unrelated +routes before admission. This bounds guest return, not provider cancellation. + **Extractor consumption.** The extractor reads through the registry: @@ -3831,21 +3894,46 @@ async fn extract(req: &RequestContext, override_key: Option<&str>) -> Result< where C: DeserializeOwned + AppConfigMeta + Validate + Send + 'static, { + let mut budget = ConfigExtractionBudget::start( + req.config_extraction_limits(), + req.monotonic_clock(), + )?; let binding = req .config_store_default_binding() - .ok_or_else(|| EdgeError::internal("no default config store registered"))?; + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::MissingRegistry, + "no default config store registered", + None, + ))?; let key = override_key.unwrap_or(&binding.default_key); - // Missing blob maps to ConfigOutOfDate per §6.3 / Q3 (d) — a re-run - // of ` config push` is the actionable response, not a 500. - let raw = binding.handle.get(key).await? // ConfigStore::get - .ok_or_else(|| EdgeError::config_out_of_date( + let read = binding + .handle + .get_bounded( + key, + budget.clock(), + budget.deadline(), + budget.remaining_backend_bytes(), + budget.max_blob_bytes(), + ) + .await + .map_err(map_config_store_error)?; + budget.charge_backend(read.backend_bytes)?; + let raw = read.value + .ok_or_else(|| EdgeError::store_extraction( + StoreExtractionReason::MissingBlob, format!("missing typed app-config blob at key `{key}` — run ` config push` for this deploy"), - String::new(), + None, ))?; - // ... envelope parse, sha verify, secret walk, deserialise + validate per §4.3 + budget.charge_value(raw.len())?; + // ... envelope parse, sha verify, secret walk with &mut budget, + // deserialise + validate per §4.3 } ``` +`ConfigExtractionBudget` is extractor-private state implementing §6.3.2. The secret walk +receives `&budget`; it passes the same absolute deadline to every bounded secret read and +charges each returned value before insertion. + `RequestContext` grows two helpers mirroring the existing `config_store_default()` / `config_store(id)`: @@ -4052,8 +4140,8 @@ validate --strict` runs at push time, so env-overlay drift Once the extractor deserialises `data` into `C`, it calls `Validate::validate(&cfg)`. Validation failures map to: -- `EdgeError::ConfigOutOfDate` — naming ONLY the field that - violated its constraint. Same surface as the +- `EdgeError::StoreExtraction` with reason `Validation`, naming ONLY the field that + violated its constraint. It has the same wire surface as the deserialise-failure case (§6.3): operator action is "re-push the typed config; the deployed `.toml` is out of bounds for the deployed code". @@ -4125,67 +4213,122 @@ key)`. ### 6.3 Errors -The extractor surfaces: +The typed classification is fixed before extractor implementation. Callers never parse +`message` to discover an extraction failure's origin: + +```rust +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum StoreExtractionReason { + BackendFailure, + BackendUnavailable, + DeadlineExceeded, + Deserialization, + IntegrityMismatch, + InvalidKey, + InvalidSecretValue, + MalformedEnvelope, + MissingBlob, + MissingRegistry, + MissingSecret, + SecretBackendUnavailable, + StructuralLimit, + UnknownStore, + UnsupportedVersion, + Validation, + ValueTooLarge, +} + +// One EdgeError variant delegates status/kind/header policy to `reason`. +StoreExtraction { + reason: StoreExtractionReason, + message: String, + field_path: Option, +} +``` -- `EdgeError::ServiceUnavailable` — store unreachable, network +`StoreExtractionReason` and the containing `EdgeError` variant are `#[non_exhaustive]`. +The reason survives every `AppConfig`/`from_store`/secret-walk mapping and is available +through `EdgeError::store_extraction_reason() -> Option` without a +downcast. It is Rust-side diagnostic metadata only: the response JSON does not serialize a +`reason` field. `field_path` is `Some` only when the failure is tied to a typed-config path; +an empty path is represented as `None` and omitted from JSON. + +The mapping is total and centralized in `EdgeError::{status_code, kind, response_headers}`: + +| Reason | HTTP / kind | `Retry-After` | `field_path` | +| --- | --- | --- | --- | +| `BackendUnavailable`, `DeadlineExceeded`, `SecretBackendUnavailable` | 503 / `service_unavailable` | absent | optional only for a secret field | +| `Deserialization`, `MissingBlob`, `MissingSecret`, `Validation` | 503 / `config_out_of_date` | `60` | optional for typed-data/validation/secret failures; absent for a missing root blob | +| `InvalidKey` | 400 / `bad_request` | absent | absent | +| `BackendFailure`, `IntegrityMismatch`, `InvalidSecretValue`, `MalformedEnvelope`, `MissingRegistry`, `StructuralLimit`, `UnknownStore`, `UnsupportedVersion`, `ValueTooLarge` | 500 / `internal` | absent | optional only when a typed field selected the store/value | + +`ValueTooLarge` covers any per-blob, per-secret, or cumulative extraction byte cap from +§6.3.2; `DeadlineExceeded` covers the one absolute extraction deadline. A source error that +arrives simultaneously with deadline expiry is classified as `DeadlineExceeded`. +`StructuralLimit` covers a config-envelope JSON node or depth cap reached before either +generic future-format or typed-envelope materialization. Invalid JSON without a breached +structure cap remains `MalformedEnvelope`. Neither category includes stored values in diagnostics. +Tests enumerate every known reason, assert status/kind/header/field-path policy, and prove +the JSON envelope omits the typed reason. + +The extractor surfaces the following wire outcomes through `EdgeError::StoreExtraction`: + +- `service_unavailable` — store unreachable, network errors. Maps to HTTP 503. -- `EdgeError::ConfigOutOfDate { message, field_path }` — +- `config_out_of_date` — typed-struct deserialise failure: the blob is present and the envelope parses, but `data` doesn't fit the runtime's `C` type. Almost always means "the code shipped before the matching ` config push`". See the contract below. -- `EdgeError::Internal` — sha mismatch (drift / corruption), +- `internal` — sha mismatch (drift / corruption), envelope parse failure (envelope `version` unrecognised or shape unexpected). Maps to HTTP 500. These are genuinely unexpected and should page the operator. -- **Key missing from the store** (i.e. - `ConfigStore::get(key)` returned `Ok(None)`) maps to - `EdgeError::ConfigOutOfDate` (Q3 (d) per round-18 - M-2, restated here for §6.3 hard-cutoff). HTTP 503 - with `Retry-After: 60`. Message: `missing typed -app-config blob at key \`\` — run \` - config push\` for this deploy`. Rationale: a missing -typed-app-config blob is operationally -indistinguishable from "the operator didn't run -`config push`yet" — which is exactly the`ConfigOutOfDate`class ("re-run config push fixes -it"). Mapping to`Internal`would page oncall on a -push-fixable condition; mapping to`NotFound`(404) -would imply the URL is wrong, which it isn't. A -future`MaybeAppConfig`→`Option`extractor -(Q3 (c)) could remap this for endpoints that want -explicit defaults; v1 ships with`ConfigOutOfDate` - and no opt-out. +- **Key missing from the store** (`get_bounded` returned `value: None`) becomes + `StoreExtractionReason::MissingBlob`, whose effective outcome is + `config_out_of_date`: HTTP 503 with `Retry-After: 60`. Message: + `missing typed app-config blob at key \`\` — run \` config push\` for + this deploy`. A missing typed-config blob is operationally indistinguishable from a + skipped config push. Mapping it to `internal` would page on-call for a push-fixable + condition; mapping it to `not_found` would imply the request URL is wrong. A future + `MaybeAppConfig -> Option` extractor could provide explicit defaults; v1 has no + opt-out. **Implementation note — `ConfigStoreError` to `EdgeError` -mapping.** `ConfigStoreError` (`config_store.rs:165`) has only -three variants today: `Internal`, `InvalidKey`, `Unavailable`. -The extractor maps: - -| ConfigStoreError | EdgeError | HTTP | Notes | -| ------------------------------------- | -------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Unavailable` | `ServiceUnavailable` | 503 | Transient backend issue. | -| `Internal` (sha mismatch) | `Internal` | 500 | Drift or corruption — the stored sha doesn't match canonical recompute. | -| `Internal` (envelope parse failure) | `Internal` | 500 | Envelope `version` unrecognised or shape unexpected. | -| `InvalidKey` | `BadRequest` | 400 | Adapter rejected the key shape. | -| _missing key_ (`Ok(None)` from `get`) | `ConfigOutOfDate` | 503 | NOT a `ConfigStoreError` variant — caught at the extractor's `ok_or_else` after `ConfigStore::get` returns `Ok(None)`. Round-18 M-2 reversal: was `Internal` in earlier drafts; the new mapping matches Q3 (d) + §3.3.3's extractor sketch. | - -Plus the new `ConfigOutOfDate` variant `EdgeError` gains as part -of this work; no new variant on `ConfigStoreError` is needed. - -**Why `ConfigOutOfDate` is separate from `Internal`:** the -operator's response is different. `Internal` means "investigate -what's wrong with our store / blob"; `ConfigOutOfDate` means -"re-run ` config push` for the deployed code -revision". A single generic 500 conflates both and trains -operators to ignore the class of error that's most actionable. - -### 6.3.1 `ConfigOutOfDate` concrete contract - -The current `EdgeError` response shape carries `status` + -`message` only (`crates/edgezero-core/src/error.rs:159`). The -blob model needs more structure so dashboards can route -`ConfigOutOfDate` to a different oncall than generic 503s. -Two specific extensions: +mapping.** The current three variants are `Internal`, `InvalidKey`, and `Unavailable`; +§6.3.2 adds `DeadlineExceeded` and `ValueTooLarge` for bounded reads. The extractor maps: + +| ConfigStore outcome | Store extraction reason | HTTP | Notes | +| ------------------------------------- | ----------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Unavailable` | `BackendUnavailable` | 503 | Transient backend issue. | +| `DeadlineExceeded` | `DeadlineExceeded` | 503 | The shared absolute extraction deadline expired; expiry wins simultaneous readiness. | +| SHA mismatch | `IntegrityMismatch` | 500 | Drift or corruption — the stored sha doesn't match canonical recompute. | +| Envelope parse/shape failure | `MalformedEnvelope` | 500 | The stored value is not a syntactically valid supported envelope. | +| Envelope version/discriminator failure | `UnsupportedVersion` | 500 | The envelope `version` or `edgezero_kind` is not understood by this build; redeploy an updated build. | +| `InvalidKey` | `InvalidKey` | 400 | Adapter rejected the key shape. | +| `Internal` | `BackendFailure` | 500 | Unexpected backend/adapter failure whose lower layer cannot classify more narrowly. | +| `ValueTooLarge` | `ValueTooLarge` | 500 | Per-value or remaining backend-byte allowance was exceeded. | +| _missing key_ (`value: None` from `get_bounded`) | `MissingBlob` | 503 | NOT a `ConfigStoreError` variant — caught at the extractor's `ok_or_else` after the bounded read returns no value. Round-18 M-2 reversal: was `Internal` in earlier drafts; the new mapping matches Q3 (d) + §3.3.3's extractor sketch. | + +`ConfigStoreError` does not duplicate semantic envelope/schema/registry reasons. It exposes +only failures owned by the backend read boundary. The extractor converts each lower-level +store/secret result once at its ownership boundary and returns the typed +`EdgeError::StoreExtraction` end state. + +**Why the `config_out_of_date` kind is separate from `internal`:** the operator's response +is different. `internal` means "investigate the store/blob/runtime"; +`config_out_of_date` means "re-run ` config push` or provision the referenced +secret for the deployed revision." The typed reason preserves the more precise origin +inside each wire class. + +### 6.3.1 `config_out_of_date` concrete wire contract + +The `config_out_of_date` outcome applies both to the existing +`EdgeError::ConfigOutOfDate` variant and to `EdgeError::StoreExtraction` +when its reason maps to that kind in §6.3. The blob model needs enough +wire structure for dashboards to route this outcome differently from +generic 503s. Three specific extensions: **1. Response-body shape.** Today's `EdgeError::IntoResponse` at `crates/edgezero-core/src/error.rs:159` writes: @@ -4225,7 +4368,7 @@ Concretely: ONLY on `config_out_of_date` (and any future field-anchored variant). Other variants omit it. Clients that don't care can ignore. -- Status code: 503 for `ConfigOutOfDate` (not 500). The +- Status code: 503 for the `config_out_of_date` outcome (not 500). The semantic is "service can't honor this request because the deployed config + code are out of sync; retry after redeploy". @@ -4236,8 +4379,9 @@ to the response body — operators who parse the body shape on the client side need to update. Per §1's hard-cutoff stance, no compat shim. -**2. Send `Retry-After: 60`** on the new `ConfigOutOfDate` -variant ONLY. Earlier drafts extended the header to all +**2. Send `Retry-After: 60`** only when the effective kind is +`config_out_of_date`. This includes `ConfigOutOfDate` and the four +store-extraction reasons mapped to that kind in §6.3. Earlier drafts extended the header to all `ServiceUnavailable` responses; an audit of in-tree `ServiceUnavailable` producers shows that the variant is ALREADY used for several non-config-related conditions @@ -4255,7 +4399,8 @@ Adding `Retry-After: 60` to every `ServiceUnavailable` would lie to clients in three of four cases. The blob model takes the narrower stance: -- **`ConfigOutOfDate`** — header sent. The new variant is +- **`ConfigOutOfDate` or a `StoreExtraction` reason mapped to + `config_out_of_date`** — header sent. This outcome is shaped specifically for "deployed config + code are out of sync; redeploy converges in <60s", which is exactly what the header tells clients. @@ -4268,14 +4413,13 @@ model takes the narrower stance: each producer site picks the right variant; out of scope here. -Added via -`Response::headers_mut().insert("retry-after", -HeaderValue::from_static("60"))` inside the -`IntoResponse` impl branch for `ConfigOutOfDate` only. -Other variants (`ServiceUnavailable`, `Internal`, -`BadRequest`, etc.) do NOT set the header. +Added via the centralized error response-header policy rather than a +variant-only `matches!` check. Other outcomes (`service_unavailable`, +`internal`, `bad_request`, etc.) do NOT set the header. Application custom error rendering may +replace the response body but cannot remove this mandatory header; EdgeZero reapplies the +effective `config_out_of_date` policy after the callback returns. -**3. `field_path` for the variant's payload** comes from +**3. `field_path` for the error payload** comes from `serde_path_to_error::Track::path()` (or equivalent), wrapped around the blob's `data` field's deserialiser. Plain serde errors only report position-in-input, not field-path; the @@ -4284,8 +4428,8 @@ errors only report position-in-input, not field-path; the the deserialise itself. The dep is small (~500 LOC, no transitive deps) and locked-in for the variant. -> **Runtime redaction (security).** In the HTTP `ConfigOutOfDate` -> response the path's STRING segments are redacted to `` +> **Runtime redaction (security).** In an HTTP `config_out_of_date` response produced by +> `StoreExtractionReason::Deserialization`, the path's STRING segments are redacted to `` > while structure (dots and sequence indices) is kept — e.g. > `.`. A `serde_path_to_error` segment for a struct > field is indistinguishable from a MAP KEY, and a map key is stored @@ -4296,11 +4440,10 @@ transitive deps) and locked-in for the variant. > secret-walk/validator constructor still supplies an explicit static > path, since those are code-supplied field names, not stored data. -**Variant declaration (sketch).** Two constructors — -one for the serde-deserialise path (which has rich -field-path data from `serde_path_to_error`), one for -the secret-walk and validator paths (which already -have an explicit `(message, field_path)` pair): +**Variant declaration (sketch).** The fixed `ConfigOutOfDate` variant remains available +for non-store producers. Typed store extraction uses the reason-bearing variant from §6.3 +and two constructors: one for explicit path/reason data and one for the serde-deserialise +path: ```rust pub enum EdgeError { @@ -4309,22 +4452,23 @@ pub enum EdgeError { message: String, // e.g. "missing field `new_checkout`" field_path: String, // e.g. "feature" }, + StoreExtraction { + reason: StoreExtractionReason, + message: String, + field_path: Option, + }, } impl EdgeError { - /// Construct from an explicit (message, field_path) pair. - /// Used by the secret walk (§3.3.3) and the validator path - /// (§6.2.2). `field_path` SHOULD be a dotted path naming - /// the offending field (e.g. `"feature.new_checkout"`); - /// pass `String::new()` when no specific field is anchored - /// (the response simply omits the `field_path` key). - pub fn config_out_of_date( + pub fn store_extraction( + reason: StoreExtractionReason, message: impl Into, - field_path: impl Into, + field_path: Option, ) -> Self { - Self::ConfigOutOfDate { + Self::StoreExtraction { + reason, message: message.into(), - field_path: field_path.into(), + field_path, } } @@ -4342,7 +4486,7 @@ impl EdgeError { /// when the local TOML still matches what was deployed — it /// reads the local source, not the deployed blob, so a drift /// between them is not recoverable this way. - pub fn config_out_of_date_from_serde( + pub fn store_deserialization_from_serde( serde_err: serde_path_to_error::Error, ) -> Self { let category = match serde_err.inner().classify() { @@ -4351,9 +4495,10 @@ impl EdgeError { Category::Eof => "unexpected end of input", Category::Io => "i/o error while reading", }; - Self::ConfigOutOfDate { + Self::StoreExtraction { + reason: StoreExtractionReason::Deserialization, message: format!("typed app-config is out of date ({category}; value redacted)"), - field_path: redact_serde_path(serde_err.path()), + field_path: Some(redact_serde_path(serde_err.path())), } } } @@ -4362,9 +4507,9 @@ impl EdgeError { Caller sites: - Secret walk (§3.3.3) and validator path (§6.2.2): - `EdgeError::config_out_of_date(msg, field_path)`. + `EdgeError::store_extraction(reason, msg, Some(field_path))`. - Blob deserialise (§3.3.3, §6.2.2): - `EdgeError::config_out_of_date_from_serde(err)`. + `EdgeError::store_deserialization_from_serde(err)`. The validator path (§6.2.2) wraps a `validator::ValidationErrors`. **On the RUNTIME path this runs @@ -4382,7 +4527,11 @@ let message = if field.is_empty() { } else { format!("app config failed validation for field `{field}`") }; -EdgeError::config_out_of_date(message, field) // NO validation_err.to_string() +EdgeError::store_extraction( + StoreExtractionReason::Validation, + message, + (!field.is_empty()).then_some(field), +) // NO validation_err.to_string() ``` The `extract_first_field` helper picks the first @@ -4396,10 +4545,214 @@ is dropped entirely rather than logged. mentioning because PR #269 fought several "do we add this dep" rounds. Verdict: yes, this one is small enough, single purpose, and the only realistic way to give operators a -useful field-path hint. Without it, every `ConfigOutOfDate` +useful field-path hint. Without it, every schema-mismatch response says "missing field" with no anchor — operators have to grep the typed struct manually. +### 6.3.2 Bounded, capability-scoped extraction reads + +Typed app-config extraction never calls the unbounded convenience methods +`ConfigStoreHandle::get` or `SecretHandle::get_bytes`. It uses bounded variants under one +budget captured when the extractor starts: + +```rust +pub const DEFAULT_CONFIG_BLOB_BYTES: u64 = 8 * 1024 * 1024; +pub const DEFAULT_CONFIG_BACKEND_BYTES: u64 = 16 * 1024 * 1024; +pub const DEFAULT_CONFIG_EXTRACTION_BYTES: u64 = 16 * 1024 * 1024; +pub const DEFAULT_CONFIG_EXTRACTION_TIMEOUT: Duration = Duration::from_secs(30); +pub const DEFAULT_CONFIG_JSON_DEPTH: u32 = 64; +pub const DEFAULT_CONFIG_JSON_NODES: u32 = 8_192; +pub const DEFAULT_CONFIG_SECRET_BYTES: u64 = 1024 * 1024; + +#[derive(Clone, Copy, Debug)] +pub struct ConfigExtractionLimits { + pub max_backend_bytes: u64, + pub max_blob_bytes: u64, + pub max_json_depth: u32, + pub max_json_nodes: u32, + pub max_secret_bytes: u64, + pub max_total_bytes: u64, + pub timeout: Duration, +} + +pub struct BoundedStoreRead { + pub backend_bytes: u64, + pub value: Option, +} + +#[async_trait(?Send)] +pub trait ConfigStore: Send + Sync { + // Required: there is no cooperative default that can hide a never-ready provider future. + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError>; + + // Preserved for hand-managed reads; it makes no extraction-bound claim. + async fn get(&self, key: &str) -> Result, ConfigStoreError>; +} + +#[async_trait(?Send)] +pub trait SecretStore: Send + Sync { + // Required for the same reason as ConfigStore::get_bounded. + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError>; + + // Existing unbounded convenience method, not used by AppConfig. + async fn get_bytes( + &self, + store_name: &str, + key: &str, + ) -> Result, SecretError>; +} + +impl ConfigStoreHandle { + pub async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError>; +} + +impl SecretHandle { + pub async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError>; +} + +impl BoundSecretStore { + pub async fn get_bytes_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError>; +} +``` + +`ConfigValue` is independently owned shared UTF-8 (`Arc`), never a slice retaining +the entire store file. Cloning shares the payload; `as_str()`/`as_ref()` borrow it, +`to_string()` deliberately copies it, and `Debug` is content-redacted. This is a hard +trait migration across all adapters, fixtures and consumers, not a compatibility layer. +Provider `String` to shared-payload conversion overlaps both allocations and must be +accounted separately. Cloudflare/Spin check size before conversion and expiry afterward; +Fastly preserves cumulative root/chunk accounting through the same terminal check. + +The bounded methods add `DeadlineExceeded` and `ValueTooLarge` to both lower-level store +error enums. Those variants are produced at the adapter boundary without formatting away +the cause and map directly to the same-named extraction reasons (`ValueTooLarge` maps to +`StoreExtractionReason::ValueTooLarge`). Existing unbounded methods need not synthesize +these variants. + +`ConfigExtractionLimits::default()` returns the seven constants above. The JSON scanner +counts the root and every nested value, including array/object containers; the root has +depth 1. A streaming `serde_json` visitor enforces both caps before either full JSON parse, +without retaining a `Value` tree. Exact-cap structures are accepted. Depth must be in +`1..=127` (below the parser's recursion ceiling), and node count must be nonzero. +These caps bound the parsed tree's structural amplification; they do not bound transient +string decoding, the provider-owned read buffer, sorted-key scratch, or total process RSS. Canonical hashing streams directly into SHA-256 without a full output-string buffer. + +`App` owns one config-extraction-limits value, set through the fallible +`Hooks::configure(&mut App) -> Result<(), EdgeError>` callback; the core-owned +`App::build::(platform)` prevents request conversion, admission, body polling, or dispatch when validation fails (and prevents +listener bind on Axum), while the adapter copies a successfully validated value into +`RequestContext`. All values must be finite and nonzero, and +`max_total_bytes >= max(max_blob_bytes, max_secret_bytes)`. `timeout` must not exceed +`DEADLINE_FAR_FUTURE`; invalid startup configuration fails before serving requests. + +At the first `AppConfig`/`named`/`from_store` call, core clones the +application-owned `MonotonicClock` from `RequestContext`, snapshots +`extraction_started_at = clock.now()`, and computes exactly one absolute deadline with +checked `extraction_started_at + limits.timeout` and `Deadline::at_instant(..)`. It does +not call `Deadline::after` or sample the process-global clock after the first snapshot. +The deadline is not reset between the root blob read, +Fastly pointer/chunk reads, secret resolution, deserialization, or validation. Every adapter +receives that same `&MonotonicClock` on every bounded call and checks the deadline before +entering a host read and after the host read becomes ready. The post-ready clock check occurs +before applying `?` or otherwise propagating the provider result, so equality expiry wins a +simultaneously ready success or provider error. Fastly uses the same clock for the root read, +each pointer chunk, and every synchronous secret-store open/lookup/plaintext operation. Equality +is expired. Cloudflare KV and Spin KV/variable reads additionally race the guest-visible provider +future against a runtime-native timer for the remaining absolute budget. Because a runtime timer +may wake early or round its duration, EdgeZero resamples the injected clock when the timer resolves +and re-arms it for the new remaining duration unless the absolute deadline is expired. At equality, +timer readiness wins a simultaneously ready provider result, and the losing guest future is +dropped. This bounds when EdgeZero returns `DeadlineExceeded`, but it does not prove that the +provider cancels host-side work. Fastly's synchronous host calls cannot be guest-preempted. The +bounded trait methods are required implementation points: core supplies no default that can +silently await an unbounded provider call forever. + +Each bounded method applies both remaining extraction-wide backend-byte allowance and its +per-value output cap. `backend_bytes` counts every byte exposed to guest code while +satisfying that logical read, including an adapter-private pointer and every chunk used to +reconstruct one Fastly value; it does not count opaque provider work. The adapter uses +checked accounting and never reports a value whose length exceeds `max_value_bytes` or +whose guest-visible read cost exceeds `max_backend_bytes`. A Native implementation rejects +before retaining the first byte beyond either allowance; an Unsupported host-allocation +implementation may first receive a provider-materialized value, then discards it. + +The extractor verifies the returned count, including `backend_bytes >= value.len()` whenever +a value is present, uses checked addition to charge it against the single extraction-wide +`max_backend_bytes`, and separately charges each returned logical blob/secret length against +`max_total_bytes` before retaining or inserting it. It passes the remaining backend +allowance into the next read. Duplicate references to the same secret are charged for every +actual backend read; §6.4 deliberately defines no extraction cache. A backend that reports +more bytes than the allowance, a value larger than the supplied per-value limit, or a byte +count smaller than its returned value fails closed as `BackendFailure`. Any legitimate cap +failure detected by the adapter or extraction budget maps to +`StoreExtractionReason::ValueTooLarge`; expiry maps to +`StoreExtractionReason::DeadlineExceeded`. Partial values are discarded and never parsed, +hashed, deserialized, validated, logged, or returned. + +These are distinct guarantees: + +- **Guest-visible bound:** every target enforces per-value and cumulative byte caps before + EdgeZero retains or processes the returned value. +- **Host-allocation bound:** only a target whose host API supports streaming/size-aware reads + can prevent the provider SDK from first materializing an oversized value. +- **Cancellation bound:** cooperative pre/post checks give a typed eventual result, but only + a target with a cancellable async read and deployed host evidence can claim that provider work + terminates within a finite wall-clock bound. A guest timer race alone proves prompt EdgeZero + return, not provider cancellation. + +The capability ladder therefore gains two config-owned cells: + +| Capability | Axum | Cloudflare | Fastly | Spin | +| --- | --- | --- | --- | --- | +| `config-read-allocation-bounds` | Unsupported overall; bounded local snapshots do not certify secret/environment/allocator overhead | Unsupported overall; capped guest byte streams do not certify opaque KV/JS retention or secret reads | Unsupported overall; caller-sized ABI buffers do not certify provider storage or secret reads | Unsupported until host API documentation and a probe prove it | +| `config-read-deadlines` | BestEffort while request-time reads are synchronous shared-handle lookups with pre/post checks | BestEffort: guest timer race returns promptly, but host cancellation is unproved | BestEffort; synchronous host reads are not guest-preemptible | BestEffort: guest timer race returns promptly, but host cancellation is unproved | + +Apps that require a strict RSS or elapsed-time guarantee declare the corresponding Native +capability and fail before startup/deploy on weaker targets. Capability documentation must +name provider-side materialization, parser allocation, SDK copies, and uninterruptible host +calls as exclusions; it must not imply that a post-return `String::len()` check bounded host +RSS. A downstream requirement for cancellation is satisfied only by a `Native` capability cell; +none of the current adapters makes that claim. One deployed timing probe per non-Axum target +records whether provider cancellation occurs within the documented tolerance before any +capability cell is upgraded. + ### 6.4 Per-request caching **v1 stance: no caching. Every `AppConfig` extractor @@ -4446,8 +4799,9 @@ scope per §3.2. ### 6.5 What happens to the existing `ConfigStore` trait `ConfigStore::get(key)` stays — it's still useful for ad-hoc -lookups against the same backends. But the `AppConfig` extractor -is the ONLY supported way to read the typed app-config. +lookups against the same backends, but it carries none of §6.3.2's extraction guarantees. +The `AppConfig` extractor uses only `get_bounded` and is the ONLY supported way to read +the typed app-config. Hand-written `ctx.config_store_default()?.get("greeting")` against the typed app-config's store will now find a JSON blob under "greeting"-ish keys, not raw strings, and fail to deserialise into @@ -4853,8 +5207,8 @@ pub trait Adapter: Sync + Send { pub enum ReadConfigEntry { /// The store and key both exist; the inner string is the raw - /// blob envelope JSON (the same shape `ConfigStore::get` - /// returns at runtime per `crates/edgezero-core/src/config_store.rs:214`). + /// blob envelope JSON (the same logical value returned by runtime + /// `ConfigStore::{get,get_bounded}`). /// Caller parses + verifies. **Round-27 reviewer flagged an /// earlier `Vec` draft as inconsistent with the rest of /// §9.1-§9.4's per-adapter wording, all of which describes @@ -4970,8 +5324,8 @@ coexist in one file. A flat `{ key: envelope_json_string }` map. Each value is the envelope serialised to JSON and stored as an ESCAPED STRING (not a nested JSON object), so the file deserialises into -`BTreeMap` and Axum's `ConfigStore::get(key)` -returns the string directly without any envelope-aware +a bounded sorted index of independently owned shared `ConfigValue` payloads. +Axum's `ConfigStore::get(key)` returns the shared UTF-8 value without any envelope-aware parsing inside the adapter. ```json @@ -4987,22 +5341,21 @@ parsing inside the adapter. to `.tmp`, fsync, rename). The entry value is the envelope serialised then `serde_json::to_string`-ed and inserted as a string value. -- Runtime: the extractor stays adapter-neutral and only calls - `ConfigStore::get(key) -> Option` per - `crates/edgezero-core/src/config_store.rs:214`. Axum's - `ConfigStore` impl wraps the file map: - - ```rust - impl ConfigStore for AxumConfigStore { - async fn get(&self, key: &str) -> Result, ConfigStoreError> { - let map: BTreeMap = self.map.read().await.clone(); - Ok(map.get(key).cloned()) - } - } - ``` - - The string form keeps the per-adapter `ConfigStore::get -> -Option` contract intact across all four adapters — +- Runtime: the standard runner loads immutable bounded flat-string snapshots before + listener binding, sequentially across the registry. It caps actual file reads, entry + count, decoded key/value bytes, all-store resident allocations and startup staging; + duplicate keys fail. No intermediate `Value` tree is created. Size violations fail + startup; other failed declarations retain typed failing handles. Missing files remain + empty declared stores. Push changes require restart. Request-time `get_bounded` checks + both extraction caps and the injected deadline before cloning one shared payload handle; + raw `get` also shares that payload but makes no extraction-bound claim. + `from_path`, `from_local_file` and fallible `from_map` require `ConfigStoreLimits`. + Caller-owned map inputs are outside startup-reader charges. The complete defaults, + supported ranges, requested-allocation formula, decoder pin and exclusions are normative + in [the Axum snapshot limits guide](../../guide/adapters/axum.md#snapshot-allocation-limits). + + The string form keeps the per-adapter `ConfigStore` logical value contract intact across + all four adapters — storing nested JSON objects would force the Axum store to re-serialise on every `get`, AND would require the extractor to know Axum's file shape. The string form sidesteps both. @@ -5039,8 +5392,25 @@ bulk put --namespace-id= --remote` `wrangler kv key get --binding --remote` (Wrangler 4.x's four-segment subcommand path; the older three-segment `wrangler kv get` is deprecated). -- Runtime: `CloudflareConfigStore::get(key)` returns the JSON - string; the `AppConfig` extractor parses + verifies. +- Runtime: `CloudflareConfigStore::get_bounded` requests a KV byte stream, not `.text()` + or a materialized array buffer. A BYOB reader uses reusable 16 KiB Rust/JS buffers and + rejects the first excess byte before append. Accumulator capacity never intentionally + exceeds `L = min(max_backend_bytes, max_value_bytes)`; requested payload staging is + conservatively `2L + 32 KiB`, covering relocation/shared-string conversion and scratch. + Bounded reads require valid UTF-8, without lossy expansion. Failure to acquire a byte + reader fails closed; there is no unbounded fallback. An acquired reader is cancelled on + overflow, timeout or dropped work. The timer covers acquisition, drain and conversion, + rechecking the injected clock after each wake and before/after every pull, with a task-queue + yield every 16 chunks for frozen-clock hosts. An acquisition owner cancels late-arriving + streams after abandonment; cancellation-promise rejections are consumed without publishing + provider diagnostics. The SDK exposes no acquisition-promise + cancellation. Transferred wasm-bindgen callbacks require `FinalizationRegistry`; absent + runtime support fails before provider dispatch. Demo/generated Workers explicitly set + `enable_weak_ref`, without claiming deterministic GC cleanup. + KV host buffering, JS wrapper/GC retention and finite host teardown remain + unqualified. Deadlines remain BestEffort and broad allocation support Unsupported. + `AppConfig` parses/verifies only after charging the bounded read. Raw `get` remains + explicitly unbounded. - `--local` push: `wrangler kv bulk put --binding --local` — lands in `.wrangler/state`. Local push deliberately addresses by @@ -5077,9 +5447,22 @@ bulk put --namespace-id= --remote` chunk pointer, read/verify/concatenate chunks and return the reconstructed normal envelope string. Missing/corrupt chunks are a read error, not "missing key". -- Runtime: `FastlyConfigStore::get(key)` performs the same - direct-or-pointer resolution and returns the normal envelope JSON - value; the core extractor does not know whether Fastly used chunks. +- Runtime: `FastlyConfigStore::get_bounded` performs the same direct-or-pointer resolution, + passes one absolute deadline/remaining allowance to every read, and returns the normal + envelope JSON value. Its `backend_bytes` includes the pointer and every fetched chunk; + the core extractor does not know which physical form Fastly used. Reads use one initialized + buffer through `fastly-sys 0.13.1`, never SDK auto-growth on `BUFLEN`. A physical entry has + an adapter ceiling of 32,000 bytes; roots are capped by + `R = min(max_backend_bytes, max(max_value_bytes, 8000), 32000)`, pointers by 8,000 bytes, + and chunks by 7,000 bytes and both remaining allowances. Validated declared totals must + fit both aggregate caps before chunk dispatch or exact-size reassembly reservation. + Root discriminators borrow raw tokens instead of materializing a JSON `Value` tree. + The bounded resolver verifies transport hashes/layout, then delegates inner-envelope + materialization and integrity to the extractor's structural preflight, matching direct + values. Raw `get`/CLI paths retain their explicit inner-envelope integrity checks and + are not allocation-certified. A conservative requested guest-payload charge is + `2V + R + 16P + 7000` bytes (`V = max_value_bytes`, `P = 8000`), plus fixed objects and + allocator overhead; host memory and synchronous-call interruption remain excluded. - Local server: `[local_server.config_stores.]` now has ONE active root entry — the blob key. PR #269 F6 already moved local- server seeding to `config push --local`. Local seeding must mirror @@ -5166,8 +5549,12 @@ key-value set --stdin` or `--from-file`. keeps the "no multi-blob merge" stance — splitting is operator-side schema work. -- Runtime: `SpinConfigStore::get(key)` returns the JSON value; - extractor parses. +- Runtime: `SpinConfigStore::get_bounded` returns the JSON value and races the provider future + against a Spin timer under the absolute deadline before applying the guest-visible checks from + §6.3.2. Spin secret reads use the same clock-verified, early-wake-rearming race. The timer bounds + EdgeZero's return but does not prove host-side cancellation or pre-allocation bounds, so deadline + support remains BestEffort and allocation support remains Unsupported. The extractor parses only + after the bounded read is charged. ## 10. Migration @@ -5188,9 +5575,9 @@ maintenance tax. 2. The runtime `AppConfig` extractor replaces direct `ctx.config_store_default()?.get(key)` calls against the typed config's store. Downstream apps with custom `ConfigStore` - consumers see a deprecation note in `CHANGELOG.md`; the - compile-time surface (the `ConfigStore` trait itself) doesn't - change. + consumers must hard-migrate `get`/`get_bounded` and matching handle APIs to + shared `ConfigValue` instead of `String`, as recorded in `CHANGELOG.md`. + No deprecated aliases or dual trait signatures remain. 3. Pre-blob blobs (the flattened per-leaf state) become unreadable. The runtime errors loudly on the first request pointing at the migration guide. We do NOT silently treat @@ -6227,25 +6614,24 @@ What happens when the store has no entry at the requested key? - (b) Return `EdgeError::ServiceUnavailable`. - (c) Add a sibling extractor `MaybeAppConfig` returning `Option` for endpoints that have a sensible default. -- (d) Return `EdgeError::ConfigOutOfDate` — matches the - "re-run ` config push` fixes it" rule - spelled out for `ConfigOutOfDate` at §6.3. +- (d) Return `EdgeError::StoreExtraction` with reason `MissingBlob`; §6.3 maps that reason + to the `config_out_of_date` wire contract because re-running ` config push` + fixes it. - **default:** (d) for v1 (round-18 M-2 reversal of the - earlier (a) pick). The §6.3 rationale defines - `ConfigOutOfDate` as "the situation a re-run of + earlier (a) pick). The §6.3 rationale defines the + `config_out_of_date` wire class as "the situation a re-run of ` config push` resolves" — and a missing blob is squarely in that bucket: the deployed code expects a blob, the store doesn't have one, an operator push fixes it. Mapping this to `Internal` would page oncall (a 500-class signal) when the actionable response is "push the config", which - `ConfigOutOfDate` (503 with `Retry-After: 60`) - already encodes. The §3.3.3 extractor's + `StoreExtractionReason::MissingBlob` (503 / `config_out_of_date` with + `Retry-After: 60`) already encodes. The §3.3.3 extractor's `ok_or_else(|| EdgeError::internal("missing typed app-config blob"))` call site changes to - `EdgeError::config_out_of_date("missing typed -app-config blob at key ``— run` - config push`", String::new())` accordingly. (c) is + `EdgeError::store_extraction(StoreExtractionReason::MissingBlob, "missing typed +app-config blob at key `` — run ` config push`", None)` accordingly. (c) is still tracked as a follow-up for endpoints that want explicit `Option` semantics. @@ -6582,14 +6968,14 @@ length, charset) on the operator-typed KEY NAME. - Returns the deserialised struct for a valid blob. - Errors on sha mismatch (manually-edited blob). - **Missing key (per Q3 (d) / §6.3 — round-19/20).** - Fixture: `ConfigStore::get` returns `Ok(None)`. - Assert the extractor produces - `EdgeError::ConfigOutOfDate { message, field_path }` + Fixture: `ConfigStore::get_bounded` returns `BoundedStoreRead { value: None, .. }`. + Assert the extractor produces `EdgeError::StoreExtraction` with reason `MissingBlob`, + no field path, and a `message` where `message` contains the literal `key -\`\``AND the literal`run +\`\`` and the literal `run \` config push\``. Render the resulting `Response`; assert HTTP status 503 and -the `Retry-After: 60`header is present. NOT`EdgeError::Internal` (the round-18 reversal that +the `Retry-After: 60` header is present. Not `EdgeError::Internal` (the round-18 reversal that round-19 then propagated through §6.3). - `named(key)` reads a different key from the same store. - `BlobEnvelope` deserialise: @@ -6759,35 +7145,23 @@ strip or revert the resolution direction: store-ref field, unchanged). This is the user-facing Model A contract — the framework swaps key NAME for resolved VALUE before the handler sees `cfg`. -- **Missing secret at extract time.** Same fixture, mock - secret store configured to `NotFound` on lookup. Assert - the extractor produces `EdgeError::ConfigOutOfDate` (per - §3.3.6) — NOT `Unavailable` (that's reserved for - store-backend network errors) and NOT `Internal` (which - today's `SecretError::NotFound → EdgeError::Internal` - mapping in `crates/edgezero-core/src/secret_store.rs:131` - produces). The blob model's missing-secret behaviour - requires a DELIBERATE deviation from the current mapping: - the extractor wraps `SecretError::NotFound` into - `ConfigOutOfDate` BEFORE it bubbles, so the dashboards - signal "deploy is incomplete" rather than "we 500'd". - Calls out: the `secret_store.rs` mapping itself doesn't - change for raw handler-side callers (still maps to - `Internal`); the extractor's `?` operator catches it and - re-wraps. Test asserts the resulting `EdgeError` variant - AND the `Retry-After: 60` header per §6.3.1. +- **Missing secret at extract time.** Same fixture, mock secret store returning no value. + Assert the extractor produces `EdgeError::StoreExtraction` with reason `MissingSecret` + (per §3.3.6), not `SecretBackendUnavailable` and not `BackendFailure`. The typed + extractor adds this context at its ownership boundary instead of relying on the generic + raw-store conversion. Assert the reason, `config_out_of_date` wire kind, field path, and + `Retry-After: 60` header per §6.3.1. - **Missing secret-store id at extract time.** Fixture: manifest declares no `[stores.secrets].ids = ["missing"]`, but a `#[secret(store_ref = "vault")]` field's value is - `"missing"`. Assert extractor surfaces - `EdgeError::ConfigOutOfDate` with the actionable message - "blob declared store_ref `missing` but [stores.secrets] - has no such id". + `"missing"`. Assert the extractor produces `EdgeError::StoreExtraction` with reason + `UnknownStore`, HTTP 500 / `internal`, a field path, and a redacted message that does not + repeat the stored identifier. - **Secret-store unreachable at extract time.** Same fixture, mock store configured to error with a network - failure (NOT NotFound). Assert extractor surfaces - `EdgeError::ServiceUnavailable` (per §3.3.6 — a flaky - backend is not an out-of-date config). + failure (not a missing value). Assert the extractor produces + `EdgeError::StoreExtraction` with reason `SecretBackendUnavailable` and the + `service_unavailable` wire kind (per §3.3.6). - **` config validate --strict` — structural checks ONLY.** Three sub-tests, each asserting on a structural property the validator can verify without @@ -6822,14 +7196,12 @@ strip or revert the resolution direction: - **Validate runs on every extract.** Fixture: a blob that serdes cleanly but violates a `#[validate(range(min=1, -max=10000))]` rule. Extract. Assert - `EdgeError::ConfigOutOfDate` with `field_path` naming the - offending field. +max=10000))]` rule. Extract. Assert `EdgeError::StoreExtraction` with reason + `Validation` and `field_path` naming the offending field. - **Validate-OK is the happy path.** Fixture: a blob in bounds. Extract. Assert the typed struct comes back with no error. -- **Validator violation report format.** Assert the - `ConfigOutOfDate` response body matches the nested +- **Validator violation report format.** Assert the `Validation` response body matches the nested envelope documented in §6.3.1 exactly: `{ "error": { "status": 503, "kind": "config_out_of_date", "message": "<…>", "field_path": "" } }`. Assert @@ -6838,74 +7210,36 @@ max=10000))]` rule. Extract. Assert fixture). Assert the response carries the `Retry-After: 60` header. -#### 12.6.1 `kind` strings on every `EdgeError` variant + header policy (round-23 M-1) - -§6.3.1 adds a stable `kind: String` field to the response -body for EVERY `EdgeError` variant (`bad_request`, -`internal`, `method_not_allowed`, `not_found`, -`not_implemented`, `service_unavailable`, `validation`, -and the new `config_out_of_date`). The earlier test plan -only asserted the `config_out_of_date` body; this section -covers the other six and the cross-cutting -header / field-presence rules so a future refactor can't -silently drop or rename a `kind` string. - -- **Stable `kind` strings.** One fixture per variant - that triggers it (e.g. a handler that returns - `Err(EdgeError::not_found("…"))` for `not_found`). - Render the `Response`. Parse the body as JSON. - Assert `body.error.kind` equals the exact string - documented in §6.3.1. Variant → expected string: - - | Variant | Expected `kind` string | - | -------------------- | ----------------------- | - | `BadRequest` | `"bad_request"` | - | `Internal` | `"internal"` | - | `MethodNotAllowed` | `"method_not_allowed"` | - | `NotFound` | `"not_found"` | - | `NotImplemented` | `"not_implemented"` | - | `ServiceUnavailable` | `"service_unavailable"` | - | `Validation` | `"validation"` | - | `ConfigOutOfDate` | `"config_out_of_date"` | - -- **`field_path` is OMITTED outside `ConfigOutOfDate`.** - For each non-`ConfigOutOfDate` variant fixture above, - assert `body.error.get("field_path")` is `None` (the - field is absent from the JSON, not present with an - empty string). Per §6.3.1's "ONLY on - `config_out_of_date`" rule. - -- **`Retry-After: 60` is PRESENT on `ConfigOutOfDate` - ONLY.** Fixture per variant: render the response, - assert `response.headers().get("retry-after")`: - - `ConfigOutOfDate`: `Some("60")`. - - `ServiceUnavailable`: `None`. Round-10 H-3 audit - found the variant is reused for several - non-retryable cases (KV size limit, missing - named KV store, missing default secret store); - sending the header would mislead clients into a - tight retry loop. The §6.3.1 narrowing pins - `ServiceUnavailable` to no `Retry-After`. - - All other variants: `None`. - -- **Status codes match the documented variant → HTTP - mapping.** Fixture per variant: assert - `response.status()`: - - | Variant | HTTP status | - | -------------------- | ----------- | - | `BadRequest` | 400 | - | `Internal` | 500 | - | `MethodNotAllowed` | 405 | - | `NotFound` | 404 | - | `NotImplemented` | 501 | - | `ServiceUnavailable` | 503 | - | `Validation` | 422 | - | `ConfigOutOfDate` | 503 | - - (The two 503-class variants are deliberately - distinguished by the `kind` string + the - `Retry-After` header, not the status code.) +#### 12.6.1 End-state `EdgeError` wire matrix and store-reason policy + +The implementing branch enumerates every then-present `EdgeError` variant without a +wildcard. Final integration includes variants owned by the outbound and inbound specs as +well as this config design: + +| Variant | Expected `kind` | HTTP | +| --- | --- | --- | +| `BadGateway` | `"bad_gateway"` | 502 | +| `BadRequest` | `"bad_request"` | 400 | +| `ConfigOutOfDate` | `"config_out_of_date"` | 503 | +| `GatewayTimeout` | `"gateway_timeout"` | 504 | +| `Internal` | `"internal"` | 500 | +| `MethodNotAllowed` | `"method_not_allowed"` | 405 | +| `NotFound` | `"not_found"` | 404 | +| `NotImplemented` | `"not_implemented"` | 501 | +| `RequestTimeout` | `"request_timeout"` | 408 | +| `ResponseTooLarge` | `"response_too_large"` | 502 | +| `ServiceUnavailable` | `"service_unavailable"` | 503 | +| `StoreExtraction` | reason-dependent per §6.3 | reason-dependent per §6.3 | +| `Validation` | `"validation"` | 422 | + +For every ordinary variant, render a response and assert status, kind, and omission of the +Rust-only typed reason. For every known `StoreExtractionReason`, assert the exact §6.3 +status/kind/`Retry-After`/field-path row. `field_path` is omitted outside +`ConfigOutOfDate` and `StoreExtraction`; it is also omitted when either carrier has no +non-empty path. `Retry-After: 60` appears only on an effective `config_out_of_date` outcome: +the existing `ConfigOutOfDate` variant plus `Deserialization`, `MissingBlob`, +`MissingSecret`, and `Validation`. A plain `ServiceUnavailable` and store reasons mapped to +`service_unavailable` carry no retry header. ### 12.7 Env-var key override (§5.2) @@ -7397,9 +7731,8 @@ default_key }` on `ConfigRegistry`. `Config::default()` the load-bearing commit; everything it touches has to land together per §10's hard-cutoff rule: - `AppConfig` extractor + secret resolution - wired into all four adapters' `ConfigStore::get` - read path. Missing-blob maps to - `EdgeError::ConfigOutOfDate` per Q3 (d). + wired into all four adapters' bounded config-read path. Missing blob maps to + `EdgeError::StoreExtraction` with reason `MissingBlob` per Q3 (d). - `config push` rewrite — single-blob writers per adapter (Axum file map / Cloudflare bulk-put / Fastly `--upsert --stdin` / Spin direct-write). diff --git a/docs/superpowers/specs/2026-06-16-test-coverage-gap.md b/docs/superpowers/specs/2026-06-16-test-coverage-gap.md index 284112015..4daac9c13 100644 --- a/docs/superpowers/specs/2026-06-16-test-coverage-gap.md +++ b/docs/superpowers/specs/2026-06-16-test-coverage-gap.md @@ -435,7 +435,9 @@ git commit -m "test(adapter): cover run_native_cli not-found and non-zero-exit p let garbage = b"this is definitely not a gzip member".to_vec(); let stream = stream::iter(vec![Ok::, io::Error>(garbage)]); let result = block_on(async { - decode_gzip_stream(stream).try_collect::>().await + decode_gzip_stream(stream, FLATE_DECODER_FIXED_CHARGE_BYTES) + .try_collect::>() + .await }); assert!(result.is_err(), "invalid gzip must decode to an error"); } @@ -446,7 +448,9 @@ git commit -m "test(adapter): cover run_native_cli not-found and non-zero-exit p let garbage = vec![0xFF_u8; 64]; let stream = stream::iter(vec![Ok::, io::Error>(garbage)]); let result = block_on(async { - decode_brotli_stream(stream).try_collect::>().await + decode_brotli_stream(stream, 24, 1_u64 << 25) + .try_collect::>() + .await }); assert!(result.is_err(), "invalid brotli must decode to an error"); } diff --git a/docs/superpowers/specs/2026-07-03-edgezero-fastly-dispatch-and-appstate-design.md b/docs/superpowers/specs/2026-07-03-edgezero-fastly-dispatch-and-appstate-design.md index 00fbabdbf..ff0e4ca3a 100644 --- a/docs/superpowers/specs/2026-07-03-edgezero-fastly-dispatch-and-appstate-design.md +++ b/docs/superpowers/specs/2026-07-03-edgezero-fastly-dispatch-and-appstate-design.md @@ -1,5 +1,9 @@ # EdgeZero P0-C + P0-D — Fastly `run_app` dispatch fidelity + app-state injection +> **Superseded lifecycle surface (2026-09-15):** The outbound design now specifies +> `run_app_with_hooks` and `FastlyService::send_request_with_hooks`; the request-only hook named +> below was removed in the hard cut. This document remains historical context. + - **Status:** Draft for edgezero maintainer - **Date:** 2026-07-03 - **Target repo:** `github.com/stackpop/edgezero` (`edgezero-adapter-fastly`, `edgezero-core`, `edgezero-macros`) @@ -158,7 +162,7 @@ builder = builder.with_state(crate::app_state()); // #state_expr, only when `s // ... routes ... ``` -**`state = ` is a full Rust expression** evaluating to the app-owned value, emitted **verbatim** into `.with_state()`. It must be the call/expression, **not** a bare function path — `with_state` takes the state *value*, so `state = crate::app_state` (a fn item) would pass the function, not its result. Write `state = crate::app_state()` or `state = std::sync::Arc::new(AppState::new())`. (This mirrors nothing magical: the macro does not append `()`.) `run_app` → `A::build_app()` → `routes()` → `build_router()` already runs per request (Fastly) / once at startup (Axum), and #306's dispatch injection clones the value into each request — so `State` reaches `#[action]` handlers on **all four adapters** with no adapter edits. Without the `state` argument, `build_router()` is unchanged (no state), preserving current behavior. +**`state = ` is a full Rust expression** evaluating to the app-owned value, emitted **verbatim** into `.with_state()`. It must be the call/expression, **not** a bare function path — `with_state` takes the state *value*, so `state = crate::app_state` (a fn item) would pass the function, not its result. Write `state = crate::app_state()` or `state = std::sync::Arc::new(AppState::new())`. (This mirrors nothing magical: the macro does not append `()`.) `run_app` → `App::build::(platform)` → `routes()` → `build_router()` already runs per request (Fastly) / once at startup (Axum), and #306's dispatch injection clones the value into each request — so `State` reaches `#[action]` handlers on **all four adapters** with no adapter edits. Without the `state` argument, `build_router()` is unchanged (no state), preserving current behavior. **Single vs. multiple state types.** `with_state` registers one `T` — a single `state = crate::app_state()` covers the `Arc` case (what trusted-server / `app-demo` need). If multiple state types are ever required, allow repeated `state = a(), state = b()` (emit one `.with_state(...)` per occurrence) or add a `RouterBuilder::with_state_extensions(Extensions)` fed by an app-supplied `Extensions` bag. Default to the single-value form unless a concrete multi-type need appears. **The grammar below permits repeated `state`; whether repeats are accepted or rejected is a decision the plan must state** (see §`app!` argument grammar). diff --git a/docs/superpowers/specs/2026-08-22-inbound-body-design.md b/docs/superpowers/specs/2026-08-22-inbound-body-design.md new file mode 100644 index 000000000..063b81396 --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-inbound-body-design.md @@ -0,0 +1,1083 @@ +# EdgeZero Inbound Request-Body Design + +> **Status:** Draft. Core admission, deadline-bound body handling, and adapter entry seams are +> partially implemented. The raw-parser requirements in §1.2 are future certification criteria; +> every current raw-ingress capability cell remains `Unsupported`. +> +> Extracted from the +> [outbound-HTTP design](2026-05-21-outbound-http-design.md) so that specification stays +> focused on outbound HTTP. This document owns the **inbound request-body** contract: +> `RequestContext` body reading, adapter ingress, extractor limits, and the `BodyCell` +> state machine. Outbound streaming proxy-forward consumes the resulting +> `BodyCell`/`into_request()` contract but does not own it. + +## 1. Scope + +The goal is to stop adapters from pre-buffering unbounded inbound bodies while preserving +the existing `FromRequest::from_request(&RequestContext, ..)` extractor signature. A body +is consumed from the platform at most once, bounded helpers cache successful bytes, and a +partially consumed failed drain becomes sticky poison. This document does not define +outbound response caps, outbound deadlines, or provider send behavior. + +The total `StoredError` match below targets the end-state `EdgeError` surface and therefore +depends on the outbound error work landing first: `BudgetSource`, `BadGateway`, +`GatewayTimeout`, and `ResponseTooLarge` are defined by the outbound design, not here. If +the body-state migration is split across earlier commits, each commit must still compile +against the variants present at that point. This design additionally owns +`RequestTimeout` (408) for an admitted inbound body whose absolute read deadline expires; +the final `StoredError` match covers every variant then present without a wildcard. + +Rust snippets show interfaces and state shapes; omitted bodies are implementation work. +Implementations must pass the workspace's strict Clippy gate: keep items, enum variants, +and methods alphabetically ordered, add `#[inline]` to public methods, and use checked +arithmetic for drain accounting. State-transition order in prose does not dictate enum +declaration order. + +### 1.1 Adapter ingress seam and admission + +Every adapter entry point has one ingress seam **before middleware, extractor execution, +EdgeZero-owned body buffering, or a `BodyCell::Initial` transition**. Route resolution is +the one deliberate exception: the router resolves once before admission so policy sees the +canonical route identity, but it does not dispatch middleware or a handler. The seam +performs this ordered protocol exactly once per platform request: + +1. Stamp `request_start` from the `App`-owned `MonotonicClock` at the earliest + EdgeZero-owned entry point by calling `App::monotonic_now()`. It means "EdgeZero + received control", not when the client sent its first byte and not when route execution + began. The same cloneable clock handle is attached to the admitted request. +2. Enforce parser-level request-target and header limits, then validate raw request framing, + where the platform exposes a raw boundary (§1.2). Target overflow returns 414, header + byte/count overflow returns 431, and framing rejection returns 400. Every rejection + closes/resets that request without route resolution, app admission, or body polling. +3. Convert only request-head data, retain the unread native body separately, and call + `RouterService::resolve(method, path)` once. Resolution returns an opaque dispatch token + plus `RouteResolution::{Matched, MethodNotAllowed, NotFound}`. It performs no middleware, + handler, body poll, or request-extension injection. The token owns the exact match and + path parameters so admitted dispatch does not rerun matching. +4. Construct a normalized `IngressHead` containing the method, target, version, visible + headers, `IngressHeadAccounting`, `IngressFraming`, adapter request metadata, public + `request_start`, and the stable route resolution. The accounting and framing values say + whether EdgeZero validated the raw boundary or the host owns those decisions. +5. Invoke the one admission policy stored on `App`. It returns `Abort`, + `Refuse { response, completion }`, `Admit { completion, grant, read_deadline }`, or + `ReadBodyBeforeFallback { completion, grant, max_body_bytes, read_deadline, on_exceeded, + on_timeout }`. The callback is synchronous and body-blind: it can inspect the head but cannot + obtain or poll the body. Every response-producing decision carries exactly one non-clone + `ResponseEgressCompletion`; applications without a response-owned resource use + `ResponseEgressCompletion::empty()`. + A refusal bypasses resolved dispatch, terminates the unread platform body with the strongest + target-specific primitive, and converts that response as detached ingress egress. It skips the + application response-egress policy and global observer but retains and terminally invokes the + refusal's response-scoped completion. Normalized head-validation failures obtain their + disposition from the application's detached-egress decision factory. + `Send { completion, deadline }` constructs detached egress with that completion and a mandatory + absolute deadline in the request's injected monotonic-clock domain. The effective bound is the + earlier of that deadline and the default response-egress policy: a later factory deadline cannot + extend the default policy, and the policy cannot extend an earlier factory deadline. The default + selects `Send` with an empty completion and `DEFAULT_RESPONSE_WRITE_BUDGET` measured from + `request_start` through + `DetachedResponseEgressHead::write_deadline_after`. Admission-selected detached responses retain + their response-owned deadline path and are outside this factory contract. `Abort` constructs no + response. Raw parser failures and platform-to-core head-conversion failures before EdgeZero + captures a normalized head remain outside the application lifecycle contract. + `Abort` is a distinct non-response outcome: it polls no body, invokes no middleware or handler, + constructs no response-egress attempt, invokes no completion or observer, and instructs the + adapter to terminate the request at its strongest pre-response reset/error/close boundary. +6. For `Admit`, wrap the still-unread platform body with the absolute deadline and native + cancellation owner, construct the core `Request`, and call + `RouterService::dispatch_resolved` with the exact token from step 3. For `Matched`, the + router constructs `RequestContext` with `request_start`, route metadata, and the + request-owned grant before middleware. `PreparedIngress` retains the non-clone completion + separately from the request and router token. For `MethodNotAllowed`/`NotFound`, ordinary + admission retains immediate 405/404 handling without polling the body; no middleware or + handler runs and the grant is dropped exactly once. The wrapper is installed for + `Body::Once` and `Body::Stream`; a content-type path must not pre-buffer before this point. + After `PreparedIngress` is returned, every adapter setup or conversion failure consumes that + proof through `App::admitted_error_egress`. The resulting envelope retains the admitted app + clock, request start, request method, route metadata, response-egress policy, completion, and + observer. + No adapter may convert such a failure directly to a provider error or detached response. +7. `ReadBodyBeforeFallback` is valid only for the pre-resolved `MethodNotAllowed` and + `NotFound` outcomes. It preserves the exact resolved token, installs the same absolute + deadline wrapper, holds the supplied grant, and drains the body to EOF while discarding its + contents. Clean EOF at or below `max_body_bytes` produces the canonical 405/404; the first + byte over the cap produces the exact buffered `on_exceeded` response, and deadline expiry + produces the exact buffered `on_timeout` response, both before the routing response. A zero + cap accepts only an empty body. The grant remains live through every body poll and is dropped + exactly once after the drain terminates and before response conversion; dropping the core + drain future also releases it. This path invokes no middleware, handler, or `RequestContext` + and never rematches. The decision's completion follows whichever terminal response wins: the + canonical 404/405, exact-cap response, overflow response, timeout response, or a later admitted + conversion error. Returning this decision for `Matched` fails closed before native body + construction and drops its completion/resource without a report because no response attempt + began. Adapter-level cancellation follows the target caveats in §1.3. + +Route identity is stable and structural, never a registration ordinal or randomized hash: + +```rust +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct RouteId { /* private canonical method + registered pattern */ } + +impl RouteId { + pub fn method(&self) -> &Method; + pub fn pattern(&self) -> &str; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RouteMetadata { /* private RouteId + optional opaque class */ } + +impl RouteMetadata { + pub fn class(&self) -> Option<&str>; + pub fn id(&self) -> &RouteId; + pub fn method(&self) -> &Method; + pub fn pattern(&self) -> &str; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum RouteResolution { + Matched(RouteMetadata), + MethodNotAllowed { allowed: Arc<[RouteMetadata]> }, + NotFound, +} +``` + +`RouteId` equality/hash is the canonical `(method, registered route pattern)` pair. Dynamic +path parameter values and route class never enter it. `RouteMetadata` owns exactly that +`RouteId` plus optional opaque manifest metadata from `class = "auction"`; its +method/pattern accessors delegate to the id, so independently mutable duplicate fields +cannot disagree. Class is for application admission policy and is neither globally unique +nor interpreted by EdgeZero. `RouterBuilder::route_with_class(path, method, class, handler)` +is the generated-route path; existing route builders produce `class() == None`. +`MethodNotAllowed.allowed` contains every matched +pattern/method candidate sorted by method then pattern; callers derive an Allow set without +depending on `HashMap` order. `RouteInfo` reuses `RouteMetadata` rather than defining a +second identity. Duplicate method/pattern registration remains the existing build-time +error. The resulting `EdgeError::MethodNotAllowed` retains a sorted, deduplicated +`Vec` rather than flattening protocol data into display text. Its canonical response +always emits the corresponding `Allow` field, including after a successful bounded fallback +body drain. An empty method set emits an empty `Allow` value; normal router-produced 405s always +contain at least one method. + +The resolution/dispatch split is an explicit router API, not an adapter-side reimplementation +of matching: + +```rust +pub struct ResolvedDispatch { /* private router identity + path parameters */ } + +impl ResolvedDispatch { + pub fn resolution(&self) -> &RouteResolution; +} + +impl RouterService { + pub fn resolve(&self, method: &Method, path: &str) -> ResolvedDispatch; + + pub async fn dispatch_resolved( + &self, + resolved: ResolvedDispatch, + request: Request, + ingress: AdmittedIngress, + ) -> Result; +} +``` + +`ResolvedDispatch` is opaque, single-use, and bound to the `RouterService` instance that +created it. `dispatch_resolved` rejects a token from another router as `Internal`, consumes +the token, and never matches the method/path again. The `Request` method and path must equal +the values captured by the token; a mismatch is also `Internal`. This prevents middleware or +adapter conversion from admitting one route and dispatching another. `AdmittedIngress` +contains the captured start, finite deadline, paired monotonic clock, and grant; it has no +public constructor so only the ingress protocol can claim admission occurred. + +`IngressGrant` is an opaque request-owned lease carrier. It is intentionally not `Clone`: + +```rust +pub struct IngressGrant { /* Option> */ } + +impl IngressGrant { + pub fn downcast(self) -> Result; + pub fn empty() -> Self; + pub fn new(value: T) -> Self; +} + +pub struct ResponseEgressCompletion { /* non-clone FnOnce callback owner */ } + +impl ResponseEgressCompletion { + pub fn empty() -> Self; + pub fn join(self, other: Self) -> Self; + pub fn new(complete: Complete) -> Self + where + Complete: FnOnce(&ResponseEgressReport) + Send + 'static; +} + +pub struct DetachedResponseEgressHead<'head> { + /* request method, request start, status, and stable error kind */ +} + +impl DetachedResponseEgressHead<'_> { + pub fn write_deadline_after(&self, duration: Duration) -> Deadline; +} + +#[non_exhaustive] +pub enum DetachedResponseEgressDecision { + Abort, + Send { + completion: ResponseEgressCompletion, + deadline: Deadline, + }, +} + +#[derive(Clone, Debug)] +pub struct BufferedIngressResponse { /* exact status + HeaderMap + Bytes */ } + +impl BufferedIngressResponse { + pub fn new>(status: StatusCode, headers: HeaderMap, body: B) -> Self; + pub fn text>(status: StatusCode, text: S) -> Self; + pub fn status(&self) -> StatusCode; + pub fn headers(&self) -> &HeaderMap; + pub fn body(&self) -> &[u8]; +} + +#[non_exhaustive] +pub enum AdmissionDecision { + Abort, + Admit { + completion: ResponseEgressCompletion, + grant: IngressGrant, + read_deadline: Deadline, + }, + ReadBodyBeforeFallback { + completion: ResponseEgressCompletion, + grant: IngressGrant, + max_body_bytes: usize, + read_deadline: Deadline, + on_exceeded: BufferedIngressResponse, + on_timeout: BufferedIngressResponse, + }, + Refuse { + completion: ResponseEgressCompletion, + response: Response, + }, +} + +#[non_exhaustive] +pub enum IngressAdmissionOutcome { + Aborted, + Admitted { + completion: ResponseEgressCompletion, + ingress: AdmittedIngress, + }, + Refused { + completion: ResponseEgressCompletion, + response: Response, + }, +} + +#[non_exhaustive] +pub enum IngressBeginOutcome { + Aborted, + Admitted(PreparedIngress), + Refused(ResponseEgressEnvelope), +} + +#[non_exhaustive] +pub enum IngressDispatchOutcome { + Aborted, + Response(Box), +} +``` + +`ResponseEgressCompletion` is never stored in `Response` extensions and is never cloneable. The +validated `IngressAdmissionOutcome` transfers it into `PreparedIngress`, which owns it beside the +grant-bearing `AdmittedIngress` and resolved router token. Immediately before router dispatch, +`App::dispatch_admitted` separates the completion from the remaining ingress proof and retains it +while the router consumes the request and grant. It then moves that exact completion into the +envelope for whichever response dispatch produces. A response replacement therefore cannot lose +or duplicate the resource. + +`ResponseEgressCompletion::join` consumes two completion owners and produces one owner. Terminal +execution invokes the left callback and then the right callback with the same borrowed +`ResponseEgressReport`, at most once. Abandoning the joined completion without a terminal report +drops both child owners and releases both sets of captured resources. On unwind-capable targets, +each child keeps an independent panic boundary, so a left-callback panic does not suppress the +right callback. A panic-abort target may terminate before the right callback runs. + +`BufferedIngressResponse` is deliberately finite and transport-neutral: it cannot carry a +streaming body or deferred provider work. `new` preserves the application-selected status, +complete header map, and exact bytes. `text` additionally supplies UTF-8 plain-text content +type and exact content length. + +The matched `RequestContext` stores the grant in an unsynchronized one-shot cell because +extractors receive `&RequestContext`. `take_ingress_grant(&self)` removes and returns it at +most once; a second call returns `None`. If application code never takes it, context drop +releases it. A refusal never creates a request-owned grant. `Abort` creates neither a grant nor a +completion. An ordinary admitted 404/405 owns the grant only until the resolved terminal response, +then drops it. The bounded fallback path +holds its grant through the body drain and drops it exactly once before converting the canonical +or application-selected terminal response. It never exposes the grant through a request context. +The carrier exposes no type-id string, serializer, or platform handle API; application code +knows the concrete lease type it inserted. + +`IngressHead::request_start()` and `RequestContext::request_start()` return the captured +`MonotonicInstant`. Both are the same value and clock domain used by `Deadline`; neither +accessor snapshots a new instant. `PreparedIngress::monotonic_clock()` and +`RequestContext::monotonic_clock()` return clones of the exact app clock handle attached at +admission. `IngressHead::route_resolution()` exposes the value above, and a matched +`RequestContext::route_metadata()` returns that exact matched metadata. + +`App::detached_ingress_error_egress(error, method, request_start)` is the adapter boundary for +normalized validation and admission-policy failures. It renders the typed error using a bounded +default response-egress policy and a no-op observer, never the application policy or observer. The +application's detached-egress decision factory sees the request method, captured request start, +response status, and stable error kind, and runs exactly once without middleware, handler, or body +polling. `Send { completion, deadline }` renders the bounded error, transfers that completion into +detached egress, and inserts the supplied deadline into the existing response-owned deadline path. +The deadline is mandatory, absolute in the request's injected monotonic-clock domain, and cannot be +used to extend the default response-egress policy; the effective bound is the earlier of the two. +`Abort` returns +`IngressDispatchOutcome::Aborted` before response construction and creates no response, envelope, +attempt, completion callback, observer call, handler call, middleware call, or body poll. Unknown +future outcome variants fail closed through the same non-response adapter boundary. For normalized +validation failures the factory runs before routing and admission; for an admission-policy error, +route resolution and the failing policy invocation have already occurred, but no decision-owned +completion exists. The default is: + +```rust +DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::empty(), + deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET), +} +``` + +Admission-selected detached responses retain their response-owned deadline path and do not use +this factory. Raw parser failures and platform-to-core head-conversion failures that occur before +EdgeZero can construct normalized `IngressHeadParts` remain explicitly outside the portable +lifecycle, even when an adapter has already sampled request start. A failure after successful +admission but before an attempt begins drops the decision-owned completion/resource without a +report. +`App::admitted_error_egress(prepared, error)` consumes the single-use admission proof, releases +its grant exactly once, and creates an application-owned envelope with the captured request, +route metadata, and exact completion. `App::dispatch_admitted` is infallible at this boundary: +handler and routing errors are rendered into the same owned envelope instead of escaping after +admission. The higher-level `App::dispatch_ingress` returns `IngressDispatchOutcome`, so it cannot +accidentally translate `Abort` into an HTTP response. + +```rust +pub type MonotonicInstant = web_time::Instant; + +#[derive(Clone)] +pub struct MonotonicClock { /* Arc MonotonicInstant + Send + Sync> */ } + +impl MonotonicClock { + pub fn new(now: Now) -> Self + where Now: Fn() -> MonotonicInstant + Send + Sync + 'static; + pub fn deadline_after(&self, duration: Duration) -> Deadline; + pub fn now(&self) -> MonotonicInstant; +} + +impl IngressHead { + pub fn read_deadline_after(&self, duration: Duration) -> Deadline; +} + +impl App { + pub fn monotonic_clock(&self) -> MonotonicClock; + pub fn monotonic_now(&self) -> MonotonicInstant; + pub fn set_monotonic_clock(&mut self, clock: MonotonicClock); +} +``` + +`MonotonicClock::default()` uses `web_time::Instant::now`. Injection is application-wide: +standard adapters take `request_start` from the app before head conversion, admission +stores that same handle, and core plus adapter body checks use +`Deadline::{remaining_at,is_expired_at}(clock.now())`. Admission policies should construct +relative deadlines with `IngressHead::read_deadline_after`, which uses the captured start +and clamps to `DEADLINE_FAR_FUTURE`. Calling the global-clock `Deadline::after` from a policy +while a custom app clock is installed is a caller error; explicit low-level APIs likewise +cannot validate that two bare `MonotonicInstant` values came from the same source. +`DetachedResponseEgressHead::write_deadline_after` follows the same clock-domain rule, anchors the +duration to captured `request_start`, clamps it to `DEADLINE_FAR_FUTURE`, and fails closed to +`request_start` if checked addition overflows. + +`Hooks::configure(&mut App) -> Result<(), EdgeError>` is the app-owned configuration point and +uses a new +`App` admission-policy setter. Manifest-generated and hand-written apps that do not install +one use the portable default: admit with `grant = IngressGrant::empty()`, +`completion = ResponseEgressCompletion::empty()`, and +`read_deadline = request_start + DEFAULT_INBOUND_READ_BUDGET`, where +`DEFAULT_INBOUND_READ_BUDGET = 30 s`. Admission cannot return "no deadline". The adapter +normalizes every admitted deadline to no later than +`request_start + DEADLINE_FAR_FUTURE` using checked addition; overflow fails closed as an +internal policy error. An app that needs a longer upload than the default sets a later +absolute deadline within that bound. The decision is computed per request and is never +cached globally. + +The adapter retains the complete `App` admission policy when constructing its request +service. Paths that currently clone only `app.router()` must also carry that policy; +`App::into_router()` remains usable for low-level callers but does not silently install or +claim ingress admission. Hand-built adapter services expose the same explicit policy setter +and otherwise use the portable default. + +Parser limits are startup policy, not admission output: the parser must know them before it +can safely materialize `IngressHead`. `App` therefore also owns immutable +`IngressHeadLimits`, copied into the adapter service during finalization. The default is +finite on every target: + +```rust +pub const DEFAULT_MAX_REQUEST_HEADER_BYTES: u64 = 65_536; +pub const DEFAULT_MAX_REQUEST_HEADER_COUNT: u64 = 100; +pub const DEFAULT_MAX_REQUEST_TARGET_BYTES: u64 = 8_192; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct IngressHeadLimits { + max_request_header_bytes: u64, + max_request_header_count: u64, + max_request_target_bytes: u64, +} + +impl IngressHeadLimits { + pub fn max_request_header_bytes(&self) -> u64; + pub fn max_request_header_count(&self) -> u64; + pub fn max_request_target_bytes(&self) -> u64; + pub fn with_max_request_header_bytes(self, max: u64) -> Result; + pub fn with_max_request_header_count(self, max: u64) -> Result; + pub fn with_max_request_target_bytes(self, max: u64) -> Result; +} +``` + +All three values must be nonzero. Validation runs when `App` is finalized, before core request +conversion, body polling, admission, or dispatch; Axum additionally validates before listener +bind. Invalid configuration is `Internal`; it never silently means unlimited. Limits are +inclusive and use checked `u64` accounting. + +This seam starts when EdgeZero receives control. It cannot reject or time-bound bytes a +provider accepted or buffered before invoking guest code; adapter capability documentation +must state that host-side exposure rather than attributing it to the guest deadline. + +### 1.2 Raw request-head limits and framing policy + +At a raw parser boundary, request-target bytes are the exact octets between the first and +second spaces of the HTTP/1 request line, before URI normalization. Header bytes are the raw +field-section octets from the first field-name through the terminating empty line, including +colons, optional whitespace, line endings, and the final line ending. Header count is the +number of field lines before duplicate coalescing. The parser rejects on the first byte or +field above the configured value; it does not read an unbounded head and check afterward. +For multiplexed protocols, equivalent accounting is performed on the encoded field section +only when the adapter exposes a bounded pre-materialization hook. + +The raw parser returns `EdgeError::UriTooLong { message }` (414) for target overflow and +`EdgeError::RequestHeaderFieldsTooLarge { message }` (431) for header byte/count overflow. +Neither diagnostic includes target or header contents. These failures happen before route +resolution and therefore never enter `StoredError` through a body drain, but those variants +are still included in its total match. Malformed request syntax remains `BadRequest` (400). + +Admission can inspect whether the configured parser contract actually ran: + +```rust +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum IngressHeadAccounting { + HostManaged, + RawValidated { + request_header_bytes: u64, + request_header_count: u64, + request_target_bytes: u64, + }, +} +``` + +`IngressHead::head_accounting()` returns this value. `RawValidated` is emitted only when all +three values were measured before normalized request construction and found within the exact +`IngressHeadLimits` installed for that service. A normalized `HeaderMap` recount may reject +visible data as defense in depth but must report `HostManaged`; it cannot satisfy the raw +capability or recover field-line/request-target octets discarded by the platform. + +`IngressFraming` distinguishes raw-validated framing from a host-managed request whose raw +boundary EdgeZero cannot inspect: + +```rust +#[non_exhaustive] +pub enum IngressFraming { + Chunked, + ContentLength(u64), + HostManaged, + NoBody, + ProtocolManaged, +} +``` + +`NoBody`, `ContentLength`, `Chunked`, and `ProtocolManaged` are produced only after raw +validation and record an unambiguous result. `HostManaged` is required when the adapter's +`raw-ingress-framing-validation` support is `Unsupported`: it makes no assertion that +ambiguous framing was rejected and must not be treated by admission policy as trusted +length/framing evidence. No variant is reconstructed from a normalized `HeaderMap`. +For HTTP/1.x, EdgeZero rejects at the parser/connection boundary, before a body reader is +created, when any of these hold: + +- both `Content-Length` and `Transfer-Encoding` are present, in either field order; +- `Content-Length` is malformed, signed, overflows `u64`, appears more than once, or uses a + comma-list form, including repeated identical values; +- `Transfer-Encoding` is malformed, repeats `chunked`, does not end in exactly one + `chunked`, or contains a transfer coding the adapter does not implement; or +- framing is otherwise invalid for the request's HTTP version. HTTP/2 and HTTP/3 reject a + `Transfer-Encoding` field rather than treating it as HTTP/1 framing (`TE: trailers` is a + separate field and rule). + +The rejection response is 400; HTTP/1 closes the connection and multiplexed transports +reset only the affected stream. EdgeZero does not drain an ambiguously framed body and does +not invoke the admission callback. This strict duplicate-`Content-Length` policy deliberately +chooses one parser interpretation instead of accepting RFC-permitted identical duplicates. + +Axum is the first required strict raw-boundary implementation. Hyper can discard a +`Content-Length` encountered after `Transfer-Encoding`, so checking the resulting +`Request`/`HeaderMap` is insufficient and must not be described as smuggling +protection. The Axum adapter needs an audited pinned Hyper parser patch or an upstream parser +hook that rejects the strict policy while Hyper still has ordered `httparse` field lines. +The adapter now owns its HTTP/1 accept path and pinned Hyper 1.12.0 connection futures. +`http1::Builder::{max_headers,max_header_size,max_buf_size,header_read_timeout}` provide bounded +pre-admission resource controls, but they do not expose exact request-target/raw-header accounting +and do not preserve every +ambiguous field shape required by this contract; configuring those knobs alone cannot promote +either raw capability. +After that parser has rejected ambiguity, the adapter may derive `IngressFraming` from the +surviving normalized headers plus HTTP version; the derivation is trusted only because the +raw parser decision preceded it. A second independent socket pre-parser is not accepted: it +can disagree with Hyper and cannot safely locate subsequent pipelined request heads without +also owning HTTP body framing. Raw-socket tests in §4 pin the parser behavior. Platform SDKs that expose +only normalized requests cannot claim Native framing validation based on assumed host +behavior; they remain Unsupported until a documented/testable raw rejection seam exists. + +#### Implemented Axum HTTP/1 resource boundary + +`AxumIngressConfig` installs finite validated connection, combined raw-head byte, raw field-count, +and absolute idle-plus-header limits through the standard server startup path. The connection +pool owns at most the configured number of futures plus one transient accepted socket on refusal; +refusal creates no parser, response, task, or capacity waiter. The first deadline is stamped before +scheduling, subsequent deadlines start when Hyper first polls the next eligible keep-alive head, +and byte trickling does not reset them. Independent native timer wakes, I/O checks, and a completed +head check enforce expiry before application admission without replacing application body/egress +deadlines. Shutdown and cancellation drop all connection-owned state. + +The raw-head byte bound includes the request line and all field syntax together. Exact inclusive +head and field-count limits are tested through real TCP; these are not the independent +`IngressHeadLimits` measurements above. Hyper remains the sole HTTP/1 parser. HTTP/2 and socket +upgrade ownership are not enabled by this server path. + +Receive allocation bounds are audited separately from Hyper's logical buffer threshold. Response +heads are capped and copied into compact storage before Hyper caches them for later request parsing; +oversized heads close the connection without another response allocation. Configuration defaults, +ranges, precise wire definitions, trailer behavior, allocation assumptions, and exclusions are in +the [Axum adapter guide](../../guide/adapters/axum.md#bounded-http1-ingress). Real TCP acceptance +tests are colocated in the adapter's `connection.rs` and `dev_server.rs`. This implementation does +not claim strict ambiguous-framing rejection, exact independent target/header totals, kernel-buffer +bounds, or complete RSS certification. Raw capability promotion remains a future parser task. + +### 1.3 Absolute body-read deadline and cancellation + +The admitted `read_deadline` and its paired `MonotonicClock` are carried with the native +body and govern the full inbound +read lifetime: waiting for the first byte, every inter-chunk wait, drain completion, and +late EOF/error. It is not reset by routing, middleware, extractors, repeated cached reads, +or transfer into `OutboundRequest::from_request`. Before each platform read and immediately +after any chunk/EOF/error becomes ready, compare the same absolute deadline. At simultaneous +read completion and expiry, expiry wins. + +When expiry is observable at a guest boundary, the adapter drops or aborts the owned reader +with its strongest available platform primitive and yields +`EdgeError::RequestTimeout { message }` (408). If `BodyCell` is draining, its existing +capture path transitions exactly once to `Poisoned(StoredError::RequestTimeout { .. })`; +the first and every later accessor observe the same status/kind/message and the source is +never polled again. Expiry after a clean cached drain does not retroactively poison cached +bytes. Dropping a caller future without deadline expiry keeps the separate documented +`Internal("inbound body drain cancelled")` poison. + +The public deadline contract is portable; the teardown strength is not. Add four +non-outbound capability cells to the shared capability ladder: + +| Capability | Axum | Cloudflare | Fastly | Spin | +| --- | --- | --- | --- | --- | +| `ingress-admission` | Native | Native | Native | Native | +| `ingress-admission-abort` | Native | BestEffort | BestEffort | BestEffort | +| `inbound-read-deadlines` | Native | BestEffort until a deployed cancellation probe passes | BestEffort (synchronous host reads are not guest-preemptible) | BestEffort until host-observed cancellation is bounded | +| `raw-ingress-head-limits` | Unsupported | Unsupported | Unsupported | Unsupported | +| `raw-ingress-framing-validation` | Unsupported | Unsupported | Unsupported | Unsupported | + +Axum maps `Abort` to a service error from its owned Hyper HTTP/1 connection and drops the +connection before response-head construction; a raw-socket test proves EOF/reset and zero response +bytes. Cloudflare, Fastly, and Spin return the strongest fixed platform error available before core +request-body construction, but remain `BestEffort` until deployed evidence proves that the host +does not synthesize a response or retain the request. No adapter is permitted to translate `Abort` +into EdgeZero's ordinary JSON error response or invoke response-egress callbacks. + +The same adapter rule applies when the detached-egress decision factory selects `Abort` for a +normalized validation or admission-policy failure. Axum proves zero response bytes at its owned +connection boundary. Cloudflare, Fastly, and Spin expose only their fixed pre-response provider +error boundaries and make no transport-observed reset claim. + +BestEffort deadline implementations still use pre-read/post-ready checks and release ownership when +expiry is observed; they do not claim a finite bound around an uninterruptible or opaque host +call. Fastly cannot preempt its synchronous read. Spin races each pending body read against +`spin_sdk::time::sleep(remaining)` and selects the timer first, so guest code can promptly emit +408 and release its source/grant ownership even when the body stream never wakes. Spin remains +BestEffort because dropping that guest reader does not prove that host-side teardown completed +within a bounded interval. A manifest +that requires Native support fails before startup/deploy through the same capability ladder +used elsewhere. Raw head limits, raw framing, and body-read deadline are separate cells: a +platform host may impose undocumented limits or reject malformed framing without exposing +evidence, and may expose a cancellable body while hiding raw target/field-line bytes. +All current standard adapters pass `IngressHeadAccounting::HostManaged` and +`IngressFraming::HostManaged`. Axum is the first planned raw-boundary implementation, but its +two raw capability cells remain `Unsupported` until the parser-boundary implementation and +raw-socket evidence land. + +Axum can preempt its asynchronous native body read at the configured deadline, which supports +the `Native` deadline cell. Its owned Hyper HTTP/1 connection loop runs the portable non-`Send` +dispatch future on a Tokio `LocalSet`; no blocking bridge or nested runtime separates caller +cancellation from the drain. Closing the connection drops the in-flight dispatch future, body +source, and fallback grant. Deadline expiry races the native body read, returns the selected +timeout response when the connection remains writable, and releases the source and grant exactly +once. The adapter suite covers pending reads, cancellation/drop, and deadline precedence. + +Cloudflare performs one `Delay::from(Duration::ZERO).await` cooperative yield before +selecting the body read against its host timer. A deliberately frozen injected clock means +clock-based post-ready checks cannot independently observe elapsed time; the real host timer +still uses the last positive `remaining_at` snapshot. This is deterministic test support, +not a claim that production time can freeze. Native promotion requires one deployed timing +probe that records runtime version, configured deadline, observed cancellation latency, and +tolerance; local mocked timers are insufficient. + +Spin likewise creates its platform timer from the last positive `remaining_at` snapshot and +keeps the post-ready check against the admitted app clock. Its WASI contract suite injects a +deterministic pending-then-ready timer to prove timer-first selection for permanently pending +matched and fallback sources, exact 408/application timeout responses, and exactly-once source +and grant release. The standard WASI test harness cannot poll the provider clock import outside +an exported component task, so the production `spin_sdk::time::sleep` wiring is compile-checked +locally and still requires the deployed host-observed timing probe before Native promotion. + +## 2. Bounded Context Helpers + +Wrap the existing `Body::into_bytes_bounded` with context-level helpers: + +```rust +// crates/edgezero-core/src/context.rs +impl RequestContext { + /// Read the inbound request body into `Bytes`, bounded by `max`. + /// Over-limit yields `Err(EdgeError::bad_request(..))` (400). + /// + /// **Takes `&self`** — `RequestContext` carries an internal body cache + /// (an `unsync::OnceCell` style cell; single-threaded + /// request, no `tokio` dep). This is deliberate so that existing + /// `FromRequest` extractors that take `&RequestContext` (e.g. `Json`, + /// `ValidatedJson`) can call it without a trait-signature breaking + /// change. The first call drains the underlying `Body::Stream` into + /// the cell; later calls return a cheap clone. The cached size is + /// re-validated against `max` on every call, so a later, stricter cap + /// is still enforced after buffering. The network body is read at most + /// once. + #[inline] + pub async fn body_bytes(&self, max: usize) -> Result; + + /// Call `body_bytes(max)` then deserialize as `application/x-www-form-urlencoded`. + /// Default cap from extractors: `DEFAULT_INBOUND_FORM_BYTES = 1 MiB` + /// (forms are typically small). Malformed form data → `bad_request` (400). + /// Same `&self` cache semantics as `body_bytes`. + #[inline] + pub async fn form_within(&self, max: usize) + -> Result; + + /// Call `body_bytes(max)` then deserialize as JSON. Malformed inbound + /// JSON yields `Err(EdgeError::bad_request(..))` (a client bug → 400, + /// in contrast to outbound `OutboundResponse::json` which maps to 502). + /// Same `&self` cache semantics as `body_bytes`. + #[inline] + pub async fn json_within(&self, max: usize) + -> Result; +} +``` + +## 3. Context and Adapter Migration + +The memory guarantee in §3.1 only holds if the adapter does not pre-buffer the +inbound request body before core can apply a cap. Today every adapter pre-buffers +(`crates/edgezero-adapter-axum/src/request.rs:24` buffers JSON with `usize::MAX`; +`crates/edgezero-adapter-cloudflare/src/request.rs:60` calls `req.bytes()`; +the Fastly and Spin paths fully materialize the body too). This migration changes that: + +- **Adapter request conversion** stops pre-buffering. Inbound `Request` is exposed to + core with a `Body::Stream` (or `Body::Once` only when the platform genuinely owns + the bytes already — e.g. an in-process Axum body that arrived buffered). Each + adapter's `request.rs` is updated to wrap the platform body as a stream rather than + drain it eagerly. +- **`RequestContext` is restructured** — today it holds a plain `Request`, which cannot + be safely mutated through `&self`. The new shape: + + ```rust + struct BodyCell(/* unsync */ RefCell); + + /// Non-consuming snapshot of cell state for app inspection. + pub enum BodyKind { + Cached { len: usize }, + Draining, + Initial, + Poisoned, + Taken, + } + + enum BodyState { + Cached(Bytes), // body drained successfully + Draining, // body taken out, drain in progress + Initial(Body), // never read; the platform body is still owned + Poisoned(StoredError), // drain failed (over-cap, stream error, drop) + Taken, // body consumed via take_body / into_request + } + + pub struct RequestContext { + body: BodyCell, // interior-mutable + parts: http::request::Parts, // method, uri, version, headers, extensions + path_params: PathParams, + } + ``` + + **`StoredError` — why the cell cannot just store an `EdgeError`.** The poison + contract requires that *every* subsequent access (`body_bytes`, `json_within`, + `form_within`, `into_request`) returns **the same error**. That needs the error to be + reproducible — but **`EdgeError` is not `Clone`**: its `Internal` variant wraps + `anyhow::Error` (`error.rs`: `Internal { #[from] source: AnyError }`), which is + deliberately not clonable. Storing an `EdgeError` and handing out copies therefore + does not compile. `StoredError` is the clonable, reconstructable **essence** of the + error that poisoned the cell: + + It **must be a variant-specific snapshot enum, NOT `{ kind, message }`** — that flat + shape cannot rebuild `EdgeError` faithfully, on two counts a compiler forces: + (a) `EdgeError::Internal`'s `message()` already renders as `"internal error: {source}"`, + so rebuilding via `internal(anyhow!(message))` **doubles the prefix**; (b) + `ConfigOutOfDate` (`field_path`), `MethodNotAllowed` (`method`, `allowed`), and + `NotFound` (`path`) carry structured payloads a single `message` string cannot hold. So + `StoredError` mirrors the variants and captures each payload: + + The [blob-app-config design](2026-06-16-blob-app-config.md) §6.3 classification is + finalized. `StoredError` mirrors its reason-bearing `StoreExtraction` variant and must + continue to mirror every future `EdgeError` payload before this state machine lands. + + ```rust + #[derive(Clone)] + enum StoredError { + BadGateway { message: String, reason: BadGatewayReason }, + BadRequest { message: String }, + ConfigOutOfDate { field_path: String, message: String }, + GatewayTimeout { cause: BudgetSource, message: String }, // preserve typed cause + Internal { rendered: String }, // ALREADY-rendered source; no re-prefixing + MethodNotAllowed { allowed: Vec, method: Method }, + NotFound { path: String }, + NotImplemented { message: String }, + RequestTimeout { message: String }, // admitted inbound read deadline expired + ResponseTooLarge { message: String, reason: ResponseLimitReason }, + ServiceUnavailable { message: String }, + StoreExtraction { + field_path: Option, + message: String, + reason: StoreExtractionReason, + }, + Validation { message: String }, + } + + impl StoredError { + /// Capture an EdgeError's essence at poison time (total match — cannot silently + /// drop a variant). For `Internal`, store `source.to_string` (already rendered), + /// NOT `err.message`, so reconstruction does not re-add the "internal error: " + /// prefix. + fn capture(err: &EdgeError) -> Self { /* one arm per variant */ } + /// Rebuild an equivalent `EdgeError` — same variant, same fields, same status. + /// `Internal { rendered }` → `EdgeError::internal(anyhow!(rendered))`. + fn to_edge_error(&self) -> EdgeError { /* inverse of capture */ } + } + ``` + + Application-owned error rendering is installed once with + `App::set_error_response_renderer(Fn(EdgeError) -> Response)`. The synchronous callback runs + exactly once for handler/routing failures, failures converted with `admitted_error_egress`, and + detached normalized-ingress failures. It permits a deployment to return a fixed-size body + without allocating the framework's canonical JSON payload. EdgeZero applies protocol-required + status and metadata after the callback, so a custom renderer cannot reinterpret the error status + (the callback's returned status is ignored) and a custom 405 renderer cannot remove or replace + the canonical `Allow` field. The same centralized mandatory-header policy preserves + `Retry-After: 60` for every effective `config_out_of_date` outcome after custom rendering. + Explicit + `AdmissionDecision::Refuse` responses and the `on_exceeded`/`on_timeout` fallback responses + bypass the renderer because the application supplied those complete responses directly. Raw + parser failures before EdgeZero receives a request remain outside the application lifecycle. + + **Decomposition happens once, at poison time.** The drain's `EdgeError` is captured + into `StoredError` and the cell returns `stored.to_edge_error()` — so *even the first* + read gets a reconstructed error, and all later reads are identical. Every accessor's + signature stays `Result<_, EdgeError>` (no `Rc` leaking into the public API). + + **Documented loss:** for the `Internal` variant the **`anyhow` source chain and + backtrace are not preserved** — only the rendered string. A reconstructed `internal` + error's `inner()` yields a fresh `anyhow::Error` carrying that string, not the original + chain. Accepted trade: the alternatives are `EdgeError: Clone` (impossible without + dropping `anyhow`) or `Rc` on every accessor (an API wart for a + diagnostic-only benefit). A platform stream that needs the original chain for + diagnostics must log at the stream-error production boundary, before yielding the typed + error. *(A `BodyCell` drain only ever produces `bad_request` / `bad_gateway` / + `gateway_timeout` / `request_timeout` / `internal`; the other structured variants are + still covered so the enum is total and `capture` never needs a lossy fallback arm.)* + + **Cancelled drain.** A drain future dropped while `Draining` transitions the cell to + `Poisoned(StoredError::Internal { rendered: "inbound body drain cancelled".into() })` + via a drop guard (§4), so a cancelled read is indistinguishable in shape from any + other poison — the next access returns that stored error rather than silently + re-reading a half-consumed body. + + `RefCell` (unsync) is fine because a `RequestContext` is owned per-request and + EdgeZero's async traits already use `?Send`. No `tokio` dependency in core. + + **Construction contract — `RequestContext::new(Request, PathParams)` is PRESERVED.** + `parts` and `body` are **private**, and `BodyCell` / `BodyState` are **not public + types**. Adapters therefore do **not** — and cannot — construct the context from + "parts + a body cell"; earlier drafts said they should, which both leaks an internal + type and misassigns ownership (adapters build a `Request`; the **router** builds the + `RequestContext`). The existing signature is kept verbatim: + + ```rust + impl RequestContext { + #[inline] + pub fn new(request: Request, params: PathParams) -> Self { + Self::new_low_level(request, params, MonotonicInstant::now()) + } + + #[inline] + pub fn request_start(&self) -> MonotonicInstant; + + #[inline] + pub fn monotonic_clock(&self) -> MonotonicClock; + + #[inline] + pub fn route_metadata(&self) -> Option<&RouteMetadata>; + + #[inline] + pub fn take_ingress_grant(&self) -> Option; + } + ``` + + So the migration is **source-compatible for every caller of `new(..)`** — adapters + and the router keep passing a `Request` exactly as they do today, and the + parts/body split becomes an implementation detail. For this low-level constructor, + `request_start` is snapped when `new` is called, `route_metadata()` returns `None`, and + `take_ingress_grant()` returns `None`. That path makes + no `ingress-admission` claim. Resolved router dispatch uses a crate-private constructor + that consumes `AdmittedIngress` and installs its original start, matched metadata, and + grant before middleware. What adapters *do* change is + **what they put in that `Request`**: a lazy `Body::Stream` instead of a + pre-buffered body (first bullet above). `BodyCell` never appears in any public + signature; the only new public surface is the accessor set (`parts()`, + `parts_mut()`, `body_kind()`, `body_bytes`, `json_within`, `form_within`, + `take_body`, `into_request`). + + **Async drain protocol.** A naive "borrow_mut across .await" implementation would + panic on reentrant access or hold the borrow indefinitely if the future is dropped + mid-drain. The implementation is therefore: + + 1. Briefly borrow the cell, `mem::replace` the state with `Draining` while taking + ownership of the `Body`, drop the borrow. (No borrow held across any `.await`.) + 2. Drive the async drain on the owned `Body`. A drop guard wraps the drain such + that, on success, the cell is set to `Cached(bytes)`; on stream error or cap + overflow, the cell is set to `Poisoned(stored_err)`; on **future-cancellation** + (the drain future is dropped), the guard's `Drop` sets the cell to + `Poisoned(StoredError::cancelled())`. The network body is partially consumed and + unrecoverable in every failure case — poison is sticky. + 3. While the cell is in `Draining`, any reentrant `body_bytes` / `json_within` call + observes that state and returns `Err(EdgeError::internal("body read already in + progress"))` rather than panicking; this would only occur in programmer-error + scenarios but must not crash the host. + + Tested in §4: a scripted stream first returns `Pending`, allowing a second accessor to + observe `Draining`; the second call returns `internal` without a panic, then the first + drain is either resumed to success or dropped to exercise cancellation poison. + +- **Public methods become coherent with the cache.** Their post-cache behaviour is + explicit so middleware → handler → proxy-forward chains compose: + + | Method | Behaviour | + | --- | --- | + | `method()` / `uri()` / `headers()` / `extensions()` | from `parts` — unaffected by body state | + | `headers_mut()` / `extensions_mut()` | mutates `parts` — unaffected by body state | + | `parts() -> &http::request::Parts` / `parts_mut() -> &mut http::request::Parts` | direct access to the underlying `Parts` for middleware that needs the full snapshot; same body-state-irrelevance as the granular accessors above. These are the migration target for call sites currently doing `ctx.request()` / `ctx.request_mut()` (§5 sweep). | + | `body_kind() -> BodyKind` | a non-consuming snapshot of the cell state — variants enumerated above (`Initial \| Draining \| Cached { len } \| Poisoned \| Taken`). There is **no** `body() -> &Body` / `body() -> Body` accessor — a `&Body` reference cannot span the cell's interior mutability, and a value-returning getter would either consume the stream (single-shot) or require a tee. Callers either buffer via `body_bytes`/`json_within` or consume via `take_body`/`into_request`. | + | `take_body() -> Result` | consume the body out of the context: `Initial` → `Ok(Body::Stream(..))`, set state to `Taken`; `Cached(bytes)` → `Ok(Body::Once(bytes))`, set state to `Taken`; `Draining` → `Err(EdgeError::internal("body read in progress"))` (programmer error); `Poisoned(err)` → `Err(err.to_edge_error())`; `Taken` → `Ok(Body::empty())`. After a successful `take_body`, the body cannot be re-read or buffered. | + | `body_bytes(max)` / `json_within(max)` / `form_within(max)` | from `Initial`: drains → `Cached`, returns clone (or → `Poisoned(err)` on drain failure, then returns that error). From `Cached`: re-validates `max` and returns a clone. From `Poisoned`: returns a fresh `EdgeError` reproduced from the stored error. From `Draining`: `Err(EdgeError::internal("body read in progress"))` — programmer error. From `Taken`: `Err(EdgeError::internal("body already consumed via take_body"))` — buffered helpers cannot resurrect a body that was handed out. | + | `into_request() -> Result` | reassembles a `Request` from `parts` + the cell's body via the same rules as `take_body`: `Cached` → `Ok(Body::Once(bytes))`, `Initial` → `Ok(Body::Stream(..))`, `Draining` → `Err(EdgeError::internal("body read in progress"))` (programmer error), `Poisoned(err)` → `Err(err.to_edge_error())` — **not** `Body::empty()`, because a poisoned read silently turning into an empty proxy-forward would violate the "poison is sticky" rule below, `Taken` → `Ok(Body::empty())` (the caller consumed via `take_body`, the empty is intentional). This is what `OutboundRequest::from_request(ctx.into_request()?, uri)?` uses, so streaming proxy-forward still works **even after middleware has buffered the body** (the cached `Bytes` flow through), and a permissive proxy-forward cannot mask a stricter middleware's poisoned read. | + + The legacy `request()` / `request_mut()` accessors are removed (they leaked the + whole `Request` and made the body cell incoherent); call sites switch to + `parts()` / `parts_mut()` for headers/method/uri/extensions, `body_kind()` for + state inspection, `body_bytes(max)` / `json_within(max)` for buffered consumption, + `take_body()` for one-shot consumption, and `into_request()` for proxy-forward + reassembly. + +- **Poison semantics on failed body reads.** If `body_bytes` fails mid-drain — the cap + is exceeded, the stream errors, or a future cancellation interrupts the drain — the + network body has already been partially consumed and cannot satisfy any later call. + The body cell transitions to `Poisoned(stored_err)`, where `stored_err` is enough + metadata to reproduce a fresh `EdgeError` on every subsequent call (since `EdgeError` + is not `Clone`). All later `body_bytes`/`json_within` calls return that error; + `body_kind()` reports `Poisoned`; `take_body()` and `into_request()` both return + `Err(stored)` — the latter explicitly fallible so a poisoned read cannot silently + become an empty proxy-forward. The network body is **not** + retried. This is the most defensible contract: silently re-reading is impossible, and + silently succeeding with a larger-cap call would let a permissive extractor mask a + stricter middleware's enforcement. The poisoned error variant matches the first + failure (e.g. an over-cap drain returns `bad_request` on call N+1 too). + +- **Existing extractors.** All extractors that consume the inbound body are migrated to + the bounded helpers: + + | Extractor (today) | After migration | + | --- | --- | + | `Json` (uses `ctx.json()`, assumes buffered body) | delegates to `ctx.json_within(DEFAULT_INBOUND_JSON_BYTES)` — `DEFAULT_INBOUND_JSON_BYTES = 8 MiB` | + | `ValidatedJson` | as above + `validator` pass; sibling `ValidatedJsonWithin` for explicit caps | + | `Form` (uses `ctx.form()`, also rejects streams today — `crates/edgezero-core/src/extractor.rs:375`, `crates/edgezero-core/src/context.rs:31`) | delegates to a new `ctx.form_within(max)` helper, default `DEFAULT_INBOUND_FORM_BYTES = 1 MiB` (forms are typically small) | + | `ValidatedForm` | as above + `validator` pass; sibling `ValidatedFormWithin` for explicit caps | + + The legacy `RequestContext::json()` and `RequestContext::form()` are removed; both + required `Body::Once` and would break once adapters stop pre-buffering. + +- **Extractor trait.** No change required — `FromRequest::from_request(&RequestContext, + ..)` continues to take `&RequestContext`, which works because `body_bytes` is now + `&self`-callable through the cache. + +Net effect: per-inbound-body memory is bounded at the boundary of the bounded helper +that actually reads the body; failed reads are sticky so a permissive caller cannot +silently bypass a stricter one; streaming proxy-forward works whether or not middleware +already buffered the body. The memory cap is independent of §1.3's time bound: both apply +to the same first drain, so an under-cap slow trickle still expires. + +**Sticky poison is scoped to READ/DRAIN failures, NOT cap-rechecks on an already-`Cached` +body — stated to remove an apparent contradiction.** Once the body is `Cached { bytes }`, +a `body_bytes(cap)` call is a **stateless length check** (`bytes.len() <= cap`) that does +**not** mutate the cell: an over-cap result returns an error but leaves the state `Cached`, +so a later `body_bytes(larger_cap)` where the cached length fits **legitimately succeeds**. +This is intended and is **not** a violation of stickiness: stickiness governs a body that +was **consumed/failed while draining** (`Initial → Draining → Poisoned`) — there the cell +is poisoned and every subsequent access (any cap) returns the stored error. The rule +"a permissive caller cannot bypass a stricter one" is about a **poisoning drain**, not +about re-reading an intact cache at different caps. (The security property still holds: +the *first* reader that actually drains sets the cache/poison; a cap check against an +existing cache reveals nothing a caller couldn't compute from the already-materialized +bytes.) §4 pins this: permissive read (caches) → stricter `body_bytes` (over-cap error, +cell stays `Cached`) → permissive retry (succeeds) — asserting the stricter failure does +**not** poison an intact cache. + +### 3.1 Memory Bound + +*(Moved here from the outbound spec's §3.4.4 batch-memory model — this is the inbound-body +half; the outbound spec keeps only the per-response and batch terms.)* + +- **Per-inbound-body.** *Persistent* memory — the cached `Bytes` after a successful drain — + is bounded by the `max` passed to `body_bytes(max)` / `json_within(max)` / + `form_within(max)`. *Transient* worst-case during the drain is the same shape: + `max + current_chunk.len()`, with the in-flight chunk source-controlled. Outbound's + `OutboundResponse::into_bytes_bounded` mirrors this same accounting. + +### 3.2 Extractor Migration + +*(Moved here from the outbound spec's §7 file-by-file migration — this is inbound-only.)* + +- `src/extractor.rs` — extractor migration: `Json` / `ValidatedJson` route through + `ctx.json_within(DEFAULT_INBOUND_JSON_BYTES)`; `Form` / `ValidatedForm` route + through `ctx.form_within(DEFAULT_INBOUND_FORM_BYTES)`; add `ValidatedJsonWithin` + and `ValidatedFormWithin` for explicit caps. Constants exposed: + `pub const DEFAULT_INBOUND_JSON_BYTES: usize = 8 * 1024 * 1024;` and + `pub const DEFAULT_INBOUND_FORM_BYTES: usize = 1024 * 1024;`. + +## 4. Test Plan + +Core tests use scripted local streams and `futures::executor::block_on`; they require no +platform runtime or network. + +| Surface | Required assertions | +| --- | --- | +| Bounded drain | `Body::Once` and multi-chunk streams succeed at and below the cap; the first byte above the cap returns `bad_request` without unchecked accounting overflow. | +| Successful cache | The platform stream is polled only once; repeated reads clone the same bytes. A permissive read followed by a stricter cached read returns an over-cap error while leaving the cell `Cached`; a later permissive read succeeds. | +| Failed drain | Source error and initial-drain cap overflow transition to `Poisoned`; every later buffered accessor, `take_body`, and `into_request` reconstructs the same variant, status, message, and structured fields. The source is never polled again. | +| Parser-level head limits (future acceptance criterion) | Exact-limit request targets, header bytes, and header counts pass at a raw parser boundary. One byte/field over rejects before route resolution, admission, request construction, or body polling with 414/431 and redacted diagnostics. Raw accounting includes duplicate field lines and framing syntax; checked overflow fails closed. Current host-normalized recounts never report `RawValidated` and do not satisfy this row. | +| Admission ordering and route identity | Raw validation runs first where available. Route resolution then runs exactly once without middleware or body polling, and the callback sees the resulting stable `RouteResolution`. Admission runs exactly once before resolved dispatch/body polling. A matched dispatch uses the admitted route and path parameters without rematching; method/path mutation or a foreign token fails closed. Route class reaches matched and 405 metadata but never changes `RouteId`. Refusal polls no body, invokes no middleware/handler, terminates the native reader, preserves the chosen response, and carries its completion through detached egress. `Abort` polls no body, creates no response/attempt, invokes no middleware/handler/completion/observer, and reaches the adapter abort boundary. The default policy supplies empty grant/completion values and one finite deadline derived from `request_start`. | +| Detached normalized-error egress | Every factory-selected `Send { completion, deadline }` carries a mandatory absolute deadline in the request's injected monotonic-clock domain. `write_deadline_after` anchors to `request_start`, clamps to `DEADLINE_FAR_FUTURE`, and fails closed to `request_start` on overflow; the default combines an empty completion with `DEFAULT_RESPONSE_WRITE_BUDGET` from request start. The effective bound is the earlier of the factory deadline and default response-egress policy, so neither can extend the other. Joined completions run left then right with the same borrowed report at most once, release both resources on abandonment, and isolate each child panic only on unwind-capable targets; panic-abort may stop before the right callback. Factory `Abort` creates no response or attempt and invokes no completion, observer, handler, middleware, or body poll. Admission-selected detached responses keep their response-owned deadline path outside this factory contract. | +| Bounded fallback precedence | For pre-resolved 404/405 requests, ordinary `Admit` retains no-poll routing behavior. Opt-in `ReadBodyBeforeFallback` holds the application grant while draining a lengthless or chunked body to EOF under one absolute deadline: exact cap preserves the canonical 404/405, the first byte over returns the exact `on_exceeded` status/header/body response first, and expiry returns the exact `on_timeout` response first. The grant is live during every poll and drops exactly once before response conversion, when the core drain future is dropped, or when the finite deadline terminates it. The completion survives each response choice and settles exactly once at egress; it is not exposed through `RequestContext`. No path rematches or invokes middleware, a handler, or `RequestContext`; `Refuse` and `Abort` remain zero-read; selecting fallback drain for a matched route fails before body construction and drops the never-started completion without a report. Every adapter suite covers both 404 and 405 exact-cap cases plus exact custom terminal responses at its honestly reported deadline capability. | +| Request ingress metadata | `IngressHead` and matched `RequestContext` expose the same captured `request_start`, paired app clock, and route metadata. `take_ingress_grant()` returns the non-clone grant exactly once, then `None`; untaken, refused, 404, and 405 grants each drop exactly once. The preserved low-level context constructor exposes no route and makes no admission claim. | +| Absolute read deadline | A 1-byte-per-step under-cap stream cannot extend its lifetime: first-byte, inter-chunk, EOF, and source-error races use one absolute deadline and the admitted app clock; expiry wins simultaneous readiness, cancels native ownership, and poisons a draining cell as `request_timeout` (408). A manually advanced injected clock proves the tie behavior without wall-clock sleeps. Spin additionally proves that a timer wake interrupts a permanently pending matched or fallback source, preserves the correct terminal response, and releases source/grant ownership exactly once. Cached success is not retroactively poisoned. | +| Cancellation | A stream held at `Pending` leaves the cell `Draining`; dropping the first `body_bytes` future transitions it to the documented cancellation poison, and the next access returns that stored internal error. | +| Reentrancy | While the first scripted drain is pending, a second body accessor returns `internal("body read already in progress")` without a `RefCell` panic. Resuming the first future can still complete and cache bytes. | +| Consumption | `take_body` and `into_request` cover Initial, Cached, Draining, Poisoned, and Taken. Initial preserves a stream; Cached reassembles `Body::Once`; Taken deliberately produces an empty body only where specified. | +| Extraction | JSON/form success, malformed input, default caps, explicit `Within` caps, and validator failures preserve their documented 400/validation behavior. Multiple extractors share the one cache. | +| Stored errors | `StoredError::capture` exhaustively covers all end-state `EdgeError` variants. Round-trips preserve variant fields, including `BadGatewayReason`, `BudgetSource`, `ResponseLimitReason`, and `StoreExtractionReason` plus its optional field path; `RequestTimeout` remains 408; `Internal` has the one documented source-chain loss without duplicating the `internal error:` prefix. | +| Raw HTTP/1 framing (future acceptance criterion) | Axum raw-socket cases cover CL+TE in both orders, duplicate equal/unequal CL, comma-list CL, overflow/signed/malformed CL, repeated/non-final/unsupported TE, valid single CL, and valid terminal chunked framing. Rejections return 400, close the connection, invoke no admission callback, and poll no body. A `HeaderMap`-only unit test is insufficient, and no current adapter satisfies this row. | + +Each adapter contract test supplies a body stream whose first poll is observable and +asserts that raw validation, when supported, route resolution, and admission complete before +that poll, while middleware and handler dispatch do not begin until after admission. Every +current standard adapter, including Axum, passes `IngressHeadAccounting::HostManaged` and +`IngressFraming::HostManaged`; their tests prove admission cannot accidentally upgrade those +values to a validated claim from normalized headers. A future Axum raw-boundary implementation +may pass validated variants only after the parser-boundary and raw-socket suite land. The same adapter test +then passes the converted core request through `RequestContext`, buffers it as middleware +would, and reassembles it through `into_request`; the outbound-facing request receives the +cached bytes unchanged and retains the original absolute read deadline. Tests must not +substitute an already-buffered body for this lazy ingress assertion. Cloudflare and Spin +need deployed/host-observed cancellation probes before upgrading their BestEffort cells; +Fastly tests cooperative checks without asserting preemption of a synchronous host read. Axum +tests native deadline preemption separately from outer Tower-task cancellation, which cannot +interrupt its current blocking bridge and therefore relies on the finite read deadline for +eventual release. + +## 5. File-by-File Change Summary + +- `crates/edgezero-core/src/context.rs`: split requests internally into private parts plus + `BodyCell`; add the state machine, bounded helpers, body-state accessors, `take_body`, and + fallible `into_request`; store the captured request start, optional route metadata, and + one-shot ingress grant; carry the admitted clock into every body deadline check; remove + whole-request borrow accessors. +- `crates/edgezero-core/src/app.rs`: add the synchronous, body-blind admission policy and + immutable `IngressHeadLimits` plus the injectable `MonotonicClock` to `App`, its default + finite inbound read budget, the normalized ingress-head/decision/accounting types, and the + response-or-abort dispatch result. Carry each decision-owned completion through prepared, + refused, fallback, error, and normal response paths without response extensions. + The macro uses + `Hooks::configure(&mut App) -> Result<(), EdgeError>` as the app-owned setter point, and + the core-owned `App::build::(platform)` prevents request conversion, admission, body polling, + or dispatch when startup policy is invalid. Axum also fails before listener bind; Cloudflare, Fastly, and Spin + enforce this boundary per host invocation. +- `crates/edgezero-core/src/error.rs`: add `RequestHeaderFieldsTooLarge { message }` with + status 431 and kind `request_header_fields_too_large`, `RequestTimeout { message }` with + status 408 and kind `request_timeout`, and `UriTooLong { message }` with status 414 and + kind `uri_too_long`; update every exhaustive match and wire-shape matrix. +- `crates/edgezero-core/src/manifest.rs`, `crates/edgezero-core/src/router.rs`, and + `crates/edgezero-adapter/src/registry.rs`: add optional trigger `class`, carry it as + non-identity route metadata, add the five inbound capability cells, and retain fail-closed + required-capability handling. Keep these counts separate from the outbound design's + eight-cell tuple. +- `crates/edgezero-core/src/extractor.rs`: route JSON/form extractors through bounded + helpers, add explicit-cap variants, and add the two public default constants. +- `crates/edgezero-core/src/body.rs`: retain the core bounded-drain primitive used by the + context and use checked pre-append accounting if it has not already landed through the + outbound body work. +- `crates/edgezero-adapter-{axum,cloudflare,fastly,spin}` request/dispatch services: stamp + `request_start`, apply normalized defense-in-depth checks while reporting the raw boundary + as host-managed, resolve once, and run app admission before middleware/handler dispatch or + body polling; stop eager collection; dispatch with the opaque resolved token; map the explicit + abort outcome to the strongest non-response platform boundary; and wrap each + platform request body with its absolute deadline and strongest available read-ownership + primitive. Keep `Body::Once` only when the platform already owns bounded bytes and admission + has already run. +- Future `crates/edgezero-adapter-axum` server connection work: enforce raw request-target/header + limits and validate raw HTTP/1 field lines before Hyper can discard framing evidence; + replace the current `axum::serve` ownership boundary, preserve HTTP/1 keepalive/pipelining, + HTTP/2, upgrades, connect metadata, and graceful shutdown, and reject/close on overflow or + ambiguity. Hyper builder limits are defense in depth, not the exact enforcement seam. The normalized + `Request` conversion is not the enforcement point. +- `crates/edgezero-core` call sites and tests: migrate `request()` / `request_mut()` users + to parts or body-specific accessors and update the now-fallible `into_request()` calls. +- Adapter contract and host tests: prove admission ordering, one absolute deadline, + target-specific cancellation strength, and the §1.2 raw-framing behavior/capability. + No outbound send or provider error-classification behavior is owned by this + specification. diff --git a/docs/superpowers/specs/2026-09-08-response-egress-design.md b/docs/superpowers/specs/2026-09-08-response-egress-design.md new file mode 100644 index 000000000..25441de66 --- /dev/null +++ b/docs/superpowers/specs/2026-09-08-response-egress-design.md @@ -0,0 +1,858 @@ +# EdgeZero Response Egress Design + +> **Status:** Approved implementation contract for provider-owned response lifetimes. +> **Owner:** Core response lifecycle plus adapter-owned transport boundaries. +> **Migration:** Atomic hard cut. No buffered compatibility path is retained. + +## 1. Scope and boundary + +This design owns the lifetime after router dispatch returns a core `Response` and before the +adapter has either reached its strongest observable completion boundary or terminally aborted +the response. It covers one absolute response-write deadline enforced to the adapter's +declared capability boundary, demand-driven body production, client cancellation where +observable, post-commit abort behavior, payload-byte accounting, one terminal report, one +response-scoped completion slot, and one global observer notification. + +It does not extend an outbound fetch deadline, classify upstream failures, or change outbound +response decoding. Those end when the outbound adapter returns an `OutboundResponse`, as +defined by the [outbound HTTP design](2026-05-21-outbound-http-design.md). Inbound request +admission and body-read deadlines are owned by the +[inbound-body design](2026-08-22-inbound-body-design.md). + +Provider-owned buffers, kernel buffers, TCP acknowledgement, peer receipt, and complete +provider/runtime allocation accounting remain outside this contract. Every capability claim +names its exact observable boundary and must not imply one of those excluded guarantees. + +This design replaced the historical converter-owned response lifetime, where Axum, Fastly, +and Spin materialized `Body::Stream` and Cloudflare reported terminal state before its actual +JavaScript writer boundary. The current adapters use the owned coordinators in §5. Returning a +response object is not an egress completion event. + +## 2. Design choice + +Each adapter owns a runtime-specific body pump and transport lifetime. Core supplies the +portable clock, policy, immutable response metadata, and exactly-once attempt guard. There is +no cross-adapter writer or sink trait: Hyper polling, Workers streams, synchronous Fastly +handles, and WASI stream writers have materially different progress and cancellation models. + +Merely replacing collection with each SDK's convenient stream wrapper is insufficient. Such +wrappers hide one or more of deadline wakeup, accepted-byte accounting, consumer cancellation, +finish, or abort. The standard EdgeZero entrypoint must retain ownership through the strongest +boundary the provider exposes. + +One adapter-local coordinator exclusively owns each `ResponseEgressAttempt`. Bodies, timers, +request signals, connection supervisors, and platform writers submit events to that owner; +they do not own independent attempt guards. The coordinator serializes competing terminal +events and releases all source and transport resources after the first terminal transition. + +Every final client response produced by an admission decision uses the owned transport path, +including successful handlers, handler errors, framework 404/405 responses, bounded fallback +responses, and admission refusals. Errors that arise after `PreparedIngress` exists but before +normal dispatch completes consume that proof through `App::admitted_error_egress`; the envelope +retains the app clock, request metadata, policy, observer, response-scoped completion, deadline, +and exactly-once terminal semantics. +An adapter must not turn source construction, core request conversion, or dispatch failure into a +provider error or detached response after this boundary. + +An admission refusal uses detached ingress policy and a no-op global observer, but carries the +response-scoped completion supplied by that refusal. Normalized head-validation failures and +admission-policy errors invoke the application's detached-egress decision factory. The default +factory returns: + +```rust +DetachedResponseEgressDecision::Send { + completion: ResponseEgressCompletion::empty(), + deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET), +} +``` + +The factory receives only bounded failure metadata and runs exactly once before any envelope is +created; it does not run middleware, the handler, the application egress policy, or the global +observer. +`Send { completion, deadline }` uses the same detached transport path with that completion and a +mandatory absolute deadline in the request's injected monotonic-clock domain. It cannot extend the +default response-egress policy because `begin()` enforces the earlier bound. `Abort` creates no +response, envelope, attempt, completion callback, observer call, handler call, middleware call, or +body poll and reaches the adapter's existing non-response abort/error boundary. Normalized +validation invokes the factory before routing and admission; a policy error invokes it after route +resolution and the failing policy callback. Admission-selected detached responses, including +refusals, retain their existing response-owned deadline path and are outside this factory contract. +`AdmissionDecision::Abort` produces no response, starts no egress attempt, and invokes neither +completion nor observer. Host/parser-generated responses and platform-to-core head-conversion +failures emitted before EdgeZero can construct a normalized head remain outside this lifecycle, +even when request start was already sampled. They are governed by the raw-ingress and adapter +conversion contracts. + +## 3. Core contract + +### 3.1 Policy and timing + +`ResponseEgressEnvelope` retains the exact `App::monotonic_clock()` clone used for ingress and +standard outbound dispatch. `begin()` snapshots `egress_started_at` from that clock immediately +before its first response-conversion operation and returns the same clock with the response, +policy, and attempt. The adapter obtains one finite absolute deadline: + +```rust +pub const DEFAULT_RESPONSE_WRITE_BUDGET: Duration = Duration::from_secs(30); + +#[derive(Clone, Copy, Debug)] +pub struct ResponseEgressDeadline { /* private */ } + +impl ResponseEgressDeadline { + pub const fn after(duration: Duration) -> Self; + pub const fn at(deadline: Deadline) -> Self; +} + +#[derive(Clone, Copy, Debug)] +pub struct ResponseEgressPolicy { + pub write_deadline: Deadline, +} +``` + +An application inserts one `ResponseEgressDeadline` into the returned response extensions. +`ResponseEgressDeadline::at` preserves an already-computed absolute cutoff in the application's +`MonotonicClock` domain, including a queue-through-egress budget. +`ResponseEgressDeadline::after` selects an egress-start-relative deadline without requiring a +response category in the global policy callback. `begin()` samples `egress_started_at` once from +the envelope's injected clock, removes the extension, and resolves `after(duration)` from that +exact sample before invoking the callback. The duration is clamped to `DEADLINE_FAR_FUTURE`, and +checked-add overflow fails closed to `egress_started_at`. `begin()` exposes the resolved absolute +value through `ResponseEgressHead::application_deadline()` and enforces the earlier of it and the +callback-selected `write_deadline`. The retired ambiguous `new()` constructor and unresolved +`deadline()` accessor do not exist. + +Every normalized-error `DetachedResponseEgressDecision::Send` carries such an absolute deadline +as a mandatory named field. `App` inserts it into the existing `ResponseEgressDeadline` extension +path before constructing detached egress. `begin()` enforces the earlier of the factory deadline +and the default response-egress policy, so neither can extend the other. +`DetachedResponseEgressHead::write_deadline_after` +constructs a deadline in the captured request's injected monotonic-clock domain by anchoring the +duration to `request_start`, clamping it to `DEADLINE_FAR_FUTURE`, and failing closed to +`request_start` if checked addition overflows. The default factory supplies an empty completion and +`DEFAULT_RESPONSE_WRITE_BUDGET` measured from request start. Admission-selected detached responses +retain the response-owned deadline path described above and do not use the normalized-error +factory contract. + +`App` owns a synchronous response-egress policy callback configured through +`Hooks::configure`. The callback receives immutable response-head metadata plus the captured +request start, optional route metadata, and optional application deadline; it cannot inspect, +consume, or mutate the body. +The portable default is +`Deadline::at_instant(egress_started_at + DEFAULT_RESPONSE_WRITE_BUDGET)`. + +The adapter normalizes every callback result to no later than +`egress_started_at + DEADLINE_FAR_FUTURE` using checked addition. An already-expired result is +a valid immediate deadline. Arithmetic overflow while deriving the clamp fails before commit +as `ConversionError`; it does not create an unbounded write. It then minimum-clamps that result +against the optional response-carried deadline. Neither path can extend an application deadline. + +The deadline measures total response-egress time. It is never reset by response conversion, +first-byte wait, source readiness, backpressure, chunk boundaries, flushes, close, finish, or +trailers. Equality is expired. Before each operation and immediately after that operation +becomes ready, expiry wins over simultaneous progress. + +If a native operation reports accepted bytes at or after the deadline, the coordinator first +accounts that accepted prefix and then terminalizes as `DeadlineExceeded`. The prefix is never +retried. Deadline precedence controls the terminal cause, not whether completed native progress +is recorded. + +Every application timestamp and terminal report uses the envelope's injected monotonic clock. +An adapter declaring Native deadline support arms a provider timer from the remaining duration +in that same clock domain. Once that timer wakes, expiry is authoritative even if a frozen test +clock has not advanced; the adapter uses the deadline instant as the minimum terminal timestamp. +That timer must not depend on another source poll or write-ready notification to wake. An +adapter unable to preempt a provider operation checks before and after each operation, +documents the non-preemptible interval, and declares `BestEffort`. + +### 3.2 Terminal report + +The app may install one observer. The adapter invokes it synchronously exactly once per +response-egress attempt: + +```rust +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ResponseEgressOutcome { + ClientDisconnected, + Completed, + ConversionError, + DeadlineExceeded, + HostHandoff, + RequestCancelled, + SourceError, + TransportError, + Unspecified, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ResponseEgressBodyKind { + Application, + Fallback, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ResponseEgressFallbackDisposition { + Aborted, + Completed, +} + +#[derive(Clone, Debug)] +pub struct ResponseEgressReport { + pub bytes_written: u64, + pub body_kind: ResponseEgressBodyKind, + pub elapsed: Duration, + pub fallback_disposition: Option, + pub outcome: ResponseEgressOutcome, + pub request_start: MonotonicInstant, + pub route: Option, +} + +pub struct ResponseEgressCompletion { /* non-clone callback owner */ } + +pub struct DetachedResponseEgressHead<'head> { + /* request method, request start, status, and stable error kind */ +} + +#[non_exhaustive] +pub enum DetachedResponseEgressDecision { + Abort, + Send { + completion: ResponseEgressCompletion, + deadline: Deadline, + }, +} + +impl DetachedResponseEgressHead<'_> { + pub fn write_deadline_after(&self, duration: Duration) -> Deadline; +} + +impl ResponseEgressCompletion { + pub fn empty() -> Self; + + pub fn join(self, other: Self) -> Self; + + pub fn late_bound() -> (ResponseEgressResource, Self) + where + T: Send + 'static; + + pub fn new(complete: Complete) -> Self + where + Complete: FnOnce(&ResponseEgressReport) + Send + 'static; +} + +pub struct ResponseEgressResource { /* private */ } + +impl ResponseEgressResource +where + T: Send + 'static, +{ + pub fn install(&self, resource: T) -> Result<(), ResponseEgressResourceInstallError>; +} + +#[non_exhaustive] +pub enum ResponseEgressResourceInstallError { + AlreadyInstalled, + Closed, +} +``` + +An application that must retain a response-scoped resource until terminal egress constructs one +non-clone `ResponseEgressCompletion` whose `FnOnce` callback owns that resource. The completion is +not attached to a `Response` and never enters `http::Extensions`; it is a separate ownership token +carried by `AdmissionDecision`, `PreparedIngress`, `ResponseEgressEnvelope`, and finally the sole +`ResponseEgressAttempt`. Consequently response cloning, replacement, error rendering, and +framework-generated 404/405 responses cannot duplicate or silently detach the resource. + +Every response-producing admission decision supplies exactly one completion. Applications that +do not need a resource use `ResponseEgressCompletion::empty()`. `Admit` and +`ReadBodyBeforeFallback` transfer it into `PreparedIngress`; `App::dispatch_admitted` removes it +before handing the remaining ingress state to the router and attaches it to whichever response +dispatch produces. That includes handler success, handler error, canonical 404/405, fallback exact +cap, fallback overflow, fallback timeout, and post-admission adapter failure. `Refuse` transfers its +completion directly into detached egress. Normalized pre-admission failures obtain one completion +when the application-configured detached-egress decision factory selects `Send`, or no response +when it selects `Abort`; every `Send` also supplies its mandatory absolute deadline, and the default +uses an empty completion plus `DEFAULT_RESPONSE_WRITE_BUDGET` from request start. Raw parser +failures for which EdgeZero cannot construct +`DetachedResponseEgressHead` remain outside this lifecycle and must not be counted as covered. + +`ResponseEgressCompletion::join` consumes a left and right completion and returns one non-clone +owner. Its terminal callback invokes the left child first and the right child second, passing both +the same borrowed `ResponseEgressReport`; the joined owner executes at most once. If the joined +owner is abandoned before terminal egress, dropping it drops both children and releases both sets +of captured resources without fabricating a report. Each child is invoked through its own panic +boundary on unwind-capable targets, so a panic in the left callback is isolated and does not +suppress the right callback. On panic-abort targets, the process or instance may terminate before +the right callback runs. Composition does not strengthen the adapter's documented completion +boundary or any provider completion guarantee. + +A resource may be acquired after admission. The application calls +`ResponseEgressCompletion::late_bound::()` during admission, puts the returned non-clone +`ResponseEgressResource` installer in `IngressGrant`, and transfers the paired completion into +the admission decision. The handler takes the typed grant and calls `install(&self, resource)`. +Installation consumes the resource. A duplicate returns `AlreadyInstalled`; installation after +terminal completion or pre-`begin()` abandonment returns `Closed`. Both failures drop the rejected +resource before returning and never return ownership to the caller. The typed install error +implements `std::error::Error` so application handlers can propagate it through their normal error +conversion without inspecting text. + +The paired completion owns a private close guard. Terminal completion closes the slot and takes the +installed resource before releasing the internal mutex. Dropping the completion without invoking +its callback performs the same close-and-take operation, even if the installer remains alive. The +slot therefore rejects later installation and releases an installed resource on envelope +abandonment without fabricating a terminal report. The installer is not cloneable; the internal +state is `Send + Sync` for `T: Send` and does not require `T: Sync`. Capturing only a temporary +handler-local guard remains incorrect because it releases the permit before response transmission. +This late-binding pattern does not make the completion cloneable and does not put either owner in +response extensions. + +`ResponseEgressEnvelope::begin()` moves the completion into the sole +`ResponseEgressAttempt` before policy evaluation or framing. The attempt retains it through +application writes, fallback writes, cancellation, adapter errors, and guard drop. The first +terminal transition stores the report in `AttemptState::Terminal`, takes the callback, and invokes +it exactly once. A later signal observes terminal state and cannot complete twice. If an envelope +is dropped before `begin()`, the completion and captured resource are dropped without fabricating +a terminal report. Standard adapters must call `begin()` exactly once for every response envelope. +`ResponseEgressEnvelope` exposes no `into_response()` escape hatch: obtaining a transmittable +response requires `begin()`, which first transfers the completion into the attempt. Dropping an +unbegun envelope is permitted only as cancellation before transmission; it releases the resource +without exposing the response or inventing a report. + +The response-scoped completion runs before the global observer. Each completion child and the +observer are isolated by independent unwind boundaries: a completion panic is logged with a +category-only message and does not prevent a later joined child or the global observer from +receiving the report; an observer panic does not re-enter the completion callback. This isolation +exists only on unwind-capable targets. Panic-abort targets retain the trusted-callback limitation +below and may terminate before a later child or observer runs. No callback can alter the response +or terminal outcome. + +`ResponseEgressBodyKind::{Application, Fallback}` identifies which bounded wire body the +reported byte count describes. Application bytes and fallback bytes are never mixed because +a fallback is permitted only before application headers commit. `fallback_disposition` is +`None` for the application body and records whether a selected fallback reached its finish +boundary or was aborted. + +`bytes_written` is payload bytes accepted across the adapter's documented guest-visible +write boundary. It excludes headers and protocol framing and does not claim wire delivery. +Checked addition is mandatory; overflow terminalizes as `TransportError`. A partial native +write accounts only its accepted prefix and never retries that prefix. + +`elapsed` is terminal monotonic time minus `egress_started_at`. If the injected clock moves +backward, the report records zero, classifies `Unspecified`, logs the clock fault, and still +completes exactly once. + +Neither completion nor observer callbacks can alter the client response. EdgeZero emits only a +category-level message for a caught callback panic. Rust invokes the application-owned process +panic hook before `catch_unwind` returns, so panic-hook output is outside EdgeZero's +diagnostic-redaction guarantee; applications whose panic payloads may contain secrets must install +an appropriate redacting hook during startup. On panic-abort targets, including the pinned Fastly +WASM build, a policy panic occurs before terminal state and terminates the instance without a +report. A completion or observer panic occurs after terminal state is stored and any fallback has +settled, but terminates the instance before later callbacks can be guaranteed. Application +callbacks are trusted not to panic there; only unwind-capable targets can exercise the contained +panic and callback-ordering contract. + +`Completed` means core EOF and any supported trailers reached the adapter's documented +successful finish boundary. `HostHandoff` means the provider accepted the complete +guest-visible stream but exposes no trustworthy transport-finish callback. +`ClientDisconnected` requires a provider signal proved to mean downstream response-consumer +loss. A request or task cancellation with weaker provenance is `RequestCancelled`. The old +converter-only `ResponseReturned` outcome is removed because no retained entrypoint ends its +lifecycle at response-object construction. + +### 3.3 Exactly-once state machine + +The portable guard remains public to adapters, non-clone, and `#[doc(hidden)]`: + +```text +Initial -> Writing -> Terminal(Completed) +Initial -> Terminal(failure) +Writing -> Terminal(failure or HostHandoff) +``` + +The adapter-local coordinator may have more detailed states such as `HeadReady`, `Committed`, +`SourceEof`, and `Finishing`, but only that coordinator may mutate the core attempt. EOF, +finish, source error, transport error, deadline, explicit cancellation, disconnect, converter +failure, connection loss, and guard drop all compete through one terminal transition. Later +terminal events perform resource cleanup only and cannot replace the original cause. + +Dropping an `Initial` guard reports `ConversionError`. Dropping a `Writing` guard reports +`ClientDisconnected` only when the provider supplied a stable disconnect/cancel signal; +otherwise it reports `TransportError`. Empty and `Body::Once` responses enter `Writing` and +produce one lifecycle report just like streamed responses. + +The existing non-clone core attempt remains the only terminal guard and owns the mandatory +response-scoped completion slot; an empty slot is represented by +`ResponseEgressCompletion::empty()`, not `Option`. `begin()` changes so +policy panic/normalization failure returns the still-live attempt, clock, and failure cause to +the adapter instead of notifying before a fallback is written. The attempt records whether +application or fallback bytes are being written. Adapters may add private event/coordinator +types but must not add a shared runtime writer abstraction, make `BodyStream` require `Send`, +or attach provider capability declarations to individual reports. + +### 3.4 Fallible application assembly + +Application configuration is startup policy and must not require `expect` or panic to reject +invalid limits. Construction is core-owned and installs target metadata before configuration. +After successful configuration, core rejects any changed platform facts, provenance, or unknown +reasons rather than silently restoring them. A hook failure propagates unchanged: + +```rust +impl App { + pub fn build(platform: PlatformMetadata) -> Result { + let mut app = Self::with_name_and_platform(A::routes(), A::name(), platform); + A::configure(&mut app)?; + if app.platform() != platform { + return Err(EdgeError::internal(anyhow::anyhow!( + "application configuration changed adapter-selected platform metadata" + ))); + } + Ok(app) + } +} + +pub trait Hooks { + fn configure(_app: &mut App) -> Result<(), EdgeError> { + Ok(()) + } +} +``` + +The `app!` macro emits the hook signatures only. A supplied `configure = ` callback must return +`Result<(), EdgeError>` and its result is returned directly rather than discarded. No adapter +continues with a partially configured `App`: + +- Axum builds before binding its listener and returns an `anyhow::Error` retaining the + `EdgeError` source. +- Cloudflare builds at the start of each host invocation, before core request conversion, + admission, body polling, or dispatch. It logs only the stable `EdgeError::kind()` and returns a + fixed `worker::Error::RustError("application configuration failed")` so private diagnostics are + not wire-visible. +- Fastly builds after fixed runtime/logger setup but before receiving, converting, admitting, or + dispatching the client request. Its top-level error retains the `EdgeError` source under the + fixed `"application configuration failed"` context. +- Spin builds at the start of each host invocation before core request conversion, admission, + body polling, or dispatch. Its `anyhow::Error` retains the `EdgeError` source under the same + fixed context. + +Cloudflare, Fastly, and Spin are per-invocation build boundaries, not process-start boundaries. +No compatibility `build_app_unchecked`, infallible callback coercion, or panic fallback remains. +The demo, generated template, hand-written `Hooks` implementations, guides, and tests migrate +atomically. + +## 4. Write, commit, and abort semantics + +### 4.1 Commit boundary + +Each adapter documents its response-header commit operation. Before commit, conversion failure +or an expired deadline may be replaced by an adapter-private bounded 500 or 504 response. The +same coordinator and attempt retain transport ownership while writing that fallback. They +account fallback bytes, record `ResponseEgressBodyKind::Fallback`, and report the original +`ConversionError` or `DeadlineExceeded` cause only after the fallback reaches finish or abort. +The fallback does not create another observed attempt or invoke application policy recursively. + +Because the application deadline may already be expired, the fixed fallback body uses a +separate internal one-second safety budget beginning when fallback conversion starts. That +budget is not an extension of the application response deadline. If fallback construction, +write, or finish fails, the adapter aborts and retains the original causal outcome; the report's +byte count, body kind, and fallback disposition still disclose the accepted fallback prefix and +its failed finish. + +The fallback head passes through the same request-method and response-status framing +normalization as an application response. A `HEAD` fallback writes no payload, reports zero +bytes with `ResponseEgressBodyKind::Fallback`, and records completion or abort at the +platform's empty-body finish boundary. + +After commit, status and headers are immutable. Deadline, source failure, cancellation, and +write failure drop the source and staged chunk, invoke the strongest platform abort/reset/error +primitive, and report the first terminal cause. No adapter appends an error payload to a +partially written response. A shared body/framing wrapper drops its source and any native reader +state before making a terminal `Err` item visible; cleanup never depends on the adapter polling +that wrapper again or dropping it after observing the error. + +### 4.2 Backpressure and memory + +Platform demand drives source polling. EdgeZero retains at most one source chunk plus explicitly +documented platform staging. A source is not polled again until the current chunk has been +fully accepted, partially accepted with its remainder retained, or released during teardown. + +`Body::Once` remains one already-materialized core allocation. This design adds no response +size limit and removes the converter-only 16 MiB collection limits. Outbound `Buffered` mode +and its `collect_response_stream*` helpers remain because they implement a separate caller- +selected outbound fetch contract; response-egress converters no longer call them. + +### 4.3 Cancellation safety + +Dropping a future is not assumed to cancel a provider operation unless the pinned API contract +or a behavioral probe establishes that fact. An adapter must retain ownership of pending +operation state until cancellation is acknowledged or the containing response/connection is +destroyed. Partial progress reported during cancellation is accounted exactly once. A losing +operation never owns or directly mutates the attempt. + +The earliest causal failure remains authoritative when abort/close also fails. Teardown errors +are logged with bounded category-only diagnostics and do not replace that outcome. + +### 4.4 Response framing and body suppression + +Owning a lower-level transport boundary also makes EdgeZero responsible for consistent final +response framing. Each adapter carries the request method into its private egress context, +validates the final response head before commit, and applies this precedence order: + +1. Parse every `Content-Length` field and comma-separated member as an unsigned decimal `u64` + after trimming optional whitespace. Empty, signed, overflowing, or otherwise invalid members + fail as `ConversionError`; repeated members must all have the same value and normalize to one + field. +2. Final 1xx responses and protocol upgrades are unsupported and fail before commit. A + successful response to `CONNECT` is rejected because EdgeZero does not own a tunnel + lifecycle. +3. Status-specific suppression runs before method-specific suppression. A `204` response drops + the body and always removes `Content-Length`. A `205` response drops the body and rejects a + nonzero declared length; explicit zero is permitted. A `304` response drops the body and may + preserve an explicitly supplied representation length. +4. For every other status, a `HEAD` response drops the body without polling it and may preserve + an explicitly supplied representation `Content-Length`; that value is not compared with the + unsent body. +5. Only responses whose payload will be transmitted enter body-length validation. + +After suppression is resolved, every adapter applies these common field rules: + +- EdgeZero does not forward an application-supplied `Transfer-Encoding`; the selected platform + transport owns transfer framing. +- For a response whose payload will be transmitted, `Content-Length` on `Body::Once` must equal + the exact body length before commit. A mismatch is `ConversionError` and produces the bounded + pre-commit fallback. +- A streamed body with `Content-Length` is checked incrementally. Exceeding the declared length + aborts immediately; early EOF aborts instead of presenting a clean truncated response. +- A streamed body without `Content-Length` uses the provider's native streaming framing. +- Core `Body` has no trailer variant. An application-supplied `Trailer` declaration is therefore + unsupported and fails before commit. Future trailer support requires an explicit declared + trailer shape in the core response contract. + +Repeated end-to-end fields are appended rather than replaced. Every adapter that receives an +application `Connection` field parses its comma-separated tokens and removes every nominated +field in addition to the standard hop-by-hop fields and the de facto `Proxy-Connection` field +before provider conversion. A provider +that rejects or hides `Connection` earlier documents that equivalent boundary. Invalid +connection tokens fail before commit as `ConversionError`. These rules are shared semantics +implemented separately with each provider's structured header API; adapters do not serialize +raw header blocks. + +## 5. Adapter designs + +### 5.1 Axum: owned Hyper HTTP/1 boundary + +The standard Axum adapter stops returning an erased `axum::body::Body`. It runs an EdgeZero- +owned Hyper HTTP/1 accept/connection loop on a Tokio `LocalSet`, allowing the portable +non-`Send` core stream to remain on one local executor. A private Hyper service converts +`Request` into the existing core request pipeline and returns a private response +body implementing `http_body::Body` directly. + +The adapter owns two coordinated scopes: + +1. A per-response coordinator owns the core stream, current chunk, deadline, byte accounting, + EOF/finish state, and core attempt. +2. A per-connection supervisor owns the Hyper connection future, socket wrapper, and an ordered + registry of response coordinator handles. Each entry records identity, deadline, commit + state, and EOF/flush marker. The supervisor can terminate the connection while Hyper is + blocked below `poll_frame` flushing bytes to the socket. + +The body accounts a payload frame when Hyper accepts it from `poll_frame`. It reports source +EOF to the connection supervisor. Completion is eligible only after a subsequent proved Hyper +framing/flush boundary has accepted all response bytes into the OS socket. It never means peer +receipt. A raw-socket test must prove that the selected boundary occurs once per response; +until then the adapter reports `HostHandoff` and completion remains `BestEffort`. + +Hyper stops polling when its declared-length encoder reaches zero, even without an exact size +hint. For a transmitted stream with `Content-Length`, Axum stages only the final declared-length +chunk and validates source EOF before handing that frame to Hyper. Empty tail chunks yield +cooperatively; excess bytes or a late source error abort without sending the staged frame. The +source is marked exhausted on clean EOF so subsequent body drop settles `HostHandoff` unless a +connection failure has already won. Lookahead remains under the original absolute write deadline +and injected application clock, with the connection supervisor's host timer covering pending +reads. It does not collect the preceding body. + +A transmitted zero-length stream is validated asynchronously before Hyper receives the response, +since Hyper may never poll such a body. The original attempt retains its completion during this +bounded validation. Source failure selects the finite conversion fallback; deadline expiry +selects the timeout fallback. HEAD/status suppression still drops the source without polling. + +The detached finite fallback used after fallback registration fails determines EOF from its +payload source, not from absence of an active attempt. GET retains the selected error bytes; HEAD +sends no payload. Consuming the final frame exhausts the source, and terminal failure explicitly +drops both the source and any staged frame before returning an error. + +HTTP/1 post-commit timeout or source failure closes the connection to preserve framing. This +may cancel pipelined responses on that connection. The supervisor never mutates a core attempt; +it submits an ordered connection event to each registered coordinator. The coordinator whose +deadline caused closure retains `DeadlineExceeded`; other committed or queued coordinators +that have no earlier terminal cause settle as `TransportError`. Already-terminal coordinators +ignore the event. Hyper does not currently enable HTTP/2 in this adapter. Future HTTP/2 support +requires per-stream reset and completion ownership; a connection-wide abort is not acceptable +for multiplexed streams. + +The public transport-blind `EdgeZeroAxumService` embedding path is removed. Public serving APIs +must accept or create a listener and retain EdgeZero connection ownership. There is no +buffered compatibility service. + +### 5.2 Cloudflare: owned Workers stream writer + +Cloudflare retains the standard `#[event(fetch)]` entrypoint but replaces the generic +`Response::from_stream` lifecycle wrapper with an adapter-owned Workers stream bridge. +The request's `AbortSignal` is captured before the request body is moved, and generated plus +demo Wrangler manifests enable `enable_request_signal`. + +For a transmitted response with declared `Content-Length`, including `Body::Stream`, the +adapter owns both sides of a native fixed-length stream so Cloudflare preserves and enforces +that length. A known `Body::Once` without an explicit length may use its exact length. An +unknown-length stream uses a native transform stream. The adapter retains the writable side +and returns the readable side in the response. A task registered with the request `Context` +owns the body source, writer, request signal, clock, deadline timer, and attempt. + +The writer waits for platform demand, writes one chunk, and accounts the chunk only after the +write promise resolves. It does not poll another source item while a write is pending. The +same task always polls an independent deadline timer, so no downstream pull is required for +expiry. On clean EOF it closes the writer before reporting `HostHandoff`; Workers exposes no +socket-flush or response-pump completion callback. + +Request abort and readable-side cancellation are complementary signals. Until deployed +evidence proves that `Request.signal` means downstream response-consumer loss for a specific +topology, it is `RequestCancelled`, not `ClientDisconnected`. A generic write rejection without +that signal is `TransportError`. Deadline or cancellation aborts the writable stream, drops +pending source state, and terminalizes exactly once. A losing write promise must not remain +able to account bytes or report completion after abort. + +High-water-mark behavior, fixed-length semantics, request-signal behavior, and runtime +cancellation require deployed Workers evidence before promotion to `Native`. + +### 5.3 Fastly: manual send-owning entrypoint + +Fastly removes `#[fastly::main]` from generated and demo applications. EdgeZero initializes +the SDK, acquires the client request, dispatches the app, converts the response head, commits +it, pumps the body, and finishes or abandons the native response before returning: + +```rust +fn main() -> Result<(), fastly::Error> { + edgezero_adapter_fastly::run_app::() +} +``` + +All public dispatch variants become send-owning terminal operations. No public +response-returning converter remains. The adapter constructs a low-level `ResponseHandle` and +commits with `ResponseHandle::stream_to_client(BodyHandle::new())`, obtaining an unbuffered +`StreamingBodyHandle`. It does not use the high-level `StreamingBody`, whose `BufWriter` may +flush buffered data while attempting to finish or abandon. + +Each chunk is fully written before the next source poll. Native short writes account their +accepted prefix and retain only the unwritten suffix. Zero-length progress is a +`TransportError`. Clean EOF invokes `finish`; `finish` returning success reports `Completed` +at the precisely documented boundary "Fastly host accepted body-handle close." It does not +claim that background transmission or peer receipt completed. Source/deadline failure invokes +`abandon` or drops the handle so its native drop guard abandons it. Generic native write errors +are `TransportError` because the SDK exposes no stable client-disconnect reason. + +Source polling and native writes are synchronous and cannot be preempted by an independent +timer. The adapter checks the absolute deadline before and after each source poll, write, and +finish operation. Response-write deadlines therefore have a `BestEffort` ceiling with the +pinned Fastly API. `StreamingBodyHandle::finish` consumes Fastly's only body handle before it +reports success or failure. A finish failure is therefore terminal and cannot be followed by a +second explicit `abandon`; EdgeZero preserves the finish failure as the first cause, reports it +exactly once, and relies on the consumed handle's host-side close semantics. This limitation is +part of Fastly's `BestEffort` abort and completion classifications. + +### 5.4 Spin: raw WASI response and owned writer task + +Spin retains the official `#[http_service]` entrypoint. Its macro accepts a raw WASIp3 +response, so replacing the export mechanism would duplicate version-sensitive SDK machinery +without improving lifecycle ownership. + +`run_app`, `AppExt`, and request dispatch return the concrete raw response type re-exported by +`spin_sdk::wasip3`. The adapter bypasses `http_into_wasi_response`, constructs `BodyWriter` +and `Response::new` itself, and spawns one SDK-owned task. The task owns the core body, +`StreamWriter`, result writer, response-result reader, clock, deadline, and attempt for their +entire lifetime. + +The task races source polling and each individual `StreamWriter::write` against the absolute +deadline. It does not use `write_all` or `send_http_body`, because the lower-level write result +is required to account a transferred prefix during cancellation. Clean EOF closes the stream, +writes the successful body result, and then observes the response-result future. Failure or +deadline drops source state and publishes one bounded WASI error result; it never appends a +post-commit error body. + +On deadline, the task retains the pending write future and writer until cancellation is +acknowledged or destroying the response stream makes further progress impossible. Any prefix +reported during cancellation is accounted before cleanup. The pending operation emits events +to the coordinator and cannot access the attempt directly. + +The current Spin host does not prove that the response-result future corresponds to network +completion. Until host source or deployed evidence proves that boundary, successful guest +termination reports `HostHandoff`, and completion remains `BestEffort`. + +All raw types and task spawning come through the pinned `spin_sdk::wasip3` re-export. A +separate direct `wasip3` dependency is removed if no other live code requires it. + +## 6. Hard-cut migration and deletion + +The implementation is atomic. It removes rather than deprecates: + +- `AXUM_RESPONSE_STREAM_BUFFER_BYTES`, `FASTLY_RESPONSE_STREAM_BUFFER_BYTES`, and + `SPIN_RESPONSE_STREAM_BUFFER_BYTES`; +- Axum's collecting response converter and transport-blind public service path; +- Fastly's response-returning `run_app`, extension/config variants, service dispatch, + registry dispatch, public `from_core_response`, and `#[fastly::main]` scaffolding; +- Spin's `SpinFullResponse`, `FullBody` converter, public `from_core_response`, and matching + `AppExt`/dispatch signatures; +- Cloudflare's generic response-egress stream wrapper and all premature + pre-enqueue accounting branches; +- the converter-only `ResponseEgressOutcome::ResponseReturned` variant and its zero-byte special + case; +- public `ResponseEgressEnvelope::into_response()` and every test/helper that extracts a response + without beginning and terminally settling its attempt; +- any response-extension-based completion carrier or cloneable shared callback slot; completion is + a direct, non-clone admission/envelope/attempt ownership chain; +- infallible `Hooks::configure` and overridable application-assembly signatures, callback-result discards, and + application-side `expect` calls used only to bridge fallible startup policy; +- obsolete tests, fixtures, imports, dependencies, capability footnotes, and adapter-guide + text that describe buffered egress; and +- the superseded Fastly-only response-egress plan. + +The generic `collect_response_stream*` functions remain only where live outbound buffered +fetch APIs use them. Historical design and completed plan documents remain records; current +specifications and guides identify this hard cut as the active contract. + +The demo adapters, adapter-owned Handlebars templates, generator assertions/snapshots, +generated-project compile fixtures, API documentation, capability matrices, and legacy API +contract script change in the same patch series. A repository check rejects production, +template, example, and current-guide references to removed symbols or buffering constants. + +## 7. Capability policy + +`Native` means implementation plus target-appropriate evidence demonstrates the exact stated +boundary without a documented behavioral deviation. Deterministic unit tests prove EdgeZero +mechanics; they do not by themselves prove provider behavior. `BestEffort` means the lifecycle +is implemented but a provider boundary is weaker or not yet proved. An app requiring a Native +capability rejects both `BestEffort` and `Unsupported` before serving traffic. + +The implemented declarations are conservative: + +| Capability | Axum | Cloudflare | Fastly | Spin | +| ------------------------------------ | ---------- | ---------- | ---------- | ---------- | +| `response-egress-abort` | BestEffort | BestEffort | BestEffort | BestEffort | +| `response-egress-backpressure` | BestEffort | BestEffort | BestEffort | BestEffort | +| `response-egress-completion` | BestEffort | BestEffort | BestEffort | BestEffort | +| `response-write-deadlines` | BestEffort | BestEffort | BestEffort | BestEffort | +| `lazy-streamed-response-passthrough` | Native | Native | BestEffort | BestEffort | + +Axum raw-socket tests prove deadline wakeup and deterministic collateral pipeline attribution, +but Hyper frame acceptance is not per-response socket or client completion. Cloudflare writer +acceptance and close lack deployed disconnect/completion proof. Fastly cannot preempt synchronous +source polls or hostcalls. Spin cancellation does not prove finite host teardown. These are the +reasons the four egress rows remain `BestEffort`; implementation intent alone would not qualify. + +## 8. Test and evidence matrix + +Every adapter gets deterministic lifecycle tests before provider integration: + +| Surface | Required proof | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Timing | One resolved absolute deadline covers conversion, first byte, source waits, writes, backpressure, and finish. Equality expiry wins; no chunk resets the budget. Frozen/backward clocks still settle exactly once. A response-owned `at` cutoff remains absolute; `after` is resolved exactly once from the injected `egress_started_at` sample, clamps to `DEADLINE_FAR_FUTURE`, and fails closed to that sample on overflow. Every normalized-error `Send` supplies a mandatory absolute deadline in the request's injected clock domain; its relative construction anchors to `request_start`, clamps to `DEADLINE_FAR_FUTURE`, and fails closed to request start on overflow. The default uses `DEFAULT_RESPONSE_WRITE_BUDGET` from request start. The effective bound is the earlier of the application deadline and default egress policy, so neither can extend the other. Admission-selected detached responses retain their response-owned deadline path outside the factory contract. | +| Completion | Empty, buffered, streamed EOF, source failure, transport failure, timeout, disconnect, conversion failure, never-polled body, and guard drop each notify once. Competing terminal events still produce one report. The exact decision-owned completion survives normal dispatch, handler/routing errors, canonical 404/405, bounded fallback outcomes, refusal, policy/framing failure, adapter fallback, and guard drop; it runs before the global observer. Joined completions receive the same borrowed report at most once in left-to-right order and release both resources if abandoned. Child panics remain independently isolated only on unwind-capable targets; panic-abort may terminate before the right callback. A dropped pre-begin envelope releases its resource without fabricating a report. Normalized pre-admission validation invokes the detached-egress decision factory exactly once: `Send { completion, deadline }` supplies exactly one completion and one mandatory deadline, with an empty completion by default, while `Abort` constructs no response or attempt and invokes no completion, observer, handler, middleware, or body poll. Raw parser failures remain outside the lifecycle contract. Explicit ingress abort likewise starts no attempt and invokes no callback. Compile-time tests prove the completion is non-clone and cannot be stored in response extensions. | +| Accounting | Exact payload totals, zero-byte response, checked overflow, short/partial writes, cancellation prefixes, and no headers/framing in the count. | +| Commit | Pre-commit failure may synthesize a bounded fallback; post-commit failure aborts without rewriting status or appending a body. | +| Backpressure | Holding one platform write prevents the next source poll and bounds EdgeZero staging to one chunk. Axum's final declared-length frame has the explicit bounded EOF-lookahead exception above. | +| Teardown | Source drop and native abort/reset/error/close occur on every supported failure path. A terminal stream error releases its source/native state before yielding `Err`, without a repoll or wrapper drop. A losing future cannot mutate terminal state. | +| Headers | Repeated fields, including `Set-Cookie`, survive the new low-level conversion paths. | +| Framing | `HEAD`, body-forbidden statuses, content-length exact/short/long bodies, removed transfer encoding, unsupported upgrades, and early EOF behave identically across adapters. | +| Migration | Removed APIs fail to compile; generated and demo applications compile only against the new entrypoints, response types, and fallible configure signature. Every adapter proves a failing configure result prevents request conversion, body polling, admission, and dispatch. Axum additionally proves no listener bind and a retained `EdgeError` source. Fastly proves no client-request receive and a retained source under the fixed context. Spin proves the retained source and fixed context. Cloudflare proves stable-kind-only logging and the fixed public `worker::Error`. | + +Target evidence adds: + +Current local Axum evidence uses Rust 1.95.0 and the locked Hyper dependency. +`connection::tests::bodyless_tcp_responses_release_the_response_resource_exactly_once` +covers HEAD/204/304 suppression without source polling and one completion/resource release. +`dev_server::bounded_ingress_tests::stalled_egress_termination_releases_capacity_and_recovers` +covers disconnect, a pending producer, and a non-reading client with an always-ready source; +all settle once, stop polling, release resources and recover a one-connection server. +It includes a frozen injected clock and an independently waking native deadline timer. +Existing connection/shutdown tests cover EOF, malformed declared lengths, source/conversion +errors and cancellation. These are native TCP/guest ownership proofs, not confirmed client +delivery or hosted-provider evidence; no capability promotion follows from this test expansion. + +| Target | Tested SDK / Native Revision | Hosted Runtime Revision / Evidence | Egress Qualification | +| --- | --- | --- | --- | +| Axum | Hyper 1.12.0, Rust 1.95.0, local TCP tests | Operator deployment not qualified by local tests | BestEffort; HostHandoff is not peer delivery | +| Cloudflare | worker 0.8.5; guest/WASM contract tests | Unknown; no deployed lifecycle artifact supplied | BestEffort; hosted disconnect/close boundary remains unproved | +| Fastly | fastly 0.13.1; scripted guest sink tests | Unknown; Viceroy is not hosted evidence | BestEffort; synchronous operations cannot be guest-preempted | +| Spin | spin-sdk 7.0.0; scripted guest/WASI tests | Unknown; local Spin is not hosted evidence | BestEffort; finite host teardown remains unproved | + +No normalized guest test qualifies raw-ingress parser guarantees. A future hosted +artifact must identify its source SHA, SDK/runtime revision (or typed unknown), +compatibility settings, scenario, terminal count, resource recovery and ownership boundary. + +### 8.1 Opt-in hosted ownership qualification + +The reusable-app fixture workspace has a default-off `qualification` feature. Its synthetic +application uses the standard entrypoints on all four adapters, including the manual Fastly +send-owning lifecycle. It emits four bounded, correlated records per valid probe: source drop, +terminal report, response-owned resource drop and final counters. Build identity is compiled in; +request headers can select only bounded run/probe tokens and a fixed scenario, never a destination. +No normal demo or generated application installs these probe routes. + +`scripts/qualify_response_egress.mjs --execute-hosted` requires an explicitly allowlisted isolated +nonproduction HTTPS origin, a finite per-probe time budget, source SHA, SDK and runtime identity +(a documented unknown is allowed), compatibility settings and independent deployment/binary +identity. It runs normal, HEAD/204/205/304, slow/non-reading client, disconnect, cancellation, +pending production, expired deadline, source failure and conversion failure probes. Each is +followed by a normal recovery request. Redirects and retries are disabled. Fastly pending probes +additionally require an independently enforced finite execution ceiling and wait for that ceiling +before recovery. A guest deadline cannot preempt an indefinitely pending synchronous source poll. + +The runner caps aggregate observed payload at 64 MiB, plus at most one delivered Node stream chunk +on a cap crossing; input files are read in 16 KiB chunks and rejected before retaining more than +4 MiB. Each evidence-input read can additionally hold one 16 KiB chunk and a final concatenation +copy. These are client-driver budgets, not provider allocation guarantees. + +`--validate` requires complete unsampled telemetry and exactly one terminal/source/resource/final +record for every scheduled probe, with matching build identities and ordered sequence numbers. +It rejects duplicates, missing records, unexpected scenarios, post-terminal source polls, +misclassified failures and unsuccessful normal/bodyless/recovery observations. Its successful +result is only `qualified-ownership` for those scheduled probes, never general delivery or +resource certification. Transport failures qualify only when independently observed; they are +not synthesized by a response object. A run terminated before telemetry export remains unverified. + +The compiled `artifact` identity is a build-input digest (for example the lockfile SHA-256), not +the final binary's self-referential hash. `binary_sha256` and deployment identity are recorded +independently after build. The fixture does not identify provider instances (`unobserved`), so +successful subsequent requests do not prove same-isolate capacity recovery. Hosted teardown, +actual transport interruption, provider allocation and raw parser behavior still require their +own evidence. See the fixture README for build and invocation commands. No hosted run is claimed +by adding or compiling these tools. + +- Axum `tokio::io::duplex` and raw TCP tests for no reader, slow reader, disconnect before + first byte, disconnect mid-body, blocked-socket deadline, normal EOF, keep-alive, + pipelining collateral cancellation, and per-response flush association. +- Cloudflare WASM tests for fixed/unknown-length streams, demand, writer rejection, + readable cancellation, `Request.signal`, no-demand expiry, source release, and one terminal + settlement. Deployed probes cover direct and service-binding topologies. +- Fastly scripted sink tests for short/zero writes, finish/abandon errors, source failure, and + synchronous deadline crossings. Viceroy exercises the undecorated manual entrypoint. +- Spin scripted WASI writer tests for partial progress, cancelled writes, result-writer errors, + response-result timing, source release, and exactly one exported HTTP handler. + +Deployed probes record provider/runtime versions and the source revision. They separately +observe source polls, first-byte timing, slow-reader pressure, disconnect, pre-commit timeout, +post-commit timeout, normal finish, byte accounting, and terminal reports. CI may keep +credentialed probes protected, but capability documentation links the latest evidence used +for any `Native` promotion. + +## 9. Security and observability + +Observer reports contain no body bytes, header values, target addresses, or unbounded error +strings. Route metadata uses the registered pattern, never dynamic path values. Platform logs +may include a bounded static outcome label and byte count; provider/source details stay in +redacted diagnostic logs. + +Fallback responses use fixed public messages and do not serialize provider errors. No adapter +claims that an egress deadline bounds provider buffers, kernel buffers, peer receipt, or total +isolate memory. Those exclusions remain explicit in the public capability guide. diff --git a/docs/superpowers/specs/2026-09-15-reusable-app-lifecycle-design.md b/docs/superpowers/specs/2026-09-15-reusable-app-lifecycle-design.md index 3eb2a2328..6cfae2d0a 100644 --- a/docs/superpowers/specs/2026-09-15-reusable-app-lifecycle-design.md +++ b/docs/superpowers/specs/2026-09-15-reusable-app-lifecycle-design.md @@ -1,6 +1,6 @@ # Opt-in reusable application lifecycle -- **Status:** Implemented; updated for PR #379 review. +- **Status:** Historical lifecycle design; reconciled with PR #275's outbound hard-cut. - **Date:** 2026-09-15 - **Source baseline:** EdgeZero `593fc9282a1c56e12bae15f91eef2162f4b6a1b7`. - **Scope:** Core application ownership and the Fastly, Cloudflare, Spin, and Axum adapter surfaces, including custom dispatch, examples, documentation, and validation. @@ -11,6 +11,13 @@ The original private-helper design in §§8/9.1 is superseded by The implemented `lifecycle::{Sandbox, serve_custom, run_custom}` surface, including its observation counters, is public. No provider-independent scheduler is added. +PR #275 supersedes the original assembly, raw-conversion, response-returning, and +buffered-delivery choices below. The +[outbound HTTP design](2026-05-21-outbound-http-design.md) is authoritative for +admission, outbound execution, response egress, and capability semantics. The API +sections here describe the current production interfaces; historical verification +records do not establish that the reconciled implementation has been reverified. + ### Included compatibility and CLI changes The following focused changes accompany retention and apply to existing users: @@ -39,19 +46,20 @@ Correctness must tolerate fresh initialization on any request. Retention is an o ## 2. Verified baseline -Paths and line numbers below refer to the source baseline, not future implementation locations. +The source baseline above records the historical investigation. These API descriptions +are reconciled with the current production implementations rather than that baseline. | Surface | Current behavior | Reference | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | -| Core construction | `Hooks::build_app` constructs routes and calls `configure`. `App` contains a name and router, not store metadata or logging policy. | `crates/edgezero-core/src/app.rs:14`, `:109` | +| Core construction | `App::build::(platform)` installs platform metadata, constructs routes, and invokes fallible `Hooks::configure`; only success yields an app. | `crates/edgezero-core/src/app.rs` | | Core dispatch | `RouterService` owns `Arc` and supports repeated `oneshot(&self, request)`. | `crates/edgezero-core/src/router.rs:298`, `:349` | | State | Registered state is cloned into each request; shared interiors remain shared. App state overwrites extensions of the same type. | `crates/edgezero-core/src/router.rs:212`, `:251` | | Macro | `app!` generates `Hooks` and router construction. Its state expression executes when the router is built. It does not generate the provider lifecycle. | `crates/edgezero-macros/src/app.rs:214`, `:229` | -| Fastly | Each `run_app_with_request_extensions` call reads runtime configuration, conditionally installs logging, builds the app, and dispatches. | `crates/edgezero-adapter-fastly/src/lib.rs:171` | +| Fastly | `run_app_with_hooks` builds per invocation; `serve_app_with_hooks` retains only successful construction through `Sandbox`. | `crates/edgezero-adapter-fastly/src/lib.rs` | | Fastly logger | Installing the process-global logger a second time returns an error. | `crates/edgezero-adapter-fastly/src/logger.rs:52` | -| Fastly prebuilt dispatch | Public `request::dispatch_with_registries` accepts `&App`, metadata, runtime configuration, and a raw-request extensions callback. | `crates/edgezero-adapter-fastly/src/request.rs:337` | -| Cloudflare | `run_app` rebuilds the app on every fetch. Registry-aware dispatch already takes `&App` but is private. | `crates/edgezero-adapter-cloudflare/src/lib.rs:98`, `src/request.rs:298` | -| Spin | `run_app` rebuilds the app on every handler invocation. Registry-aware dispatch already takes `&App` but is private. | `crates/edgezero-adapter-spin/src/lib.rs:111`, `src/request.rs:183` | +| Fastly prebuilt dispatch | `request::send_request_with_registries_and_hooks` performs canonical admission and owned egress; no native response is returned for another sender. | `crates/edgezero-adapter-fastly/src/request.rs` | +| Cloudflare | `run_app` builds per fetch and delegates to public `dispatch_app::(&app, req, env, ctx)`. | `crates/edgezero-adapter-cloudflare/src/lib.rs` | +| Spin | `run_app` builds per invocation and delegates to public `dispatch_app::(&app, req)`, returning raw `SpinResponse`. | `crates/edgezero-adapter-spin/src/lib.rs` | | Cloudflare/Spin logging | Both adapter logger initializers are currently no-ops. Their existing entry points honor `owns_logging`. | Cloudflare `src/lib.rs:30`; Spin `src/lib.rs:65` | | Axum | `run_app` builds the app once before starting the server, then shares the router through cloned services. | `crates/edgezero-adapter-axum/src/dev_server.rs:352`, `:302`; `src/service.rs:132` | @@ -59,7 +67,7 @@ Paths and line numbers below refer to the source baseline, not future implementa - **Fastly:** reuse is opt-in through an ordinary `main` and SDK `Serve`. A sandbox processes requests sequentially. Lifecycle limits are checked between requests; the platform may end a sandbox earlier. - **Cloudflare:** an isolate can handle overlapping requests on its event loop. No application loop requests the next fetch. Retention must tolerate eviction and must not associate the first request's bindings or context with subsequent requests. -- **Spin:** the pinned SDK 6 HTTP macro exports WASI P3. The cached `spin-macro-6.0.0/src/lib.rs:131` explicitly exports `wasip3::http::service`. The Rust `wasm32-wasip2` build target does not establish a P2 HTTP lifecycle. Current Spin documentation describes P3 instance reuse, including concurrent invocation; P2 components have different behavior. +- **Spin:** the current pinned SDK 7 HTTP macro exports WASI P3. The Rust `wasm32-wasip2` build target does not establish a P2 HTTP lifecycle. Verify the emitted interface and host together; retained state must support overlapping invocations when the host permits them. - **Axum:** the existing long-running service already retains its router and permits concurrent requests. Provider references: @@ -79,7 +87,7 @@ These distinctions prohibit a universal sequential serving loop in core. - Provide the Fastly opt-in lifecycle and retained standard-app convenience. - Document concrete application-owned retention for Cloudflare and Spin. - Preserve fresh request conversion, contexts, extensions, and registry resolution. -- Preserve existing response translation and custom-dispatch escape hatches. +- Preserve canonical admission and adapter-owned lazy response egress for both default and retained dispatch. - Document lifecycle, concurrency, failure, refresh, and resource boundaries. - Provide framework contract tests and local lifecycle fixtures. @@ -97,7 +105,7 @@ These distinctions prohibit a universal sequential serving loop in core. - No new core `Hooks` methods, macro arguments, manifest keys, CLI reuse switch, or provider-independent scheduler. - No framework singleton registry keyed by application type. - No SDK upgrade solely for this feature; Fastly 0.12.1 already provides `Serve`. -- Standard dispatch retains body buffering and stream caps. The header-preservation and CLI diagnostic corrections listed above are explicit scope exceptions; custom callbacks own progressive response streaming. +- Retention introduces no response buffer or stream-collection cap. Standard and custom admitted dispatch use the authoritative outbound design's lazy response-egress contract. The header-preservation and CLI diagnostic corrections listed above remain historical scope exceptions. - No durable cache, automatic configuration refresh, parsed-key cache, or application-specific finalization hook. - No deployment, publishing, or external issue/PR activity as part of implementing this spec. @@ -105,7 +113,12 @@ These distinctions prohibit a universal sequential serving loop in core. ### 4.1 Preserve core ownership -Use the existing `edgezero_core::app::{App, Hooks, StoresMetadata}` and `edgezero_core::http::Extensions`. Do not add metadata or lifecycle state to `App`. +Use `edgezero_core::app::{App, Hooks, StoresMetadata}` and core `Extensions`. +`App::build::(PLATFORM) -> Result` is the canonical assembly +path. It installs target metadata before fallible `Hooks::configure`, propagates +its typed failure, and rejects a successful configuration that changes platform +metadata. Retain only a completed successful app; initialization errors are never +cached. Lifecycle counters and store metadata do not belong in `App`. `App` is structurally `Send + Sync`: handlers and middleware require these bounds, registered state requires them, and the router is reference-counted. Dispatch futures may remain non-`Send`. Add a compile-time assertion protecting the retained object's bounds without imposing `Send` on WASM futures. @@ -119,24 +132,27 @@ pub use fastly::http::serve::{Serve, ServeSummary}; pub fn serve_app(serve: Serve) -> ServeSummary; -pub fn serve_app_with_request_extensions( +pub fn serve_app_with_hooks( serve: Serve, - extend: F, + prepare: Prepare, + finalize: Finalize, ) -> ServeSummary where A: Hooks, - F: FnMut(&fastly::Request, &mut Extensions); + Prepare: FnMut(&mut fastly::Request, &mut Extensions), + Finalize: FnMut(&mut edgezero_core::Response) -> State; ``` -`serve_app` delegates with an empty extensions callback. Both helpers: +`serve_app` delegates with empty preparation and finalization callbacks. Both helpers: 1. Capture static `A::stores()` metadata. 2. Enter the SDK callback before performing host-dependent initialization. -3. Resolve the logging policy baked into `A`. -4. On the first callback only, honor `A::owns_logging()`, initialize adapter logging if enabled, and call `A::build_app()`. +3. Resolve immutable logging settings with `A::logging_for("fastly")`. +4. Complete logging setup through `Sandbox::setup_once`, honoring `A::owns_logging()`, then initialize with `App::build::(FASTLY_PLATFORM)` through `Sandbox::initialize`. 5. Retain the successfully constructed app for subsequent callbacks. -6. On every request, use `request::dispatch_with_registries` with fresh registries opened through the deployed logical resource-link aliases. -7. Return the SDK summary unchanged. Do not hide handler errors or synthesize a guaranteed request count. +6. Open fresh store handles through the deployed logical resource aliases on every dispatch; do not cache them with the application. +7. On every request, use `request::send_request_with_registries_and_hooks` with fresh registries, admission, and response-egress resources. Preparation borrows the native request before admission; finalization borrows routed responses before egress policy and framing. Detached responses skip finalization. +8. Return the SDK summary unchanged. Do not hide handler errors or synthesize a guaranteed request count. The logger's enablement, level, endpoint, and stdout policy come from the application manifest and are immutable for the binary. `owns_logging` retains @@ -144,9 +160,15 @@ its existing meaning: skip adapter logger installation. An application adopting retained construction must arrange its own logger installation before construction if construction needs logging. -Capture the mutable extensions callback in the outer serving closure and pass a fresh `&mut extend` reborrow to each `dispatch_with_registries` invocation. Its `FnOnce` parameter can call that borrowed `FnMut`; do not move the callback out of the serving closure on the first request. +Capture preparation and finalization callbacks in the serving closure and pass +fresh mutable reborrows on every request. Do not move either callback out on the +first invocation or retain request-owned values in its captures. -An initialization `Err` ends the SDK loop. `Hooks::build_app` is infallible by signature: a panic remains a sandbox failure, not a fabricated initialization `Result`. Do not retry after partially completed global initialization. +An initialization `Err` leaves `Sandbox` empty. Standard serving propagates that +error and terminates; custom callbacks may handle a recoverable failure and retry +on a later request. Configuration is fallible, and panics remain sandbox failures. +Partial global setup side effects are not rolled back, so custom retry policy must +make setup safe to repeat. Example entry point after implementation: @@ -162,15 +184,27 @@ The example deliberately uses ordinary `main`, not `#[fastly::main]`. Existing g ### 4.3 Fastly custom lifecycle -Use `lifecycle::serve_custom(Serve, callback)` for EdgeZero-owned custom lifecycle mechanics, or direct SDK serving as an escape hatch. Preserve access to the SDK's `HandlerResult` trait through its existing SDK path; a new framework callback trait is unnecessary. +Use `lifecycle::serve_custom(Serve, callback)` with +`FnMut(fastly::Request, &mut Sandbox) -> Result<(), E>`. The callback owns +terminal delivery. The wrapper records that result through the SDK without asking +the SDK to send a response or synthesize an error response. -A callback receives an owned native request and may mutate it, capture metadata, convert it with `request::into_core_request_with_registries`, dispatch through a retained router, inspect core response extensions, finalize the response, send or stream it explicitly, and finish request-owned post-send work before returning. +A callback receives an owned native request and may use +`request::send_request_with_registries_and_hooks` with a retained app, current +runtime configuration, and request-local hooks. Preparation can mutate the native +request and populate extensions; finalization can inspect or mutate routed core +responses before policy/framing. The adapter retains transmission ownership and +streams lazily through terminal delivery; no public raw-conversion or +response-returning dispatch helper is part of this path. Applications may capture retained state in a closure or pass it through `run_with_context`. Host-dependent or fallible initialization belongs inside the callback. A lightweight health response may bypass expensive initialization; retaining state must not require eager construction before every callback can run. -`request::dispatch_with_registries` is the complete prebuilt-app path when standard response translation is appropriate. `into_core_request_with_registries` exposes the request half for custom streaming; bare `into_core_request` injects no stores. Drive router dispatch with `futures::executor::block_on` in a synchronous callback. The immutable extensions callback is not a substitute for mutable raw-request custom dispatch. +Resolve baked logging once, initialize it before store-open boot diagnostics, +and pass fresh logical store registries through the canonical +send-owning path. Do not manually route a converted request and collect its body; +that would bypass admission or duplicate response-egress ownership. -The custom lifecycle extension adds `lifecycle::Sandbox`: an application-owned payload in a framework-owned successful-only slot, attempted-callback and initialization counters, and an independent successful setup guard. `initialize` returns errors unchanged and leaves the slot empty so a later call can retry; applications decide whether to respond and continue. `run_custom` creates fresh state for a single received request without entering `Serve`. Both wrappers complete `HandlerResult` exactly once. State decision logic is feature-independent for host tests; SDK wrappers require `fastly`. No teardown hook or response-finalization pipeline is introduced. Default generated entry points remain unchanged. +The custom lifecycle uses `lifecycle::Sandbox`: an application-owned payload in a framework-owned successful-only slot, attempted-callback and initialization counters, and an independent successful setup guard. `initialize` returns errors unchanged and leaves the slot empty so a later call can retry; applications decide whether to respond and continue. `run_custom` creates fresh state for a single received request without entering `Serve`. Both wrappers record terminal delivery exactly once without SDK resends. State decision logic is feature-independent for host tests; SDK wrappers require `fastly`. Default generated entry points remain unchanged. ### 4.4 Cloudflare and Spin prebuilt dispatch @@ -189,12 +223,12 @@ pub async fn dispatch_app( pub async fn dispatch_app( app: &App, req: spin_sdk::http::Request, -) -> anyhow::Result; +) -> anyhow::Result; ``` Use the same feature and target gates as each adapter's existing `run_app`. -These functions do not build an app, install logging, or cache anything. They resolve runtime configuration for that invocation, build registries using `A::stores()`, and call the existing internal registry-aware dispatcher. Callers must supply an app constructed from `A`; `App` erases its type, so the function cannot verify provenance. +These functions do not build an app, install logging, or cache anything. They resolve runtime configuration for that invocation, build registries using `A::stores()`, and run fresh admission and response egress through the internal registry-aware dispatcher. Callers must supply an app constructed from `A` with `CLOUDFLARE_PLATFORM` or the selected Spin platform; `App` erases its type, so the function cannot verify provenance. Spin returns the raw WASI response; its coordinator retains the body writer and transmission-result observation. For Cloudflare, derive `EnvConfig` before moving `env` into the dispatcher and keep the configuration alive through the awaited call that borrows it in `RegistryInputs`. For Spin, unpack `stores.config`, `stores.kv`, and `stores.secrets` into the existing dispatcher's separate metadata arguments. These are adapter wrappers, not changes to internal dispatcher signatures. @@ -209,33 +243,44 @@ Do not route these helpers through the manual Cloudflare builder or Spin `AppExt Document a static owned by the concrete application entry-point module: ```rust,ignore -use edgezero_core::app::{App, Hooks}; +use edgezero_adapter_cloudflare::CLOUDFLARE_PLATFORM; +use edgezero_core::{app::App, EdgeError}; use std::sync::OnceLock; static APP: OnceLock = OnceLock::new(); -fn retained_app() -> &'static App { - APP.get_or_init(MyApp::build_app) +fn retained_app() -> Result<&'static App, EdgeError> { + if let Some(app) = APP.get() { + return Ok(app); + } + let app = App::build::(CLOUDFLARE_PLATFORM)?; + Ok(APP.get_or_init(|| app)) } ``` -The fetch/HTTP callback obtains this reference synchronously and passes it and the current request arguments to `dispatch_app::`. +The fetch callback obtains this reference synchronously, maps initialization +failure to a category-safe Worker error, and passes the successful app and current +request arguments to `dispatch_app::`. For Spin, use `SPIN_PLATFORM` or +metadata for the selected host and return `anyhow::Result` from the +HTTP callback. A failed build leaves the cache empty. Concurrent attempts may +construct multiple successful candidates; only one is retained. Requirements for the example and API documentation: - The cache belongs to one concrete application. Never put an unkeyed static inside a generic `cached()`; such statics are shared across type instantiations. -- Perform any application-owned logging initialization before `get_or_init` if construction logs. Adapter logging is currently a no-op on these two targets; do not invent a new logger implementation in this change. +- Perform any application-owned logging initialization before `App::build` if construction logs. Adapter logging is a no-op on these two targets; do not invent a new logger implementation in this change. - The initializer is synchronous and must not recursively access the same cache. Do not block the event loop waiting on an async initializer or hold mutex/borrow guards across dispatch awaits. - Retain only the app, not a future, native request, Worker `Env`/`Context`, or store registry. - Binding/open failures occur during dispatch and are retried on a later request through normal request setup; they must not poison the app cache. -- The simple example does not define fallible/async initialization or refresh. Applications needing these manage an initialized-state owner and publish a complete successful snapshot before sharing it. No generic framework failure cache is added. +- The example propagates fallible synchronous construction without caching errors. Applications needing asynchronous initialization, serialized construction attempts, or refresh manage that owner and publish a complete successful snapshot before sharing it. No generic framework failure cache is added. - App middleware and shared state now survive across requests and can be accessed by overlapping invocations. The application must consent to that changed lifetime by explicitly adopting the example. ### 4.6 Axum Keep the existing construction and service ownership. Include Axum in the shared-state contract tests and documentation matrix, but add no reuse flag or alternate lifecycle helper. -This does not claim that all Axum per-request costs have already been optimized; for example, request conversion still creates proxy wiring. Such work is outside this app-retention change. +This does not claim that all Axum per-request costs have already been optimized; +request-local outbound wiring and admission/egress resources remain necessary. ## 5. Fastly limits and termination @@ -250,9 +295,23 @@ Expose SDK limit configuration without translating or duplicating it: The implementation must follow the pinned SDK's exact comparisons rather than introducing alternative threshold semantics. The platform may enforce additional limits, and request CPU/runtime limits still apply per request. -The pinned SDK stops after a handler error. It attempts a 500 containing the error's display text for `Result` handler returns. This is existing SDK behavior, not a new sanitization policy. - -Distinguish an SDK callback error from an application error rendered as an HTTP response. `RouterService::oneshot` already renders ordinary handler errors, including propagated proxy errors, through `IntoResponse`; an HTTP 4xx/5xx response alone does not stop reuse. If rendering an application error itself returns `Err`, `RouterService::oneshot` propagates that failure too. Errors that escape the adapter boundary, such as error-rendering failure, required-KV opening, inbound conversion/body reads, or response stream collection failures, reach the SDK as `Err` and terminate that sandbox. Recovery then occurs through fresh initialization in another sandbox. The later-request binding retry described in §4.5 applies to Cloudflare/Spin. Do not add a catch-all response conversion to `serve_app`: it would change termination/summary semantics, and the current dispatcher has already erased `EdgeError` into `fastly::Error` text. Test rendered application errors and escaping adapter errors separately. No client-triggerable reuse-denial scenario or performance regression is established without a reproducible workload. +The pinned SDK stops after a handler error. EdgeZero's serving wrappers use a +delivery-result adapter that records `Result<(), E>` without invoking another +send; an escaping error terminates serving without an SDK-generated 500. Direct +SDK result handlers have a different sending contract and are not the canonical +EdgeZero entry path. + +Distinguish a terminal delivery error from an application error rendered as an +HTTP response. Canonical app dispatch renders routing/handler errors through the +configured error renderer, and an HTTP 4xx/5xx alone does not stop reuse. Admission +refusals and bounded precommit fallbacks follow the authoritative outbound design; +they are not escaping SDK errors merely because they report failure status. An +error that escapes owned delivery terminates standard serving without a second +response attempt. Test rendered errors, admission aborts, and terminal delivery +failures separately. Custom callbacks may handle a recoverable initialization +failure before commitment; recovery after an escaping error requires a fresh +sandbox. Cloudflare/Spin resolve bindings again on later requests without caching +initialization failures. `ServeSummary::requests()` counts attempted callback invocations, not successfully completed responses: the SDK increments it before invoking the handler, including the callback that returns an error. A panic may prevent a summary from being returned at all. `ServeSummary` also reports handler wall time, wait wall time, and handler error. It does not expose all reasons for termination: a next-request wait error can end the loop with no summary handler error, and promise registration can panic. `into_result() == Ok(())` alone is not proof of healthy reuse or reaching the requested limit. @@ -268,19 +327,28 @@ and retained entry points; stream translation and response ownership stay unchan | Adapter/path | Existing behavior retained | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| Fastly standard | Duplicate headers are appended. A core stream is consumed into a Fastly body before the response is returned. | -| Fastly custom | Explicit header commitment, chunk pumping, stream finish/drop, response-extension inspection, and application finalization remain possible. | -| Cloudflare | Preserve current stream translation and native response ownership. | -| Spin | Preserve fully buffered output and the current 16 MiB cap on streamed response collection; already buffered bodies do not use that stream cap. | -| Axum | Preserve current conversion, which collects streamed bodies into an in-memory buffer before sending. | +| Fastly standard | Duplicate headers are appended; the adapter owns commitment, lazy chunk writes, terminal observation, and cleanup. | +| Fastly custom | Request preparation and routed-response finalization remain possible through borrowing hooks; canonical delivery stays adapter-owned. | +| Cloudflare | A request-local JavaScript stream coordinator registered with `Context::wait_until` owns backpressure, deadline/abort handling, and close. | +| Spin | A raw `SpinResponse` is backed by an owned WASI body/transmission coordinator; no whole-response collection or historical 16 MiB stream cap. | +| Axum | A connection-local Hyper body preserves lazy production and terminal response-egress observation. | -Fastly and Spin inbound conversions buffer bodies. This feature must not be described as providing end-to-end streaming or removing body costs. +Inbound bodies are consumed only after canonical admission, under the selected +limits and absolute deadline. Lazy downstream delivery does not imply unbounded +uploads, remove provider queues, or eliminate per-request body costs. ### 6.2 Explicit-send error ordering -Fastly callbacks returning `()` or `Ok(())` must have sent a response themselves. They must finish or abandon guest-owned streaming resources before returning. Successful return must not cause a second send. +Custom Fastly callbacks return terminal `Result<(), E>` after owned delivery. +They must finish or abandon guest-owned streaming resources before returning. +The lifecycle wrapper records that result without another send. -Before committing headers, a callback may return an SDK-supported error. After committing a final response, returning `Err` through the built-in `HandlerResult` attempts another send and can panic. Custom code must handle post-commit errors explicitly: finish/drop the stream, log safely, and either return a completed outcome after cleanup or deliberately terminate the sandbox if state cannot safely continue. Do not imply `Result<(), E>` automatically distinguishes pre-commit and post-commit errors. +Canonical response egress decides the bounded fallback before commitment and +never replaces a committed response. After commitment, a body failure follows +adapter-owned cleanup and terminal reporting. An escaping `Err` from an EdgeZero +lifecycle callback terminates serving without an SDK resend. Applications must +not hand a native response to a second SDK sender or bypass this path with raw +conversion/manual body collection. Complete required guest-owned post-send work within the Fastly callback. Finishing a guest response does not mean the peer has received every network byte; do not wait for network delivery acknowledgements. Cloudflare's documented request `wait_until` work remains tied to its own context, not to the retained app. @@ -294,21 +362,29 @@ Complete required guest-owned post-send work within the Fastly callback. Finishi Cloning a wrapper or satisfying `Send + Sync` is not proof of host-resource validity or request isolation. Persistent `Arc` interiors are intentionally shared; the framework does not clear their contents between requests. Existing same-type extension overwrite behavior must be tested and documented, not changed here. -Keep runtime configuration reads and store registry construction per request. Retained app state is a separate snapshot and will not automatically follow selector changes. A refresh must publish a coherent snapshot; in-flight requests keep the snapshot they acquired. Applications may choose not to refresh within an instance, but must document the resulting staleness policy. +Keep store registry construction per request. Cloudflare and Spin resolve their runtime configuration per invocation; Fastly opens immutable deployed logical aliases. Retained app state is a separate snapshot and will not automatically follow configuration changes. A refresh must publish a coherent snapshot; in-flight requests keep the snapshot they acquired. Applications may choose not to refresh within an instance, but must document the resulting staleness policy. Audit all process-global and thread-local state, including `OnceLock` caches, warning suppression, and recursion guards. Static warning suppression can reduce log volume across requests; warning rate must not be interpreted as failure rate. The current bounded recent-name set can evict entries, so suppression is not an absolute once-per-sandbox guarantee. Verify guard cleanup on supported exit/error paths; the presence of a thread-local guard alone does not demonstrate leakage. Audit result: core's recursion depth is thread-local and guarded by `SecretFieldsRecursionGuard`. Tests verify normal scope cleanup and host unwinding followed by fresh entry; host unwinding does not prove cleanup after a terminating WASM trap. Canonical-form instrumentation and environment locks are test-only. Fastly's missing-store warning sets are bounded, and their eviction tests cover repeated names. No request data belongs in these caches. -**Dynamic backends:** `proxy::ensure_backend` creates names from scheme, host, and port, so requests targeting many distinct origins exercise growing registration diversity. Current Fastly documentation describes node-level registration reuse even without sandbox reuse, and a service-wide concurrent dynamic-backend limit that blocks awaiting capacity rather than necessarily returning an immediate registration error. Do not model this as a per-sandbox counter that resets all capacity on every request. Use authorized, bounded origin sets and consider static backends for predictable origins. Test repeated and distinct origins, registration conflicts, and time waiting for capacity. Tune sandbox request limits using measured behavior; `with_max_requests` alone cannot bound service-wide registrations, fan-out within a request, or time blocked inside a request. The between-request lifetime check cannot interrupt a blocked registration. See [registration scope](https://www.fastly.com/documentation/guides/integrations/non-fastly-services/developer-guide-backends/) and [dynamic-backend limit behavior](https://www.fastly.com/documentation/reference/compute/errors). +**Dynamic backends:** Fastly outbound execution registers backends using +URI-derived identity and adapter policy; the outbound design owns naming, +timeout, and dispatch rules. Distinct origins can increase registration +diversity. Do not model service-wide capacity as a counter reset by app or +sandbox reconstruction. Use bounded authorized origin sets and measure repeated +and distinct origins, registration failures/waits, and memory. A between-request +lifecycle limit cannot preempt a blocked hostcall or bound fan-out within one +request. See [registration scope](https://www.fastly.com/documentation/guides/integrations/non-fastly-services/developer-guide-backends/) +and [dynamic-backend limit behavior](https://www.fastly.com/documentation/reference/compute/errors). Fastly environment variables describe the sandbox: `FASTLY_TRACE_ID` must not be treated as a unique request identifier in reusable mode. Capture `Request::get_client_request_id()` separately for request correlation, with a documented fallback when unavailable. Do not retain request correlation fields in the global logger or app state. ## 7. Compatibility and unresolved platform evidence - Existing Fastly, Cloudflare, and Spin `run_app` calls continue building each invocation; generated templates continue calling them. -- Existing Fastly `run_app_with_config`, manual service builders, raw conversion, and custom router dispatch retain their signatures and behavior. -- Existing `Hooks`, `app!` syntax, registered-state precedence, and provider feature gates remain unchanged. +- PR #275 supersedes historical raw conversion and response-returning custom dispatch. Fastly uses `Sandbox`, `serve_app`, `serve_app_with_hooks`, and canonical send-owning request hooks; no compatibility aliases restore removed APIs. +- `Hooks::configure` is fallible and `App::build::(PLATFORM)` is the canonical constructor. Retention does not change macro syntax, registered-state precedence, or provider feature gates. - No Tokio dependency is added to core or WASM adapters. Examples import portable HTTP types from core and use `#[action]` for any new handlers. - Reuse changes app-state lifetime only on explicit adoption. It must never silently become the default through a template or macro change. - Adopting retention changes the frequency of construction-owned logging setup. `owns_logging` still only disables framework installation; applications that refresh logging policy during each construction must move that refresh to explicit request setup or accept an initialization snapshot. The framework does not invoke a new application logging hook. @@ -330,16 +406,16 @@ Expected production changes: - `crates/edgezero-adapter-cloudflare/src/lib.rs`: `Hooks`-typed prebuilt dispatcher shared with `run_app`. - `crates/edgezero-adapter-cloudflare/src/response.rs`: the duplicate-header correction described in §6.1. - `crates/edgezero-adapter-spin/src/lib.rs`: `Hooks`-typed prebuilt dispatcher shared with `run_app`. -- Fastly `src/request.rs`: public registry-aware request conversion shared with buffered dispatch. +- Fastly `src/request.rs`: canonical admitted, send-owning dispatch with request preparation and routed-response finalization hooks. - CLI `src/config.rs` and Spin `src/cli.rs`: selected-adapter validation and diagnostic redaction listed above. -- `crates/edgezero-core/src/app.rs` or existing core tests: compile-time retained-object bounds and shared-state contract coverage; no new core production abstraction. +- `crates/edgezero-core/src/app.rs`: fallible platform-aware assembly and canonical admission/egress; retained-object bounds and shared-state contract coverage. Documentation changes during implementation: - `docs/guide/adapters/overview.md`: lifecycle matrix, retained versus request state, common opt-in contract. - `docs/guide/adapters/fastly.md`: default and reusable `main`, SDK limits, summary/error handling, standard and custom streaming paths, evidence limitations. - `docs/guide/adapters/cloudflare.md`: concrete app cache, fresh `Env`/`Context`, concurrent fetch isolation, cold initialization. -- `docs/guide/adapters/spin.md`: SDK6/P3 distinction, concrete app cache, host reuse controls and concurrency, buffering behavior. +- `docs/guide/adapters/spin.md`: SDK7/P3 distinction, successful-only concrete app cache, host reuse controls and concurrency, raw response-egress ownership. - `docs/guide/adapters/axum.md`: existing retained-router behavior and concurrent shared state. - `examples/app-demo/crates/app-demo-core/src/lib.rs`: qualify fresh-Fastly-instance language as default behavior. @@ -355,13 +431,13 @@ The listed surfaces are implemented; the original design-step authorization chec The current Fastly crate does not link native host tests when its `fastly` feature is enabled. Verification with `cargo test -p edgezero-adapter-fastly --features fastly --lib --offline` failed on arm64 with unresolved Fastly hostcalls, including `_uri_get`. This is pre-existing: the default workspace test gate omits that feature, while the feature-enabled `cargo check` gate type-checks without linking. Do not require native feature-enabled tests or add fake hostcall symbols to make them pass. -The original private-helper plan is superseded by [the custom serving lifecycle plan](../plans/2026-09-17-custom-serving-lifecycle.md). Public `lifecycle::Sandbox` contains feature-independent retention state with no Fastly SDK types or hostcalls; `serve_app` also retains private standard-initialization policy. Its production wrapper supplies runtime configuration, logging/build operations, and SDK dispatch. Host tests must exercise the same state transitions used in production, with counted injected operations or a counted real core app builder; a separate model that merely repeats the expected decisions is insufficient. Keep these mechanics local to the Fastly adapter; the public custom helpers do not introduce a cross-provider lifecycle abstraction. +The original private-helper plan is superseded by [the custom serving lifecycle plan](../plans/2026-09-17-custom-serving-lifecycle.md) and PR #275's outbound hard-cut. Public `lifecycle::Sandbox` contains feature-independent retention state with no Fastly SDK types or hostcalls; standard serving uses its independent setup and successful-app guards. The production wrapper supplies baked logging, fresh logical bindings, fallible platform-aware assembly, and canonical send-owning dispatch. Host tests must exercise those same transitions, not a duplicate model. Keep these mechanics local to Fastly; no cross-provider lifecycle abstraction is introduced. Assign verification explicitly: -- **Default host tests:** retained build/configure counts, first-initialization decisions, logger ownership/order through injected operations, degraded-then-successful configuration inputs preserving the first logging snapshot, terminal initialization errors, and fresh-owner reset. Pure core tests cover extension/state behavior without provider resources. Run `cargo test -p edgezero-adapter-fastly --lib` for the adapter logic. +- **Default host tests:** retained build/configure counts, baked logger ownership/order through injected operations, terminal initialization errors, and fresh-owner reset. Pure core tests cover extension/state behavior without provider resources. Run `cargo test -p edgezero-adapter-fastly --lib` for the adapter logic. - **Feature-enabled checks:** type-check SDK wrapper integration and callback bounds, including target-specific checks. These checks do not prove runtime behavior. -- **WASM/local provider fixtures (§9.2):** actual callback/build counts in reused instances, runtime-store failure/recovery where supported by controlled fixtures, real named-endpoint log delivery, native metadata/handles, stream completion, SDK summary counts/termination, and all other hostcall behavior. Fault injection proves only the injected case; unavailable provider failure modes must be reported separately. +- **WASM/local provider fixtures (§9.2):** actual callback/build counts in reused instances, logical binding failure/recovery where supported by controlled fixtures, real named-endpoint log delivery, native metadata/handles, stream completion, SDK summary counts/termination, and all other hostcall behavior. Fault injection proves only the injected case; unavailable provider failure modes must be reported separately. The requirements below span these layers. They do not require provider calls inside native colocated tests. The warning about feature-disabled no-op stubs excludes claims about provider behavior, not valid tests of shared feature-independent production logic. @@ -369,8 +445,8 @@ The requirements below span these layers. They do not require provider calls ins - Prove a concrete cache cannot return another application's router. No generic unkeyed static may appear in production or examples. - Verify fresh extensions, route parameters, headers, bodies, request IDs, and native metadata on successive requests. - Include same-type app/request extension collisions and intentionally shared `Arc` mutation, distinguishing intended sharing from leakage. -- Verify store metadata, named/default bindings, runtime selectors, missing-store behavior, and recovery after a request-specific binding failure. -- Verify logger ownership, ordering, first-initialization policy, and Fastly named-endpoint output on subsequent requests. Include a degraded first runtime-store read followed by a successful read: selectors recover while the logging snapshot remains disabled, as specified in §4.2. +- Verify store metadata, named/default bindings, deployed logical aliases, missing-store behavior, and recovery after a request-specific binding failure. Verify invocation-scoped selectors separately on targets that support them. +- Verify logger ownership, ordering, baked initialization policy, and Fastly named-endpoint output on subsequent requests. A binding failure must not change the release's logging policy. - Verify standard errors and custom send outcomes; initialization errors, constructor panic, pre-commit errors, post-commit stream errors, and later fresh initialization are separate cases. - Keep existing duplicate `Set-Cookie` and body tests. Add actual delayed-chunk/first-byte tests for paths that support progressive client streaming. - Compile examples against the locked SDKs on their actual targets. Host tests with feature-disabled no-op stubs do not validate provider behavior. @@ -389,7 +465,7 @@ Observed with the pinned standalone Viceroy 0.17.0: a guest admits at most six r - **Fastly:** run A/B/C below, stream completion, duplicate cookies, repeated logging, limit/idle termination, and restart isolation. - **Cloudflare:** use a local Workers runtime to compare per-fetch build versus retained concrete app; force overlapping requests with distinct inputs and a controlled await barrier. Restart to validate cold initialization. Record availability/version rather than assuming the runtime exists. -- **Spin:** run the SDK6/P3 fixture on a compatible local host, with both sequential and concurrent instance reuse. Exercise supported reuse-count/concurrency/idle controls and record chosen values. +- **Spin:** run the SDK7/P3 fixture on a compatible local host, with both sequential and concurrent instance reuse. Exercise supported reuse-count/concurrency/idle controls and record chosen values. - **Axum:** verify one build per server start and independent overlapping requests against the shared router. Use it as a retained-app reference, not as a proxy for WASM performance. A sequential request sequence alone does not prove reuse: observe a stable instance identifier and an increasing request ordinal. A runtime process ID alone is insufficient because one host process can create many guest instances. @@ -402,16 +478,20 @@ A sequential request sequence alone does not prove reuse: observe a stable insta | B | SDK `Serve` | App construction on every callback; global logging installed once | | C | SDK `Serve` | App construction and global logging once per sandbox | -B's logging qualification is mandatory: blindly wrapping the enabled-logger `run_app` would fail on its second installation and confound the comparison. Test that failure separately as a negative control. Keep all other dispatch, configuration, response, and workload choices identical between variants. Test standard dispatch and custom progressive streaming as separate comparable workloads; never compare buffered A against streaming C. +B's logging qualification is mandatory: blindly wrapping the enabled-logger `run_app` would fail on its second installation and confound the comparison. Test that failure separately as a negative control. Keep dispatch, configuration, lazy delivery, and workloads identical between variants. Do not compare historical buffered helpers against current streamed delivery. -Implement B through the custom path in §4.3: `Serve::run`, a once-only logging initialization decision from the baked manifest settings, per-callback `A::build_app()`, then `request::dispatch_with_registries`. Do not use retaining `serve_app` for B. Apply the same baked logging policy in B and C. +Implement B through `lifecycle::serve_custom`: a successful once-only baked logging +decision, per-callback `App::build::(FASTLY_PLATFORM)`, +then `request::send_request_with_registries_and_hooks`. Do not use retaining +`serve_app` for B. Apply the same baked logging policy in B and C so configuration +does not introduce a second experimental variable. Collect: - Sandbox start count, request ordinal, app/configure initialization count, and separately instrumented application initialization phases. - Attempted requests per sandbox, including one-request sandboxes and early exits. Record response commitment, guest-side completion, errors, and client-observed completion separately; do not equate the SDK attempt count with successful responses. Emit per-request records because crashes may prevent a final summary. - Client time to first byte and completion latency, with cold versus reused distributions and p50/p95/p99 plus sample counts. -- Initialization/handler wall time and CPU observations. SDK summary wall time is not CPU time; returned-response sending can occur outside callback timing. +- Initialization/handler wall time and CPU observations. SDK summary wall time is not CPU time; current Fastly callbacks include adapter-owned terminal delivery rather than handing a response to another sender. - SDK CPU phase deltas where available. The pinned SDK warns against cross-run benchmarking with its vCPU clock; corroborate comparisons with controlled profiling or platform telemetry and report precision/unsupported metrics. - Memory before initialization, after initialization, and after each request's cleanup; request-ordinal slope, peak, and plateau. Distinguish guest linear-memory high-water marks, SDK host-inclusive snapshots, and host process RSS. MiB rounding cannot prove absence of small leaks. @@ -445,7 +525,7 @@ The implementation is acceptable when: - Existing generated/default entry points retain their lifecycle defaults, with the separately listed compatibility and CLI corrections. - Fastly standard apps can opt into a bounded SDK serving lifecycle with exactly one successful app initialization per sandbox. -- Custom Fastly callbacks can retain their own state, mutate raw requests, preserve response extensions, explicitly stream, and complete post-send work. +- Custom Fastly callbacks can retain successful app state, prepare raw requests, inspect/finalize routed response extensions through hooks, and use canonical lazy delivery without another sender. - Cloudflare and Spin can use concrete retained apps with full metadata-aware per-request dispatch and tested overlapping-request isolation. - Axum's existing retained-router behavior remains intact. - Fresh initialization, failure recovery, bounded state, duplicate headers, and existing response semantics are validated. diff --git a/docs/superpowers/specs/2026-09-28-platform-resource-metadata-design.md b/docs/superpowers/specs/2026-09-28-platform-resource-metadata-design.md new file mode 100644 index 000000000..10d545b27 --- /dev/null +++ b/docs/superpowers/specs/2026-09-28-platform-resource-metadata-design.md @@ -0,0 +1,152 @@ +# Platform Resource Metadata Design + +**Date:** 2026-09-28 +**Status:** Approved for implementation + +## Purpose + +Applications can currently read a target memory ceiling and whether that ceiling is per execution +or shared by one runtime instance. That is insufficient for fail-closed startup validation: a shared +ceiling also needs the maximum number of live inbound request executions charged to it, and every +ceiling needs an explicit statement about whether provider-owned ingress framing memory is charged +against it. Absence must remain distinguishable from a known value. + +This design publishes those facts without moving application admission policy into EdgeZero. + +## Platform facts + +`edgezero-core` owns a generic fact wrapper: + +```rust +pub enum PlatformFact { + Known { + value: Value, + source: PlatformResourceSource, + }, + Unknown { + reason: PlatformUnknownReason, + }, +} +``` + +`PlatformResourceSource` identifies a published platform limit, a hosted default, or a runtime +configuration. `PlatformUnknownReason` distinguishes operator configuration, runtime configuration, +provider-unpublished facts, and unspecified metadata. Unknown is data, not an absent field and not +permission to substitute an unrelated provider limit. + +`PlatformMetadata` carries three facts: + +- `PlatformFact`; +- `PlatformFact`, whose non-zero value is the maximum number of live + inbound request executions charged to the same memory ceiling; +- `PlatformFact`, whose known values are `CountsTowardCeiling` and + `OutsideCeiling`. + +`MemoryCeiling` retains exact primary bytes, optional separately published stack bytes, and +`MemoryCeilingScope`. Provenance moves to the fact wrapper so every resource fact uses one source +model. + +## Canonical target facts + +| Target | Memory ceiling | Live requests per memory domain | Host ingress/framing charge | +| --- | --- | --- | --- | +| Axum | Unknown: operator configured | Unknown: operator configured | Unknown: operator configured | +| Cloudflare Workers | Known: 128,000,000 bytes per instance | Unknown: provider unpublished | Unknown: provider unpublished | +| Fastly Compute | Known: 128,000,000-byte heap plus separate 1,000,000-byte stack per execution | Known: 1 per execution | Unknown: provider unpublished | +| Spin (generic) | Unknown: runtime configured | Unknown: runtime configured | Unknown: runtime configured | +| Akamai Functions (Spin) | Known hosted default: 134,217,728 bytes per execution | Known hosted default: 1 per execution | Unknown: provider unpublished | + +The Cloudflare six-simultaneous-connection limit is not an inbound population bound. It applies to +outbound connections waiting for headers within one invocation and must not populate this metadata. +All adapters currently receive host-parsed requests, so EdgeZero cannot infer host parser or HPACK +accounting from guest-visible allocations. + +## Validation + +Applications describe the arithmetic they intend to validate with a scope-tagged `MemoryEnvelope`: + +```rust +pub struct MemoryEnvelope { + scope: MemoryCeilingScope, + fixed_bytes: u64, + per_live_request_bytes: u64, + separate_stack_bytes: Option, +} +``` + +`PlatformMetadata::validate_memory_envelope` uses checked arithmetic and returns: + +- `Fits` only when the ceiling, request-population bound, and host-ingress accounting are all known, + the envelope scope matches the ceiling scope, primary required bytes do not exceed the primary + ceiling, and any separately published stack allowance has a supplied requirement within its + limit; +- `Exceeds` when the known minimum primary or separate-stack requirement already exceeds its + ceiling, even if another fact is unknown; +- `Indeterminate` with typed reasons whenever an unknown fact prevents a complete result. + +For `PerExecution`, required bytes are `fixed + per_live_request`; the population bound must be one +when known. For `PerInstance`, required bytes are `fixed + per_live_request * max_live_requests`. +When the per-instance population is unknown, the validator uses one as the checked minimum only to +decide whether `Exceeds` is already certain; otherwise the result remains `Indeterminate`. + +A separately published stack ceiling is checked independently. Omitting the envelope's separate +stack requirement when the platform publishes one makes the result `Indeterminate`; supplying a +separate stack requirement when the platform publishes no separate allowance is an error because +the primary envelope must then include that memory. An envelope whose scope differs from the +published ceiling is an error, as is arithmetic overflow or a non-one population bound attached to +a per-execution ceiling. These failures use a public typed validation error rather than strings. + +`per_live_request_bytes` must include every charge the application claims is covered. If host +ingress memory is known to count toward the ceiling, the caller must include its bounded charge. An +unknown accounting fact prevents `Fits`; callers cannot override it through this API. + +The validation result describes the complete published memory domain. Applications may separately +validate narrower, explicitly named managed budgets, but must not reinterpret `Indeterminate` as a +complete fit. + +## Adapter API + +The CLI adapter trait hard-cuts `memory_ceiling()` to `platform_metadata()`. Runtime entrypoints and +CLI adapters return the same canonical `PlatformMetadata` constant, preventing each new resource +fact from creating another parallel trait method. The default returns fully unknown metadata. + +No compatibility alias remains. + +## Batch observation cutoff + +The batch method names remain unchanged. Public parameter names, examples, and documentation use +`observation_cutoff`. The contract states that this instant stops observation regardless of a +slot's remaining request budget. Passing an earlier instant intentionally produces unresolved slots +at cutoff. + +No debug assertion requires the observation cutoff to cover every request deadline: early cutoffs +are a supported partial-result operation. `BudgetSource::BatchCutoff` continues to identify the +selected effective slot budget and does not, by itself, signal batch termination. + +## Migration record + +This is the historical platform-metadata migration record. Its five-argument response +constructor is superseded by the outbound design's six-argument constructor carrying +original Content-Type provenance; no five-argument compatibility API remains. + +The repository gains an unreleased changelog with an exact migration table: + +| Removed or changed API | Replacement | +| --- | --- | +| `MemoryCeilingSource` | `PlatformResourceSource` inside `PlatformFact::Known` | +| `MemoryCeiling::new(total, scope, stack, source)` | `MemoryCeiling::new(total, scope, stack)` wrapped in `PlatformFact::known(value, source)` | +| `MemoryCeiling::source()` | Match or inspect the enclosing `PlatformFact` | +| `PlatformMetadata::new(Option)` | `PlatformMetadata::new(memory, inbound_population, host_ingress_accounting)` | +| `PlatformMetadata::memory_ceiling() -> Option<_>` | `PlatformMetadata::memory_ceiling() -> PlatformFact<_>` | +| `Adapter::memory_ceiling()` | `Adapter::platform_metadata()` | +| overridable `Hooks::build_app()` and infallible `configure` | `App::build::(platform)` plus fallible `Hooks::configure` | +| four-argument `OutboundResponse::new` | five-argument constructor requiring the application `MonotonicClock` | + +The demo application and generated project use the same current APIs; no stale compatibility path +is retained. + +## Verification + +Tests cover fact construction, each canonical adapter profile, every validation outcome, scope and +arithmetic failures, per-execution population invariants, documentation drift, generated projects, +the demo workspace, native feature matrices, and all three WASM targets. diff --git a/docs/superpowers/specs/2026-10-05-logging-ownership-boot-diagnostics-design.md b/docs/superpowers/specs/2026-10-05-logging-ownership-boot-diagnostics-design.md new file mode 100644 index 000000000..98ab33aba --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-logging-ownership-boot-diagnostics-design.md @@ -0,0 +1,63 @@ +# Logging Ownership and Boot Diagnostics + +## Contract + +`Hooks::owns_logging() == true` means the application owns backend installation, +backend filters, and the `log` facade maximum. Adapters must not replace or +reconfigure that logger. Applications install it before the adapter entrypoint. + +Core exposes a pure `resolve_logging_level(&EnvConfig) -> log::LevelFilter` +helper for `EDGEZERO__LOGGING__LEVEL`. It preserves the existing case-insensitive +parser and `Info` fallback for missing or invalid values; it does not install a +logger, read process environment, or change global filters. Manifest logging +resolution remains separate and is not implicitly applied by this helper. + +`BOOT_LOG_TARGET` names `edgezero::boot`; `BOOT_LOG_LEVEL` is `Warn`. Adapter-owned +Fastly and Axum logging preserves errors and warnings on that target even when +ordinary logging is `Error` or `Off`. Each backend and the facade must admit the +maximum of the ordinary and boot levels, while the outer target-aware filter +continues to suppress ordinary records above their configured level. The boot +target is a category, not a new Fastly log endpoint. + +Fastly `runtime_env_config` returns `FastlyRuntimeConfig { env, ... }`, hard-cutting +the former direct `EnvConfig` return. An unavailable optional runtime store sets +one boolean diagnostic flag. `emit_boot_diagnostics()` consumes its fixed warning +once after backend installation; missing backends do not gain implicit output. +`run_app` and `run_app_with_hooks` emit it even when application assembly returns +an error. The explicit-config runner does not read that runtime store. + +Install adapter-owned logging before application configuration. Diagnostics +generated while resolving logger configuration must be retained as bounded +typed/static state and emitted after installation, not as an unbounded log +buffer. When an application owns logging, the same boot records use its backend +and filters; visibility depends on the application's policy. + +Cloudflare and Spin logger initializers remain explicitly documented no-ops. +Their platform consoles do not automatically install a Rust `log` backend. +This change adds neither a logger dependency nor a new capability there. + +The bundled CLI must not install its CLI logger before running the Axum demo. +Axum's `run_app_with_preflight` runs reusable startup checks after logging setup +and before application configuration or listener binding. The demo uses that +boundary for capability validation. Axum returns logger-setup errors rather than +silently using a different backend. CLI terminal errors use stderr regardless of +the runtime filter. +Generated adapter entrypoints continue delegating to reusable adapter code; +logging policy must not be duplicated in templates. + +## Acceptance + +- Resolver coverage includes every level, absent/invalid values, and no global + logging side effects. +- Ordinary records retain their configured filter; boot `Error`/`Warn` survive + ordinary `Error`/`Off`; boot `Info`/`Debug`/`Trace` remain suppressed. +- Fastly backend filtering and the facade ceiling cannot contradict the boot + target policy. +- Configuration executes after adapter-owned logger setup; application-owned + logger/filter state is not changed. +- Startup warning deferral is bounded and emits each diagnostic once. +- Direct Axum and bundled demo use the same runtime logging configuration. +- Documentation, generator output, and demo describe ownership accurately. + +This is a logging API/documentation improvement, not an outbound correctness or +provider-certification prerequisite. diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index 06b8bb650..0d679b2af 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -22,9 +22,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -46,9 +46,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -105,9 +105,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "app-demo-adapter-axum" @@ -191,9 +191,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -225,13 +225,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -248,9 +248,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -258,14 +258,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -326,6 +327,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "1.3.2" @@ -334,9 +341,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -369,9 +376,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.3" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -385,15 +392,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.65" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "jobserver", @@ -403,15 +410,26 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] [[package]] name = "chrono" @@ -428,9 +446,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -438,9 +456,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -450,21 +468,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "cmake" @@ -502,9 +520,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -553,6 +571,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -574,9 +601,9 @@ dependencies = [ [[package]] name = "ctor" -version = "1.0.7" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" dependencies = [ "link-section", "linktime-proc-macro", @@ -588,8 +615,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -606,13 +643,37 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn 2.0.119", ] @@ -641,7 +702,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.119", @@ -678,13 +739,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -703,6 +764,7 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" name = "edgezero-adapter" version = "0.1.0" dependencies = [ + "edgezero-core", "serde", "serde_json", "sha2 0.10.9", @@ -715,6 +777,7 @@ name = "edgezero-adapter-axum" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "async-trait", "axum", "bytes", @@ -724,12 +787,17 @@ dependencies = [ "futures", "futures-util", "http", + "http-body", + "hyper", + "hyper-util", + "libc", "log", "redb", "reqwest", + "serde", "serde_json", "simple_logger 5.2.0", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "toml", "tower", @@ -742,6 +810,7 @@ name = "edgezero-adapter-cloudflare" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "async-trait", "brotli", "bytes", @@ -756,6 +825,8 @@ dependencies = [ "tempfile", "toml_edit", "walkdir", + "wasm-streams", + "web-sys", "worker", ] @@ -773,6 +844,8 @@ dependencies = [ "edgezero-adapter", "edgezero-core", "fastly", + "fastly-shared", + "fastly-sys", "fern", "flate2", "futures", @@ -782,7 +855,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.18", + "thiserror 2.0.21", "toml_edit", "walkdir", ] @@ -792,13 +865,12 @@ name = "edgezero-adapter-spin" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "async-trait", - "brotli", "bytes", "ctor", "edgezero-adapter", "edgezero-core", - "flate2", "futures", "futures-util", "log", @@ -807,7 +879,7 @@ dependencies = [ "serde_json", "spin-sdk", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.21", "toml", "toml_edit", "walkdir", @@ -832,7 +904,7 @@ dependencies = [ "serde_json", "similar", "simple_logger 5.2.0", - "thiserror 2.0.18", + "thiserror 2.0.21", "toml", "validator", ] @@ -841,28 +913,40 @@ dependencies = [ name = "edgezero-core" version = "0.1.0" dependencies = [ + "adler2", + "alloc-no-stdlib", + "alloc-stdlib", "anyhow", "async-compression", "async-stream", "async-trait", + "brotli", + "brotli-decompressor", "bytes", + "compression-codecs", + "compression-core", + "crc32fast", "edgezero-macros", + "flate2", "futures", "futures-util", "http", "http-body", "log", "matchit 0.9.2", + "miniz_oxide", "ryu", "serde", "serde_json", "serde_path_to_error", "serde_urlencoded", "sha2 0.10.9", - "thiserror 2.0.18", + "simd-adler32", + "thiserror 2.0.21", "toml", "tower-service", "tracing", + "url", "validator", "web-time", ] @@ -876,16 +960,16 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 3.0.3", + "syn 3.0.6", "toml", "validator", ] [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "elsa" @@ -927,9 +1011,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastly" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531e4c3df48350d9f4fc95b4deaf87fd29820336b7926bb84bf460457c2a126b" +checksum = "3c5c07c0c67d0b5826d1cb2311e563a17860b50e2ae89e1c703d6400d904d1e2" dependencies = [ "anyhow", "bytes", @@ -940,7 +1024,6 @@ dependencies = [ "fastly-sys", "http", "itertools", - "lazy_static", "mime", "serde", "serde_json", @@ -955,9 +1038,9 @@ dependencies = [ [[package]] name = "fastly-macros" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2aef5f9690b04c8890f9a54ddb591b12b9779ec25ee0e572d207106e52e3d8" +checksum = "698025eab5612adab20df48963a5126650893014e8505af02a0af20939ee6a05" dependencies = [ "proc-macro2", "quote", @@ -966,9 +1049,9 @@ dependencies = [ [[package]] name = "fastly-shared" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080ad138403159fd366d3e0b14bb49cb0c01dc18c25095bbbd1c85e3338f5413" +checksum = "191c2cf082f5d17f4ca0685cedaa0642ad4ade949fac8d1dbdd62ff756b13500" dependencies = [ "bitflags 1.3.2", "http", @@ -976,9 +1059,9 @@ dependencies = [ [[package]] name = "fastly-sys" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de75ef193f6c29c43d667458bede648970715aedd5db2d42c2eba3ffa3ad738b" +checksum = "239c77706cf9110b423a89b6b03b8011729b144390d2a7f641d95ef66b2768b8" dependencies = [ "bitflags 1.3.2", "fastly-shared", @@ -989,9 +1072,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fern" @@ -1004,9 +1087,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flate2" @@ -1047,9 +1130,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1062,9 +1145,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1072,15 +1155,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1089,38 +1172,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1156,20 +1239,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -1177,15 +1246,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", - "r-efi 6.0.0", + "r-efi", + "rand_core", + "wasm-bindgen", ] [[package]] name = "handlebars" -version = "6.4.1" +version = "6.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43ccdfe15a81ab0a8af639e90254227c9a46afd9c5f5b6ec7efaa345c4b0f00" +checksum = "75c54236f9045c8004a77942bebc52145b4844639db934a5c70fe08617fbe61a" dependencies = [ "derive_builder", "log", @@ -1194,7 +1266,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -1242,9 +1314,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1252,9 +1324,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1277,13 +1349,12 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.10.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", - "futures-channel", "futures-core", "http", "http-body", @@ -1298,9 +1369,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -1313,16 +1384,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -1360,9 +1432,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1374,9 +1446,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1387,9 +1459,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1401,16 +1473,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1421,15 +1494,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1475,9 +1548,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -1487,9 +1560,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is_terminal_polyfill" @@ -1524,7 +1597,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.21", "walkdir", "windows-link", ] @@ -1563,19 +1636,19 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -1596,9 +1669,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libsqlite3-sys" @@ -1613,15 +1686,15 @@ dependencies = [ [[package]] name = "link-section" -version = "0.18.2" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2b1dd6fe32e55c0fc0ea9493aa57459ca3cf4ff3c857c7d0302290150da6e4f" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" [[package]] name = "linktime-proc-macro" -version = "0.2.0" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" [[package]] name = "linux-raw-sys" @@ -1631,21 +1704,21 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "log-fastly" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51dae5def13a2d557fdb63862d642f8d4641ec3773c036bb14092697b6764013" +checksum = "433713b6191ad3e1d5248db9536f3e2d99a12d7ff998afd272a98d96e6dbc2ed" dependencies = [ "fastly", "log", @@ -1654,9 +1727,9 @@ dependencies = [ [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "matchit" @@ -1678,9 +1751,9 @@ checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -1700,9 +1773,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -1717,9 +1790,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-modular" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc41a1374056e9672221567958a66c16be12d0e2c1b408761e14d901c237d5e0" +checksum = "bd8e500409e6cd603b03e477c26a6caecdc27ac58979a53e881c75eafc079f44" [[package]] name = "num-order" @@ -1780,9 +1853,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" dependencies = [ "memchr", "ucd-trie", @@ -1790,9 +1863,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" dependencies = [ "pest", "pest_generator", @@ -1800,9 +1873,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" dependencies = [ "pest", "pest_meta", @@ -1813,12 +1886,11 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" dependencies = [ "pest", - "sha2 0.10.9", ] [[package]] @@ -1849,15 +1921,15 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1868,15 +1940,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -1888,41 +1951,41 @@ dependencies = [ ] [[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" +name = "proc-macro-error-attr3" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" dependencies = [ "proc-macro2", "quote", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "proc-macro-error3" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" dependencies = [ - "proc-macro-error-attr2", + "proc-macro-error-attr3", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -1932,7 +1995,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -1940,21 +2003,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", "rand", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -1962,33 +2026,27 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1997,47 +2055,44 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.4" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "rand_chacha", + "chacha20", + "getrandom 0.4.3", "rand_core", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "rand_core" -version = "0.9.5" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "getrandom 0.3.4", + "rand_core", ] [[package]] name = "redb" -version = "4.1.0" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e925444704b5f17d32bf42f5b6e2df050bceebc3dcd6e71cc73dafe8092e839" +checksum = "fb338a6c67830a61bed824b78c2bb034ab1ff401a20616bd7957524f4fdc2f22" dependencies = [ "libc", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2047,9 +2102,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2068,7 +2123,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -2121,7 +2176,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2131,9 +2186,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -2146,11 +2201,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -2159,9 +2214,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", @@ -2185,9 +2240,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -2195,9 +2250,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation", "core-foundation-sys", @@ -2216,15 +2271,15 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -2234,9 +2289,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -2268,7 +2323,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation", "core-foundation-sys", "libc", @@ -2293,9 +2348,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2314,22 +2369,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -2358,13 +2413,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -2396,7 +2451,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -2408,7 +2463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -2436,9 +2491,9 @@ checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -2488,15 +2543,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2504,9 +2559,9 @@ dependencies = [ [[package]] name = "spin-macro" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11e483b94d5bcfac493caf0427fa875063e3e8604d0466a4ab491ec200a42857" +checksum = "ac8a641da857d4e7cada31a853c40180eeb10485a0f2f0282d0a7e5a2a4105d4" dependencies = [ "proc-macro2", "quote", @@ -2515,9 +2570,9 @@ dependencies = [ [[package]] name = "spin-sdk" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd2abac3eb2ee249c2241ab87f7b1287f36172c8cc1ea815c19c85e41ede44d" +checksum = "2b7a3d9a6805ccdb4d404e91ccf7198ac5e486b6199dc9387aeebaa368272112" dependencies = [ "anyhow", "bytes", @@ -2526,7 +2581,7 @@ dependencies = [ "http-body", "http-body-util", "spin-macro", - "thiserror 2.0.18", + "thiserror 2.0.21", "wasip3", ] @@ -2548,7 +2603,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -2563,6 +2627,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2593,9 +2669,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -2613,13 +2689,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -2646,11 +2722,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.21", ] [[package]] @@ -2666,20 +2742,20 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] name = "time" -version = "0.3.51" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "libc", @@ -2699,9 +2775,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.30" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -2709,9 +2785,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -2719,24 +2795,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -2750,20 +2817,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -2771,9 +2838,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", @@ -2795,9 +2862,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap", "toml_datetime", @@ -2808,18 +2875,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" @@ -2843,7 +2910,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -2919,9 +2986,9 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-xid" @@ -2977,13 +3044,12 @@ dependencies = [ [[package]] name = "validator_derive" -version = "0.20.0" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" dependencies = [ - "darling", - "once_cell", - "proc-macro-error2", + "darling 0.23.0", + "proc-macro-error3", "proc-macro2", "quote", "syn 2.0.119", @@ -3013,9 +3079,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -3037,22 +3103,22 @@ dependencies = [ [[package]] name = "wasip3" -version = "0.6.0+wasi-0.3.0-rc-2026-03-15" +version = "0.7.1+wasi-0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed83456dd6a0b8581998c0365e4651fa2997e5093b49243b7f35391afaa7a3d9" +checksum = "75e9ed91ee5a995b6fe16c89d4511a8c12eb76967be20b57557618e02e781bcc" dependencies = [ "bytes", "http", "http-body", - "thiserror 2.0.18", + "thiserror 2.0.21", "wit-bindgen 0.57.1", ] [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -3063,19 +3129,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3083,22 +3150,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -3144,7 +3211,7 @@ version = "0.247.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "hashbrown 0.17.1", "indexmap", "semver", @@ -3152,9 +3219,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.102" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -3172,9 +3239,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -3274,15 +3341,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -3316,30 +3374,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3352,12 +3393,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3370,12 +3405,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3388,24 +3417,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3418,12 +3435,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -3436,12 +3447,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -3454,12 +3459,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -3472,17 +3471,11 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -3493,7 +3486,7 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", ] [[package]] @@ -3502,7 +3495,7 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "futures", "wit-bindgen-rust-macro", ] @@ -3556,7 +3549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" dependencies = [ "anyhow", - "bitflags 2.13.0", + "bitflags 2.13.2", "indexmap", "log", "serde", @@ -3607,7 +3600,7 @@ dependencies = [ "serde-wasm-bindgen", "serde_json", "serde_urlencoded", - "strum", + "strum 0.27.2", "tokio", "url", "wasm-bindgen", @@ -3620,15 +3613,15 @@ dependencies = [ [[package]] name = "worker-macros" -version = "0.8.5" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d908735d273dd7f9c325a842623f4e5a745e0686187ce465b34dc162ad348df" +checksum = "f3d7389670b55aab89760be149007aeff773cdad7b650d807f7fb556d04bb304" dependencies = [ "async-trait", "proc-macro2", "quote", - "strum", - "syn 2.0.119", + "strum 0.28.0", + "syn 3.0.6", "wasm-bindgen", "wasm-bindgen-macro-support", "worker-sys", @@ -3636,9 +3629,9 @@ dependencies = [ [[package]] name = "worker-sys" -version = "0.8.5" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33faa1a8fa6c7eec67b196e008859c44d468a5ad4f991855cdc856f119e0e98f" +checksum = "6a440092e7ae7c5feb5507fbdc6aee0cc241d132757c83e1729766a24cddff82" dependencies = [ "cfg-if", "js-sys", @@ -3648,9 +3641,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "yoke" @@ -3665,30 +3658,30 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", @@ -3706,13 +3699,13 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] @@ -3724,9 +3717,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -3735,9 +3728,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -3746,17 +3739,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/examples/app-demo/Cargo.toml b/examples/app-demo/Cargo.toml index 8ef1cedf1..617f165b4 100644 --- a/examples/app-demo/Cargo.toml +++ b/examples/app-demo/Cargo.toml @@ -29,8 +29,8 @@ edgezero-adapter-fastly = { path = "../../crates/edgezero-adapter-fastly" } edgezero-adapter-spin = { path = "../../crates/edgezero-adapter-spin" } edgezero-cli = { path = "../../crates/edgezero-cli" } edgezero-core = { path = "../../crates/edgezero-core" } -spin-sdk = { version = "6", default-features = false } -fastly = "0.12" +spin-sdk = { version = "=7.0.0", default-features = false } +fastly = "=0.13.1" futures = { version = "0.3", default-features = false, features = ["std", "executor"] } log = "0.4" once_cell = "1" @@ -45,7 +45,7 @@ simple_logger = "4" tempfile = "3" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tracing = "0.1" -worker = { version = "0.8", default-features = false, features = ["http"] } +worker = { version = "=0.8.5", default-features = false, features = ["http"] } [profile.release] debug = 1 diff --git a/examples/app-demo/crates/app-demo-adapter-axum/axum.toml b/examples/app-demo/crates/app-demo-adapter-axum/axum.toml index 364f8e071..1b40fab97 100644 --- a/examples/app-demo/crates/app-demo-adapter-axum/axum.toml +++ b/examples/app-demo/crates/app-demo-adapter-axum/axum.toml @@ -2,3 +2,12 @@ crate = "app-demo-adapter-axum" crate_dir = "." port = 8787 + +# Local config snapshots load before listener bind. Push config before startup; +# restart after an update. Override allocation budgets with: +# EDGEZERO__ADAPTER__CONFIG_STORE__MAX_FILE_BYTES=16777216 +# EDGEZERO__ADAPTER__CONFIG_STORE__MAX_ENTRIES=1024 +# EDGEZERO__ADAPTER__CONFIG_STORE__MAX_KEY_BYTES=1024 +# EDGEZERO__ADAPTER__CONFIG_STORE__MAX_VALUE_BYTES=8388608 +# EDGEZERO__ADAPTER__CONFIG_STORE__MAX_RESIDENT_BYTES=33554432 +# EDGEZERO__ADAPTER__CONFIG_STORE__MAX_STARTUP_BYTES=117440512 diff --git a/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml b/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml index 6840f654d..9a00b286e 100644 --- a/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml +++ b/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml @@ -1,6 +1,7 @@ name = "app-demo-adapter-cloudflare" main = "build/worker/shim.mjs" compatibility_date = "2023-05-01" +compatibility_flags = ["enable_request_signal", "enable_weak_ref"] [build] command = "worker-build --release" diff --git a/examples/app-demo/crates/app-demo-adapter-fastly/src/main.rs b/examples/app-demo/crates/app-demo-adapter-fastly/src/main.rs index b8ba7515a..fac1438ec 100644 --- a/examples/app-demo/crates/app-demo-adapter-fastly/src/main.rs +++ b/examples/app-demo/crates/app-demo-adapter-fastly/src/main.rs @@ -6,11 +6,8 @@ #[cfg(target_arch = "wasm32")] use app_demo_core::App; #[cfg(target_arch = "wasm32")] -use fastly::{Error, Request, Response}; -#[cfg(target_arch = "wasm32")] -#[fastly::main] -pub fn main(req: Request) -> Result { - edgezero_adapter_fastly::run_app::(req) +pub fn main() -> Result<(), fastly::Error> { + edgezero_adapter_fastly::run_app::() } #[cfg(not(target_arch = "wasm32"))] diff --git a/examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs b/examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs index 51adabb4e..8bcb12a06 100644 --- a/examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs +++ b/examples/app-demo/crates/app-demo-adapter-spin/src/lib.rs @@ -9,12 +9,12 @@ #[cfg(target_arch = "wasm32")] use app_demo_core::App; #[cfg(target_arch = "wasm32")] -use spin_sdk::http::{IntoResponse, Request}; +use spin_sdk::http::Request; #[cfg(target_arch = "wasm32")] use spin_sdk::http_service; #[cfg(target_arch = "wasm32")] #[http_service] -async fn handle(req: Request) -> anyhow::Result { +async fn handle(req: Request) -> anyhow::Result { edgezero_adapter_spin::run_app::(req).await } diff --git a/examples/app-demo/crates/app-demo-cli/tests/config_flow.rs b/examples/app-demo/crates/app-demo-cli/tests/config_flow.rs index e7b608693..279cf4498 100644 --- a/examples/app-demo/crates/app-demo-cli/tests/config_flow.rs +++ b/examples/app-demo/crates/app-demo-cli/tests/config_flow.rs @@ -11,7 +11,11 @@ #![cfg(test)] use app_demo_core::config::AppDemoConfig; +use edgezero_adapter::cli_support::validate_spin_outbound_host_contract; +use edgezero_adapter_axum::config_store_limits::ConfigStoreLimits; use edgezero_cli::args::{ConfigPushArgs, ConfigValidateArgs}; +use edgezero_core::manifest::ManifestLoader; +use edgezero_core::Capability; use std::fs; use std::path::{Path, PathBuf}; @@ -120,6 +124,43 @@ fn push_args(manifest: &Path, adapter: &str, dry_run: bool) -> ConfigPushArgs { args } +#[test] +fn app_demo_manifest_declares_outbound_http_optional() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let app_manifest_path = root.join("edgezero.toml"); + let spin_manifest_path = root.join("crates/app-demo-adapter-spin/spin.toml"); + let manifest = + ManifestLoader::from_path(&app_manifest_path).expect("parse shipped app-demo manifest"); + assert_eq!( + manifest.manifest().capabilities.optional, + [Capability::OutboundHttp] + ); + let hosts = manifest + .manifest() + .capabilities + .outbound + .hosts + .as_deref() + .expect("app-demo outbound hosts"); + assert_eq!(hosts, ["https://*:*"]); + + let spin = fs::read_to_string(&spin_manifest_path).expect("read shipped Spin manifest"); + assert_eq!( + spin.matches("allowed_outbound_hosts = [\"https://*:*\"]") + .count(), + 1, + "app-demo Spin manifest must grant exactly the canonical HTTPS wildcard" + ); + assert!(!spin.contains("allowed_outbound_hosts = [\"http://")); + validate_spin_outbound_host_contract( + manifest.manifest(), + &app_manifest_path, + &spin_manifest_path, + None, + ) + .expect("app-demo application and selected Spin component must remain aligned"); +} + #[test] fn config_validate_strict_passes_against_app_demo_config() { // Typed validator runs the raw checks (manifest schema, store @@ -215,7 +256,11 @@ fn config_typed_handler_deserialises_blob_envelope_to_greeting() { }); let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned()); let blob_str = serde_json::to_string(&envelope).expect("envelope JSON"); - let store = AxumConfigStore::from_map([("app_config".to_owned(), blob_str)]); + let store = AxumConfigStore::from_map( + [("app_config".to_owned(), blob_str)], + ConfigStoreLimits::default(), + ) + .expect("bounded snapshot"); let config_registry: ConfigRegistry = StoreRegistry::single_id( "app_config".to_owned(), diff --git a/examples/app-demo/crates/app-demo-core/src/handlers.rs b/examples/app-demo/crates/app-demo-core/src/handlers.rs index ae112669d..fabf6a519 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -1,5 +1,7 @@ -use std::env; +use std::io::Error as IoError; +use std::num::NonZeroU64; use std::sync::Arc; +use std::time::Duration; use bytes::Bytes; use edgezero_core::action; @@ -9,27 +11,70 @@ use edgezero_core::error::EdgeError; use edgezero_core::extractor::{ AppConfig, Headers, Json, Kv, Path, Query, Secrets, State, ValidatedPath, }; -use edgezero_core::http::{self, Response, StatusCode, Uri}; -use edgezero_core::proxy::ProxyRequest; +use edgezero_core::http::{self, Method, Response, StatusCode, Uri}; +use edgezero_core::outbound::{ + OutboundBatchTermination, OutboundCachePolicy, OutboundRequest, OutboundSlotResult, +}; use edgezero_core::response::Text; +use edgezero_core::ResponseEgressDeadline; use futures::{stream, StreamExt as _}; -use crate::config::AppDemoConfig; +use crate::{config::AppDemoConfig, AdmissionLease}; const ALLOWED_CONFIG_KEYS: &[&str] = &["greeting", "feature.new_checkout", "service.timeout_ms"]; const DEFAULT_PROXY_BASE: &str = "https://httpbin.org"; +const MAX_DECODER_BYTES: u64 = 0x0200_0000; +const MAX_BROTLI_WINDOW_BITS: u8 = 24; /// Maximum request body size (25 MB, matches KV value limit). const MAX_BODY_SIZE: usize = 25 * 1024 * 1024; +const MAX_DECODED_RESPONSE_BYTES: u64 = 0x0010_0000; +const MAX_ENCODED_RESPONSE_BYTES: u64 = 0x0020_0000; +const MAX_FANOUT_INPUT_BYTES: usize = 0x4000; +const MAX_FANOUT_REQUESTS: usize = 8; +const MAX_FINAL_RESPONSE_BYTES: u64 = 0x0020_0000; +const MAX_OUTBOUND_REQUEST_BODY_BYTES: u64 = 0x0010_0000; +const MAX_RESPONSE_CHUNK_BYTES: u64 = 0x0001_0000; +const MAX_RESPONSE_HEADER_BYTES: u64 = 0x0001_0000; +const MAX_RESPONSE_HEADER_COUNT: u64 = 100; // 512 (KV key limit) - 5 (len of "note:") = 507 const MAX_NOTE_ID_LEN: u64 = 507; +const OUTBOUND_BATCH_BUDGET: Duration = Duration::from_secs(5); +const OUTBOUND_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const RESPONSE_EGRESS_BUDGET: Duration = Duration::from_secs(2); const SMOKE_SECRET_MISSING_NAME: &str = "SMOKE_SECRET_MISSING"; const SMOKE_SECRET_NAME: &str = "SMOKE_SECRET"; +#[derive(serde::Serialize)] +struct AdmissionView { + grant_consumed_once: bool, + route_class: Option, +} + #[derive(serde::Deserialize)] struct ConfigParams { name: String, } +#[derive(serde::Deserialize)] +struct FanoutRequest { + paths: Vec, +} + +#[derive(serde::Serialize)] +struct FanoutSlot { + elapsed_ms: Option, + index: usize, + outcome: FanoutSlotOutcome, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "snake_case", tag = "kind")] +enum FanoutSlotOutcome { + Error { category: &'static str, status: u16 }, + Response { status: u16 }, + Unresolved, +} + #[derive(serde::Deserialize)] pub struct EchoBody { pub name: String, @@ -93,22 +138,157 @@ pub async fn echo_json(Json(body): Json) -> Text { Text::new(format!("Hello, {}!", body.name)) } +#[action] +pub async fn admission(RequestContext(ctx): RequestContext) -> Result { + let route_class = ctx + .route_metadata() + .and_then(|metadata| metadata.class()) + .map(str::to_owned); + let grant = ctx + .take_ingress_grant() + .ok_or_else(|| EdgeError::internal(IoError::other("admission grant was not installed")))?; + let lease = grant + .downcast::() + .map_err(|_grant| EdgeError::internal(IoError::other("admission grant type mismatch")))?; + if lease.route_class != route_class { + return Err(EdgeError::internal(IoError::other( + "admission grant route class mismatch", + ))); + } + lease + .install_response_permit() + .map_err(EdgeError::internal)?; + + json_response(&AdmissionView { + grant_consumed_once: ctx.take_ingress_grant().is_none(), + route_class, + }) +} + +#[action] +pub async fn fanout(RequestContext(ctx): RequestContext) -> Result { + let Some(client) = ctx.http_client() else { + return proxy_not_available_response(); + }; + let input: FanoutRequest = ctx.json_within(MAX_FANOUT_INPUT_BYTES).await?; + if input.paths.len() > MAX_FANOUT_REQUESTS { + return Err(EdgeError::validation(format!( + "fanout accepts at most {MAX_FANOUT_REQUESTS} paths" + ))); + } + + let base = ctx + .variable("API_BASE_URL") + .unwrap_or_else(|| DEFAULT_PROXY_BASE.to_owned()); + let deadline = ctx.monotonic_clock().deadline_after(OUTBOUND_BATCH_BUDGET); + let source_uri = Uri::from_static("/fanout"); + let requests = input + .paths + .into_iter() + .map(|path| { + let target = build_proxy_target(&base, &path, &source_uri)?; + Ok(outbound_policy(OutboundRequest::new(Method::GET, target)?).deadline(deadline)) + }) + .collect::, EdgeError>>()?; + let slots = client + .send_all_until(requests, deadline) + .await + .map_err(|failure| failure.error)?; + let termination = slots.termination; + let output = slots + .slots + .into_iter() + .enumerate() + .map(|(index, slot)| fanout_slot(index, slot, termination)) + .collect::, EdgeError>>()?; + + let mut response = json_response(&output)?; + response + .extensions_mut() + .insert(ResponseEgressDeadline::after(RESPONSE_EGRESS_BUDGET)); + Ok(response) +} + #[action] pub async fn proxy_demo(RequestContext(ctx): RequestContext) -> Result { let params: ProxyPath = ctx.path()?; - let proxy_handle = ctx.proxy_handle(); - let request = ctx.into_request(); - let base = env::var("API_BASE_URL").unwrap_or_else(|_| DEFAULT_PROXY_BASE.to_owned()); + let http_client = ctx.http_client(); + let base = ctx + .variable("API_BASE_URL") + .unwrap_or_else(|| DEFAULT_PROXY_BASE.to_owned()); + let request = ctx.into_request()?; let target = build_proxy_target(&base, ¶ms.rest, request.uri())?; - let proxy_request = ProxyRequest::from_request(request, target); + let outbound_request = outbound_policy(OutboundRequest::from_request(request, target)?); - if let Some(handle) = proxy_handle { - handle.forward(proxy_request).await + if let Some(client) = http_client { + client.send(outbound_request).await?.into_response() } else { proxy_not_available_response() } } +fn error_category(status: StatusCode) -> &'static str { + match status { + StatusCode::BAD_REQUEST => "bad_request", + StatusCode::BAD_GATEWAY => "bad_gateway", + StatusCode::GATEWAY_TIMEOUT => "gateway_timeout", + StatusCode::REQUEST_TIMEOUT => "request_timeout", + StatusCode::UNPROCESSABLE_ENTITY => "validation", + _ => "error", + } +} + +fn fanout_slot( + index: usize, + slot_result: Option, + termination: OutboundBatchTermination, +) -> Result { + match slot_result { + Some(slot) => { + let outcome = match slot.outcome { + Ok(response) => FanoutSlotOutcome::Response { + status: response.status().as_u16(), + }, + Err(error) => FanoutSlotOutcome::Error { + category: error_category(error.status()), + status: error.status().as_u16(), + }, + }; + Ok(FanoutSlot { + elapsed_ms: Some(slot.elapsed.as_millis()), + index, + outcome, + }) + } + None if termination == OutboundBatchTermination::Cutoff => Ok(FanoutSlot { + elapsed_ms: None, + index, + outcome: FanoutSlotOutcome::Unresolved, + }), + None if termination == OutboundBatchTermination::Completed => Err(EdgeError::internal( + IoError::other("completed outbound batch contained an unresolved slot"), + )), + None => Err(EdgeError::internal(IoError::other( + "outbound batch contained an unresolved slot for an unsupported termination reason", + ))), + } +} + +fn outbound_policy(request: OutboundRequest) -> OutboundRequest { + request + .cache_policy(OutboundCachePolicy::Bypass) + .max_decoder_bytes(MAX_DECODER_BYTES) + .max_brotli_window_bits(MAX_BROTLI_WINDOW_BITS) + .max_decoded_response_bytes(MAX_DECODED_RESPONSE_BYTES) + .max_encoded_response_bytes(MAX_ENCODED_RESPONSE_BYTES) + .max_request_body_bytes(MAX_OUTBOUND_REQUEST_BODY_BYTES) + .max_response_bytes(MAX_FINAL_RESPONSE_BYTES) + .max_chunk_bytes(NonZeroU64::new(MAX_RESPONSE_CHUNK_BYTES).unwrap_or(NonZeroU64::MIN)) + .max_response_header_bytes(MAX_RESPONSE_HEADER_BYTES) + .max_response_header_count(MAX_RESPONSE_HEADER_COUNT) + .timeout(OUTBOUND_REQUEST_TIMEOUT) +} + fn build_proxy_target(base: &str, rest: &str, original_uri: &Uri) -> Result { let mut target = base.trim_end_matches('/').to_owned(); let trimmed_rest = rest.trim_start_matches('/'); @@ -130,9 +310,7 @@ fn build_proxy_target(base: &str, rest: &str, original_uri: &Uri) -> Result Result { - let body = Body::text( - "proxy example is not enabled for this adapter build; enable a proxy-capable adapter", - ); + let body = Body::text("outbound HTTP is not enabled for this adapter build"); http::response_builder() .status(StatusCode::NOT_IMPLEMENTED) .header("content-type", "text/plain; charset=utf-8") @@ -140,6 +318,18 @@ fn proxy_not_available_response() -> Result { .map_err(EdgeError::internal) } +fn json_response(value: &T) -> Result +where + T: serde::Serialize, +{ + let body = Body::json(value).map_err(EdgeError::internal)?; + http::response_builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(body) + .map_err(EdgeError::internal) +} + fn text_response(status: StatusCode, message: impl Into) -> Result { http::response_builder() .status(status) @@ -169,7 +359,7 @@ pub async fn config_get(RequestContext(ctx): RequestContext) -> Result text_response(StatusCode::OK, value), + Some(value) => text_response(StatusCode::OK, value.as_str()), None => text_response( StatusCode::NOT_FOUND, format!("config key '{}' not found", params.name), @@ -224,7 +414,7 @@ pub async fn kv_note_put( let store = kv .named("cache") .ok_or_else(|| EdgeError::service_unavailable("KV store `cache` is not registered"))?; - let body = ctx.into_request().into_body(); + let body = ctx.into_request()?.into_body(); let body_bytes = body.into_bytes_bounded(MAX_BODY_SIZE).await?; store .put_bytes(&format!("note:{}", path.id), body_bytes) @@ -323,18 +513,27 @@ mod tests { use async_trait::async_trait; use edgezero_core::blob_envelope::BlobEnvelope; use edgezero_core::body::Body; - use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::config_store::{ + finish_bounded_config_read, BoundedStoreRead, ConfigStore, ConfigStoreError, + ConfigStoreHandle, + }; use edgezero_core::context::RequestContext; use edgezero_core::http::header::{HeaderName, HeaderValue}; - use edgezero_core::http::{request_builder, Method, StatusCode, Uri}; + use edgezero_core::http::{request_builder, HeaderMap, Method, StatusCode, Uri}; use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; + use edgezero_core::outbound::{ + HttpClient, OutboundBatch, OutboundBatchItem, OutboundHttpClient, OutboundRequestParts, + OutboundResponse, OutboundSlotResult, ResponseMode, + }; use edgezero_core::params::PathParams; - use edgezero_core::proxy::{ProxyClient, ProxyHandle, ProxyResponse}; use edgezero_core::response::IntoResponse as _; use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; use edgezero_core::store_registry::{ ConfigRegistry, ConfigStoreBinding, KvRegistry, StoreRegistry, }; + use edgezero_core::{ + BudgetSource, Deadline, MonotonicClock, MonotonicInstant, OutboundBatchDriverEvent, + }; use futures::executor::block_on; use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, Mutex}; @@ -346,14 +545,40 @@ mod tests { data: Mutex>, } - struct TestProxyClient; + #[derive(Clone, Default)] + struct TestOutboundClient { + clock: MonotonicClock, + } struct UnavailableConfigStore; #[async_trait::async_trait(?Send)] impl ConfigStore for MapConfigStore { - async fn get(&self, key: &str) -> Result, ConfigStoreError> { - Ok(self.0.get(key).cloned()) + async fn get( + &self, + key: &str, + ) -> Result, ConfigStoreError> { + Ok(self.0.get(key).cloned().map(Into::into)) + } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + finish_bounded_config_read( + Ok(self.0.get(key).cloned()), + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) } } @@ -420,30 +645,186 @@ mod tests { } #[async_trait(?Send)] - impl ProxyClient for TestProxyClient { - async fn send(&self, request: ProxyRequest) -> Result { - let (_method, uri, _headers, _body, _) = request.into_parts(); - assert!(uri.to_string().contains("status/201")); - Ok(ProxyResponse::new(StatusCode::CREATED, Body::empty())) + impl OutboundHttpClient for TestOutboundClient { + async fn send(&self, request: OutboundRequest) -> Result { + let parts = request.into_parts(); + assert_eq!(parts.method, Method::POST); + assert!(parts + .uri + .path_and_query() + .is_some_and(|value| value.as_str() == "/status/201?source=demo")); + assert!(parts.deadline.is_none()); + assert_outbound_policy(&parts); + response_for(parts, self.clock.clone()) + } + + fn start_batch_until( + &self, + requests: Vec, + observation_cutoff: Deadline, + ) -> OutboundBatch { + let mut observed_deadline = None; + let slot_count = requests.len(); + let mut results = Vec::with_capacity(slot_count); + for (index, request) in requests.into_iter().enumerate() { + let parts = request.into_parts(); + assert_outbound_policy(&parts); + let slot_deadline = parts.deadline.expect("batch deadline"); + if let Some(expected) = observed_deadline { + assert_eq!(slot_deadline.instant(), expected); + } else { + observed_deadline = Some(slot_deadline.instant()); + } + let elapsed = Duration::from_millis( + u64::try_from(index).expect("slot index").saturating_add(1), + ); + results.push(OutboundBatchItem::new( + index, + OutboundSlotResult::new(elapsed, response_for(parts, self.clock.clone())), + )); + } + if let Some(batch_deadline) = observed_deadline { + assert_eq!(batch_deadline, observation_cutoff.instant()); + } + OutboundBatch::from_driver( + slot_count, + stream::iter(results.into_iter().map(OutboundBatchDriverEvent::Item)), + ) } } #[async_trait::async_trait(?Send)] impl ConfigStore for UnavailableConfigStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { Err(ConfigStoreError::unavailable("backend offline")) } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + finish_bounded_config_read( + self.get(key).await, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) + } } struct FixedStore(String); #[async_trait(?Send)] impl ConfigStore for FixedStore { - async fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some(self.0.clone())) + async fn get( + &self, + _key: &str, + ) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone().into())) + } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + finish_bounded_config_read( + self.get(key).await, + clock, + deadline, + max_backend_bytes, + max_value_bytes, + ) } } + fn assert_outbound_policy(parts: &OutboundRequestParts) { + assert_eq!(parts.cache_policy, OutboundCachePolicy::Bypass); + assert_eq!(parts.timeout, Some(OUTBOUND_REQUEST_TIMEOUT)); + assert_eq!( + parts.max_request_body_bytes, + MAX_OUTBOUND_REQUEST_BODY_BYTES + ); + assert_eq!( + parts.max_encoded_response_bytes, + Some(MAX_ENCODED_RESPONSE_BYTES) + ); + assert_eq!( + parts.max_decoded_response_bytes, + Some(MAX_DECODED_RESPONSE_BYTES) + ); + assert_eq!( + parts.response_mode, + ResponseMode::Buffered { + max_bytes: MAX_FINAL_RESPONSE_BYTES, + } + ); + assert_eq!( + parts.max_response_header_bytes, + Some(MAX_RESPONSE_HEADER_BYTES) + ); + assert_eq!( + parts.max_response_header_count, + Some(MAX_RESPONSE_HEADER_COUNT) + ); + assert_eq!( + parts.max_chunk_bytes, + Some(NonZeroU64::new(MAX_RESPONSE_CHUNK_BYTES).unwrap_or(NonZeroU64::MIN)) + ); + assert_eq!(parts.max_brotli_window_bits, MAX_BROTLI_WINDOW_BITS); + assert_eq!(parts.max_decoder_bytes, MAX_DECODER_BYTES); + } + + fn response_for( + parts: OutboundRequestParts, + clock: MonotonicClock, + ) -> Result { + if parts.uri.path() == "/fail" { + return Err(EdgeError::gateway_timeout_caused( + "provider URL https://user:token@example.invalid", + BudgetSource::RequestDeadline, + )); + } + + let status = match parts.uri.path() { + "/status/201" => StatusCode::CREATED, + "/status/204" => StatusCode::NO_CONTENT, + _ => StatusCode::OK, + }; + let mut headers = HeaderMap::new(); + headers.insert("x-outbound-test", HeaderValue::from_static("preserved")); + let body = if status == StatusCode::NO_CONTENT { + Body::empty() + } else { + Body::text("outbound-response") + }; + Ok(OutboundResponse::new( + parts.method, + status, + headers, + body, + clock, + edgezero_core::ContentTypeProvenance::Unknown, + )) + } + #[test] fn introspection_routes_are_wired() { let router = crate::build_router(); @@ -784,7 +1165,7 @@ mod tests { #[test] fn echo_json_formats_payload() { - let ctx = context_with_json("/echo", r#"{"name":"Edge"}"#); + let ctx = context_with_json("/echo", &serde_json::json!({"name": "Edge"}).to_string()); let response = block_on(echo_json(ctx)) .expect("handler ok") .into_response() @@ -905,15 +1286,15 @@ mod tests { } #[test] - fn proxy_demo_uses_injected_handle() { + fn app_demo_outbound_client_preserves_method_and_uri() { let mut request = request_builder() - .method(Method::GET) - .uri("/proxy/status/201") - .body(Body::empty()) + .method(Method::POST) + .uri("/proxy/status/201?source=demo") + .body(Body::text("request-body")) .expect("request"); request .extensions_mut() - .insert(ProxyHandle::with_client(TestProxyClient)); + .insert(HttpClient::with_client(TestOutboundClient::default())); let mut params = HashMap::new(); params.insert("rest".to_owned(), "status/201".to_owned()); @@ -921,15 +1302,125 @@ mod tests { let response = block_on(proxy_demo(ctx)).expect("response"); assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(response.headers()["x-outbound-test"], "preserved"); + assert_eq!( + response + .into_body() + .into_bytes() + .expect("buffered") + .as_ref(), + b"outbound-response" + ); } #[test] - fn proxy_demo_without_handle_returns_placeholder() { + fn app_demo_outbound_client_retains_its_clock_in_responses() { + let start = MonotonicInstant::now(); + let observed = Arc::new(Mutex::new(start)); + let clock_now = Arc::clone(&observed); + let client = TestOutboundClient { + clock: MonotonicClock::new(move || *clock_now.lock().expect("clock lock")), + }; + let request = outbound_policy( + OutboundRequest::post("https://example.com/status/201?source=demo").expect("request"), + ); + let response = block_on(client.send(request)).expect("response"); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + *observed.lock().expect("clock lock") = deadline; + + let error = block_on(response.into_bytes_bounded_until(64, Deadline::at_instant(deadline))) + .expect_err("retained clock reached deadline"); + + assert!(matches!(error, EdgeError::GatewayTimeout { .. })); + } + + #[test] + fn fanout_reports_positional_elapsed_and_typed_outcomes() { + let ctx = fanout_context( + &serde_json::json!({"paths": ["status/200", "status/204", "fail"]}).to_string(), + ); + let response = block_on(fanout(ctx)).expect("fanout response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], "application/json"); + assert!(response + .extensions() + .get::() + .is_some()); + assert!( + !String::from_utf8_lossy(response.body().as_bytes().expect("buffered")) + .contains("token@example.invalid") + ); + + let payload: serde_json::Value = response.body().to_json().expect("json"); + assert_eq!(payload[0]["index"], 0_i64); + assert_eq!(payload[0]["elapsed_ms"], 1_i64); + assert_eq!(payload[0]["outcome"]["kind"], "response"); + assert_eq!(payload[0]["outcome"]["status"], 200_i64); + assert_eq!(payload[1]["index"], 1_i64); + assert_eq!(payload[1]["elapsed_ms"], 2_i64); + assert_eq!(payload[1]["outcome"]["status"], 204_i64); + assert_eq!(payload[2]["index"], 2_i64); + assert_eq!(payload[2]["elapsed_ms"], 3_i64); + assert_eq!(payload[2]["outcome"]["kind"], "error"); + assert_eq!(payload[2]["outcome"]["category"], "gateway_timeout"); + assert_eq!(payload[2]["outcome"]["status"], 504_i64); + } + + #[test] + fn outbound_fixture_settles_no_content_body_at_source() { + let request = OutboundRequest::get("https://example.com/status/204").expect("request"); + let response = + response_for(request.into_parts(), MonotonicClock::default()).expect("response"); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!( + response + .into_response() + .expect("portable response") + .into_body() + .into_bytes() + .expect("buffered"), + Bytes::new() + ); + } + + #[test] + fn fanout_with_empty_input_returns_an_empty_array() { + let ctx = fanout_context(&serde_json::json!({"paths": []}).to_string()); + let response = block_on(fanout(ctx)).expect("fanout response"); + let payload: serde_json::Value = response.body().to_json().expect("json"); + assert_eq!(payload, serde_json::json!([])); + } + + #[test] + fn fanout_rejects_more_than_eight_paths() { + let ctx = fanout_context( + &serde_json::json!({"paths": ["1", "2", "3", "4", "5", "6", "7", "8", "9"]}) + .to_string(), + ); + let error = block_on(fanout(ctx)).expect_err("oversized fanout must fail"); + assert_eq!(error.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + #[test] + fn proxy_demo_without_client_returns_placeholder() { let ctx = context_with_params("/proxy/status/200", &[("rest", "status/200")]); let response = block_on(proxy_demo(ctx)).expect("response"); assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); } + fn fanout_context(json: &str) -> RequestContext { + let mut request = request_builder() + .method(Method::POST) + .uri("/fanout") + .body(Body::from(json)) + .expect("request"); + request + .extensions_mut() + .insert(HttpClient::with_client(TestOutboundClient::default())); + RequestContext::new(request, PathParams::default()) + } + #[test] fn root_returns_static_body() { let ctx = empty_context("/"); @@ -983,7 +1474,8 @@ mod tests { .to_vec(), ) .expect("utf8"); - assert!(body.contains("required secret is not configured")); + assert!(body.contains("internal server error")); + assert!(!body.contains("required secret is not configured")); assert!(!body.contains("SMOKE_SECRET_MISSING")); } diff --git a/examples/app-demo/crates/app-demo-core/src/lib.rs b/examples/app-demo/crates/app-demo-core/src/lib.rs index e2d4beba3..683fc09c6 100644 --- a/examples/app-demo/crates/app-demo-core/src/lib.rs +++ b/examples/app-demo/crates/app-demo-core/src/lib.rs @@ -8,7 +8,142 @@ pub mod config; // internally; pub visibility is purely additive. pub mod handlers; +use std::error::Error; +use std::fmt; +#[cfg(test)] +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use edgezero_core::app::App as EdgeZeroApp; +use edgezero_core::http::StatusCode; +use edgezero_core::{ + AdmissionDecision, BufferedIngressResponse, ConfigExtractionLimits, + DetachedResponseEgressDecision, EdgeError, IngressGrant, MemoryCeiling, MemoryCeilingScope, + MemoryEnvelope, MemoryEnvelopeValidation, MemoryEnvelopeValidationError, MemoryResource, + PlatformMetadata, ResponseEgressCompletion, ResponseEgressResource, + ResponseEgressResourceInstallError, RouteResolution, DEFAULT_RESPONSE_WRITE_BUDGET, +}; + +const DEFAULT_INGRESS_READ_BUDGET: Duration = Duration::from_secs(30); +const FALLBACK_INGRESS_BODY_BYTES: usize = 4 * 1024; +const FALLBACK_INGRESS_READ_BUDGET: Duration = Duration::from_secs(5); +const OUTBOUND_INGRESS_READ_BUDGET: Duration = Duration::from_secs(10); +const EXPECTED_FIXED_MEMORY_BYTES: u64 = 4 * 1024 * 1024; +const EXPECTED_PER_LIVE_REQUEST_BYTES: u64 = 1024 * 1024; +const EXPECTED_SEPARATE_STACK_BYTES: u64 = 256 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PlatformMemoryStartupError { + Exceeds { + available_bytes: u64, + required_bytes: u64, + resource: MemoryResource, + }, + UnsupportedValidationOutcome, + Validation { + source: MemoryEnvelopeValidationError, + }, +} + +impl fmt::Display for PlatformMemoryStartupError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Self::Exceeds { + available_bytes, + required_bytes, + resource, + } => { + let resource_label = match resource { + MemoryResource::Primary => "primary memory", + MemoryResource::SeparateStack => "separate stack", + _ => "unrecognized memory resource", + }; + write!( + f, + "application memory envelope exceeds {resource_label}: required {required_bytes} bytes, available {available_bytes} bytes" + ) + } + Self::UnsupportedValidationOutcome => { + f.write_str("unsupported platform memory validation outcome") + } + Self::Validation { source } => source.fmt(f), + } + } +} + +#[expect( + clippy::missing_trait_methods, + reason = "the typed validation source is the only nested startup error" +)] +impl Error for PlatformMemoryStartupError { + #[expect( + clippy::pattern_type_mismatch, + reason = "match ergonomics preserve the borrowed validation source" + )] + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Validation { source } => Some(source), + Self::Exceeds { .. } | Self::UnsupportedValidationOutcome => None, + } + } +} + +#[derive(Debug)] +struct AdmissionLease { + #[cfg(test)] + release_counter: Option>, + response_resource: ResponseEgressResource, + route_class: Option, +} + +impl AdmissionLease { + fn install_response_permit(&self) -> Result<(), ResponseEgressResourceInstallError> { + let permit = ResponsePermit { + #[cfg(test)] + release_counter: self.release_counter.clone(), + route_class: self.route_class.clone(), + }; + self.response_resource.install(permit) + } + + #[cfg(test)] + fn observe_response_permit_releases(&mut self, counter: Arc) { + self.release_counter = Some(counter); + } +} + +/// Demonstration stand-in for an application semaphore permit. +#[derive(Debug)] +struct ResponsePermit { + #[cfg(test)] + release_counter: Option>, + route_class: Option, +} + +impl ResponsePermit { + fn new(route_class: Option) -> Self { + Self { + #[cfg(test)] + release_counter: None, + route_class, + } + } + + fn release(self) { + drop(self); + } +} + +impl Drop for ResponsePermit { + fn drop(&mut self) { + drop(self.route_class.take()); + #[cfg(test)] + if let Some(counter) = self.release_counter.take() { + counter.fetch_add(1, Ordering::SeqCst); + } + } +} /// App-owned shared state for the `app!(..., state = ...)` demonstration, /// handed to handlers via `State>`. @@ -18,10 +153,129 @@ pub struct DemoState { pub greeting: String, } +/// Installs request-lifecycle policy before any adapter begins polling a body. +fn configure_app(app: &mut EdgeZeroApp) -> Result<(), EdgeError> { + platform_memory_startup(app.platform()).map_err(EdgeError::internal)?; + app.set_config_extraction_limits(ConfigExtractionLimits::default())?; + app.set_detached_response_egress_decision_factory(|head| { + let permit = ResponsePermit::new(Some(format!("detached:{}", head.error_kind()))); + let completion = ResponseEgressCompletion::new(move |_report| { + permit.release(); + }); + DetachedResponseEgressDecision::Send { + completion, + deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET), + } + }); + app.set_ingress_admission_policy(|head| { + if let RouteResolution::Matched(metadata) = head.route_resolution().clone() { + let route_class = metadata.class().map(str::to_owned); + let read_budget = if route_class.as_deref() == Some("outbound") { + OUTBOUND_INGRESS_READ_BUDGET + } else { + DEFAULT_INGRESS_READ_BUDGET + }; + let permit = ResponsePermit::new(route_class.clone()); + let (lease, completion) = response_lifecycle(route_class, permit); + AdmissionDecision::Admit { + completion, + grant: IngressGrant::new(lease), + read_deadline: head.read_deadline_after(read_budget), + } + } else { + let (lease, completion) = response_lifecycle(None, ResponsePermit::new(None)); + AdmissionDecision::ReadBodyBeforeFallback { + completion, + grant: IngressGrant::new(lease), + max_body_bytes: FALLBACK_INGRESS_BODY_BYTES, + read_deadline: head.read_deadline_after(FALLBACK_INGRESS_READ_BUDGET), + on_exceeded: BufferedIngressResponse::text( + StatusCode::BAD_REQUEST, + "request body too large\n", + ), + on_timeout: BufferedIngressResponse::text( + StatusCode::REQUEST_TIMEOUT, + "request timeout\n", + ), + } + } + }); + Ok(()) +} + +fn platform_memory_validation( + platform: PlatformMetadata, +) -> Result { + let known_ceiling = platform.memory_ceiling().value(); + let scope = known_ceiling.map_or(MemoryCeilingScope::PerExecution, MemoryCeiling::scope); + let (fixed_bytes, separate_stack_bytes) = + match known_ceiling.and_then(MemoryCeiling::stack_bytes) { + Some(_) => ( + EXPECTED_FIXED_MEMORY_BYTES, + Some(EXPECTED_SEPARATE_STACK_BYTES), + ), + None => ( + EXPECTED_FIXED_MEMORY_BYTES + EXPECTED_SEPARATE_STACK_BYTES, + None, + ), + }; + let envelope = MemoryEnvelope::new( + scope, + fixed_bytes, + EXPECTED_PER_LIVE_REQUEST_BYTES, + separate_stack_bytes, + ); + platform.validate_memory_envelope(envelope) +} + +fn platform_memory_startup(platform: PlatformMetadata) -> Result<(), PlatformMemoryStartupError> { + let validation = platform_memory_validation(platform) + .map_err(|source| PlatformMemoryStartupError::Validation { source })?; + match validation { + MemoryEnvelopeValidation::Fits { .. } => Ok(()), + MemoryEnvelopeValidation::Indeterminate { .. } => { + // Unknown facts remain indeterminate; startup may continue without claiming `Fits` + // or adding an application concurrency gate. + Ok(()) + } + MemoryEnvelopeValidation::Exceeds { + available_bytes, + required_bytes, + resource, + } => Err(PlatformMemoryStartupError::Exceeds { + available_bytes, + required_bytes, + resource, + }), + _ => Err(PlatformMemoryStartupError::UnsupportedValidationOutcome), + } +} + +/// Composes a held permit with a core-owned late-bound response resource. +fn response_lifecycle( + route_class: Option, + permit: ResponsePermit, +) -> (AdmissionLease, ResponseEgressCompletion) { + let static_completion = ResponseEgressCompletion::new(move |_report| { + permit.release(); + }); + let (response_resource, late_bound_completion) = ResponseEgressCompletion::late_bound(); + ( + AdmissionLease { + #[cfg(test)] + release_counter: None, + response_resource, + route_class, + }, + static_completion.join(late_bound_completion), + ) +} + /// Returns the shared app state, referenced by `app!(..., state = crate::app_state())`. /// /// IMPORTANT: `app!(state = )` emits this call inside the macro-generated -/// `build_router()`, which every adapter's `run_app` invokes via `A::build_app()` +/// `build_router()`, which every adapter's `run_app` invokes via +/// `EdgeZeroApp::build::(platform)` /// — once at startup for long-lived runtimes (Axum), but **once per request** on /// default Fastly, Cloudflare and Spin entry points. Fastly starts a fresh Wasm /// instance by default; Cloudflare and Spin also rebuild the app per request. @@ -39,4 +293,487 @@ pub fn app_state() -> Arc { })) } -edgezero_core::app!("../../edgezero.toml", state = crate::app_state()); +edgezero_core::app!( + "../../edgezero.toml", + configure = crate::configure_app, + state = crate::app_state() +); + +#[cfg(test)] +mod lifecycle_tests { + use std::num::NonZeroU32; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + use bytes::Bytes; + use edgezero_core::app::App as EdgeZeroApp; + use edgezero_core::body::Body; + use edgezero_core::error::EdgeError; + use edgezero_core::http::{request_builder, HeaderMap, Method, Response, StatusCode, Version}; + use edgezero_core::ingress::{ + AdmissionDecision, IngressBeginOutcome, IngressDispatchOutcome, IngressGrant, + IngressHeadParts, + }; + use edgezero_core::response_egress::ResponseEgressEnvelope; + use edgezero_core::router::RouteResolution; + use edgezero_core::time::MonotonicInstant; + use futures::executor::block_on; + use futures::stream::iter; + + #[test] + fn manifest_route_classes_reach_route_resolution() { + let resolved = crate::build_router().resolve(&Method::GET, "/proxy/status/200"); + let RouteResolution::Matched(metadata) = resolved.resolution().clone() else { + panic!("proxy route must resolve"); + }; + assert_eq!(metadata.class(), Some("outbound")); + } + + #[test] + fn platform_memory_validation_preserves_unknown_facts() { + let validation = + super::platform_memory_validation(edgezero_core::PlatformMetadata::default()) + .expect("valid demo envelope"); + + let edgezero_core::MemoryEnvelopeValidation::Indeterminate { reasons, .. } = validation + else { + panic!("unknown platform facts must prevent complete validation"); + }; + assert_eq!(reasons.len(), 3); + } + + fn known_platform( + ceiling: edgezero_core::MemoryCeiling, + population: NonZeroU32, + ) -> edgezero_core::PlatformMetadata { + let source = edgezero_core::PlatformResourceSource::PlatformLimit { + provider: "test platform", + }; + edgezero_core::PlatformMetadata::new( + edgezero_core::PlatformFact::known(ceiling, source), + edgezero_core::PlatformFact::known( + edgezero_core::InboundRequestPopulationBound::new(population), + source, + ), + edgezero_core::PlatformFact::known( + edgezero_core::HostIngressMemoryAccounting::OutsideCeiling, + source, + ), + ) + } + + #[test] + fn platform_memory_validation_accounts_for_both_stack_shapes() { + let primary_without_separate_stack = super::EXPECTED_FIXED_MEMORY_BYTES + + super::EXPECTED_SEPARATE_STACK_BYTES + + super::EXPECTED_PER_LIVE_REQUEST_BYTES; + let without_separate_stack = super::platform_memory_validation(known_platform( + edgezero_core::MemoryCeiling::new( + primary_without_separate_stack, + edgezero_core::MemoryCeilingScope::PerExecution, + None, + ), + NonZeroU32::MIN, + )) + .expect("valid envelope without separate stack"); + assert!(matches!( + without_separate_stack, + edgezero_core::MemoryEnvelopeValidation::Fits { + required_primary_bytes, + required_separate_stack_bytes: None, + .. + } if required_primary_bytes == primary_without_separate_stack + )); + + let primary_with_separate_stack = + super::EXPECTED_FIXED_MEMORY_BYTES + super::EXPECTED_PER_LIVE_REQUEST_BYTES; + let with_separate_stack = super::platform_memory_validation(known_platform( + edgezero_core::MemoryCeiling::new( + primary_with_separate_stack, + edgezero_core::MemoryCeilingScope::PerExecution, + Some(super::EXPECTED_SEPARATE_STACK_BYTES), + ), + NonZeroU32::MIN, + )) + .expect("valid envelope with separate stack"); + assert!(matches!( + with_separate_stack, + edgezero_core::MemoryEnvelopeValidation::Fits { + required_primary_bytes, + required_separate_stack_bytes: Some(required_stack_bytes), + .. + } if required_primary_bytes == primary_with_separate_stack + && required_stack_bytes == super::EXPECTED_SEPARATE_STACK_BYTES + )); + } + + #[test] + fn platform_memory_startup_accepts_fit() { + let required_primary = + super::EXPECTED_FIXED_MEMORY_BYTES + super::EXPECTED_PER_LIVE_REQUEST_BYTES; + let platform = known_platform( + edgezero_core::MemoryCeiling::new( + required_primary, + edgezero_core::MemoryCeilingScope::PerExecution, + Some(super::EXPECTED_SEPARATE_STACK_BYTES), + ), + NonZeroU32::MIN, + ); + + assert_eq!(super::platform_memory_startup(platform), Ok(())); + } + + #[test] + fn platform_memory_startup_preserves_primary_excess() { + let platform = known_platform( + edgezero_core::MemoryCeiling::new( + 1, + edgezero_core::MemoryCeilingScope::PerExecution, + None, + ), + NonZeroU32::MIN, + ); + + assert!(matches!( + super::platform_memory_startup(platform), + Err(super::PlatformMemoryStartupError::Exceeds { + available_bytes: 1, + required_bytes, + resource: edgezero_core::MemoryResource::Primary, + }) if required_bytes == super::EXPECTED_FIXED_MEMORY_BYTES + + super::EXPECTED_SEPARATE_STACK_BYTES + + super::EXPECTED_PER_LIVE_REQUEST_BYTES + )); + } + + #[test] + fn platform_memory_startup_preserves_stack_excess() { + let platform = known_platform( + edgezero_core::MemoryCeiling::new( + super::EXPECTED_FIXED_MEMORY_BYTES + super::EXPECTED_PER_LIVE_REQUEST_BYTES, + edgezero_core::MemoryCeilingScope::PerExecution, + Some(1), + ), + NonZeroU32::MIN, + ); + + assert_eq!( + super::platform_memory_startup(platform), + Err(super::PlatformMemoryStartupError::Exceeds { + available_bytes: 1, + required_bytes: super::EXPECTED_SEPARATE_STACK_BYTES, + resource: edgezero_core::MemoryResource::SeparateStack, + }) + ); + } + + #[test] + fn platform_memory_startup_keeps_indeterminate_distinct_from_fit() { + assert_eq!( + super::platform_memory_startup(edgezero_core::PlatformMetadata::default()), + Ok(()) + ); + } + + #[test] + fn platform_memory_startup_preserves_validation_error() { + let platform = known_platform( + edgezero_core::MemoryCeiling::new( + u64::MAX, + edgezero_core::MemoryCeilingScope::PerExecution, + Some(super::EXPECTED_SEPARATE_STACK_BYTES), + ), + NonZeroU32::new(2).expect("non-zero population"), + ); + + assert!(matches!( + super::platform_memory_startup(platform), + Err(super::PlatformMemoryStartupError::Validation { + source: + edgezero_core::MemoryEnvelopeValidationError::NonUnitPerExecutionPopulation { + max_live_requests, + }, + }) if max_live_requests.get() == 2 + )); + } + + #[test] + fn configured_admission_uses_finite_class_aware_deadlines() { + let app = EdgeZeroApp::build::(edgezero_core::PlatformMetadata::default()) + .expect("configured app"); + let start = MonotonicInstant::now(); + + let outbound = begin_ingress(&app, "/proxy/status/200", start); + assert_eq!( + outbound.read_deadline().instant(), + start + .checked_add(Duration::from_secs(10)) + .expect("deadline") + ); + + let health = begin_ingress(&app, "/", start); + assert_eq!( + health.read_deadline().instant(), + start + .checked_add(Duration::from_secs(30)) + .expect("deadline") + ); + + let fallback = begin_ingress(&app, "/missing", start); + assert_eq!( + fallback.read_deadline().instant(), + start.checked_add(Duration::from_secs(5)).expect("deadline") + ); + } + + #[test] + fn admission_handler_consumes_the_typed_grant_once() { + let app = EdgeZeroApp::build::(edgezero_core::PlatformMetadata::default()) + .expect("configured app"); + let start = MonotonicInstant::now(); + let prepared = begin_ingress(&app, "/admission", start); + let request = request_builder() + .method(Method::GET) + .uri("/admission") + .body(Body::empty()) + .expect("request"); + + let response = complete_envelope(block_on(app.dispatch_admitted(prepared, request))); + let payload: serde_json::Value = response.body().to_json().expect("json"); + assert_eq!(payload["route_class"], "diagnostic"); + assert_eq!(payload["grant_consumed_once"], true); + } + + #[test] + fn late_bound_permit_lives_until_terminal_egress() { + let releases = Arc::new(AtomicUsize::new(0)); + let policy_releases = Arc::clone(&releases); + let mut app = EdgeZeroApp::build::(edgezero_core::PlatformMetadata::default()) + .expect("configured app"); + app.set_ingress_admission_policy(move |head| { + let RouteResolution::Matched(metadata) = head.route_resolution().clone() else { + panic!("admission route must resolve"); + }; + let route_class = metadata.class().map(str::to_owned); + let permit = super::ResponsePermit::new(route_class.clone()); + let (mut lease, completion) = super::response_lifecycle(route_class, permit); + lease.observe_response_permit_releases(Arc::clone(&policy_releases)); + AdmissionDecision::Admit { + completion, + grant: IngressGrant::new(lease), + read_deadline: head.read_deadline_after(Duration::from_secs(30)), + } + }); + + let prepared = begin_ingress(&app, "/admission", MonotonicInstant::now()); + let request = request_builder() + .method(Method::GET) + .uri("/admission") + .body(Body::empty()) + .expect("request"); + let envelope = block_on(app.dispatch_admitted(prepared, request)); + + assert_eq!(releases.load(Ordering::SeqCst), 0); + let response = complete_envelope(envelope); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(releases.load(Ordering::SeqCst), 1); + } + + #[test] + fn composed_response_completion() { + let static_effects = Arc::new(AtomicUsize::new(0)); + let late_bound_effects = Arc::new(AtomicUsize::new(0)); + let static_effects_for_policy = Arc::clone(&static_effects); + let late_bound_effects_for_policy = Arc::clone(&late_bound_effects); + let mut app = EdgeZeroApp::build::(edgezero_core::PlatformMetadata::default()) + .expect("configured app"); + app.set_ingress_admission_policy(move |head| { + let static_permit = super::ResponsePermit { + release_counter: Some(Arc::clone(&static_effects_for_policy)), + route_class: None, + }; + let (mut lease, completion) = super::response_lifecycle(None, static_permit); + lease.observe_response_permit_releases(Arc::clone(&late_bound_effects_for_policy)); + lease + .install_response_permit() + .expect("install late-bound response permit"); + AdmissionDecision::Admit { + completion, + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(Duration::from_secs(30)), + } + }); + + let prepared_ingress = begin_ingress(&app, "/", MonotonicInstant::now()); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .expect("request"); + let envelope = block_on(app.dispatch_admitted(prepared_ingress, request)); + + assert_eq!(static_effects.load(Ordering::SeqCst), 0); + assert_eq!(late_bound_effects.load(Ordering::SeqCst), 0); + let Ok((prepared_response, _, mut attempt, clock)) = envelope.begin() else { + panic!("response egress begin"); + }; + assert_eq!(static_effects.load(Ordering::SeqCst), 0); + assert_eq!(late_bound_effects.load(Ordering::SeqCst), 0); + + assert!(attempt.begin_writing()); + assert_eq!(static_effects.load(Ordering::SeqCst), 0); + assert_eq!(late_bound_effects.load(Ordering::SeqCst), 0); + + let observed_at = clock.now(); + assert!(attempt.complete(observed_at)); + assert_eq!(static_effects.load(Ordering::SeqCst), 1); + assert_eq!(late_bound_effects.load(Ordering::SeqCst), 1); + + assert!(!attempt.complete(observed_at)); + assert_eq!(static_effects.load(Ordering::SeqCst), 1); + assert_eq!(late_bound_effects.load(Ordering::SeqCst), 1); + drop(attempt); + + assert_eq!(static_effects.load(Ordering::SeqCst), 1); + assert_eq!(late_bound_effects.load(Ordering::SeqCst), 1); + assert_eq!(prepared_response.into_response().status(), StatusCode::OK); + } + + #[test] + fn configured_admission_preserves_fallback_status_at_exact_cap() { + let app = EdgeZeroApp::build::(edgezero_core::PlatformMetadata::default()) + .expect("configured app"); + for (method, path, expected) in [ + (Method::POST, "/missing", StatusCode::NOT_FOUND), + (Method::POST, "/", StatusCode::METHOD_NOT_ALLOWED), + ] { + let request = request_builder() + .method(method) + .uri(path) + .body(Body::stream(iter([Bytes::from(vec![b'a'; 4_096])]))) + .expect("request"); + let response = complete_dispatch( + block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + edgezero_core::IngressHeadAccounting::HostManaged, + edgezero_core::IngressFraming::HostManaged, + )) + .expect("dispatch"), + ); + assert_eq!(response.status(), expected); + } + } + + #[test] + fn configured_admission_returns_exact_overflow_response_for_both_fallbacks() { + let app = EdgeZeroApp::build::(edgezero_core::PlatformMetadata::default()) + .expect("configured app"); + for (method, path) in [(Method::POST, "/missing"), (Method::POST, "/")] { + let request = request_builder() + .method(method) + .uri(path) + .body(Body::stream(iter([ + Bytes::from(vec![b'a'; 4_096]), + Bytes::from_static(b"b"), + ]))) + .expect("request"); + let response = complete_dispatch( + block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + edgezero_core::IngressHeadAccounting::HostManaged, + edgezero_core::IngressFraming::HostManaged, + )) + .expect("dispatch"), + ); + + assert_plain_text_response( + &response, + StatusCode::BAD_REQUEST, + b"request body too large\n", + ); + } + } + + #[test] + fn configured_admission_returns_exact_timeout_response() { + let app = EdgeZeroApp::build::(edgezero_core::PlatformMetadata::default()) + .expect("configured app"); + let request = request_builder() + .method(Method::POST) + .uri("/missing") + .body(Body::from_stream(iter([Err::( + EdgeError::request_timeout("adapter read deadline exceeded"), + )]))) + .expect("request"); + let response = complete_dispatch( + block_on(app.dispatch_ingress( + request, + MonotonicInstant::now(), + edgezero_core::IngressHeadAccounting::HostManaged, + edgezero_core::IngressFraming::HostManaged, + )) + .expect("dispatch"), + ); + + assert_plain_text_response(&response, StatusCode::REQUEST_TIMEOUT, b"request timeout\n"); + } + + fn assert_plain_text_response(response: &Response, status: StatusCode, body: &[u8]) { + let expected_length = body.len().to_string(); + assert_eq!(response.status(), status); + assert_eq!( + response + .headers() + .get("content-type") + .expect("content type"), + "text/plain; charset=utf-8" + ); + assert_eq!( + response + .headers() + .get("content-length") + .expect("content length") + .to_str() + .expect("ASCII content length"), + expected_length + ); + assert_eq!(response.body().as_bytes().expect("buffered body"), body); + } + + fn complete_dispatch(outcome: IngressDispatchOutcome) -> Response { + let IngressDispatchOutcome::Response(envelope) = outcome else { + panic!("expected response"); + }; + complete_envelope(*envelope) + } + + fn complete_envelope(envelope: ResponseEgressEnvelope) -> Response { + let Ok((prepared, _, mut attempt, clock)) = envelope.begin() else { + panic!("response egress begin"); + }; + assert!(attempt.begin_writing()); + assert!(attempt.complete(clock.now())); + prepared.into_response() + } + + fn begin_ingress( + app: &EdgeZeroApp, + path: &str, + start: MonotonicInstant, + ) -> edgezero_core::PreparedIngress { + let head = IngressHeadParts::new( + Method::GET, + path.parse().expect("URI"), + Version::HTTP_11, + HeaderMap::new(), + ); + match app.begin_ingress(head, start).expect("begin ingress") { + IngressBeginOutcome::Admitted(prepared) => prepared, + IngressBeginOutcome::Refused(_) => panic!("demo policy must admit request"), + IngressBeginOutcome::Aborted | _ => panic!("unknown admission outcome"), + } + } +} diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index 123e59030..a5d4893cd 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -7,12 +7,19 @@ version = "0.1.0" kind = "http" entry = "crates/app-demo-core" +[capabilities] +optional = ["outbound-http"] + +[capabilities.outbound] +hosts = ["https://*:*"] + [[triggers.http]] id = "root" path = "/" methods = ["GET"] handler = "app_demo_core::handlers::root" adapters = ["axum", "cloudflare", "fastly", "spin"] +class = "health" description = "Default health-check endpoint" [[triggers.http]] @@ -52,6 +59,14 @@ methods = ["POST"] handler = "app_demo_core::handlers::echo_json" adapters = ["axum", "cloudflare", "fastly", "spin"] +[[triggers.http]] +id = "admission" +path = "/admission" +methods = ["GET"] +handler = "app_demo_core::handlers::admission" +adapters = ["axum", "cloudflare", "fastly", "spin"] +class = "diagnostic" +description = "Confirms route-aware admission and one-time grant consumption" [[triggers.http]] id = "proxy_demo" @@ -59,6 +74,16 @@ path = "/proxy/{*rest}" methods = ["GET", "POST"] handler = "app_demo_core::handlers::proxy_demo" adapters = ["axum", "cloudflare", "fastly", "spin"] +class = "outbound" + +[[triggers.http]] +id = "fanout" +path = "/fanout" +methods = ["POST"] +handler = "app_demo_core::handlers::fanout" +adapters = ["axum", "cloudflare", "fastly", "spin"] +class = "outbound" +description = "Concurrent positional outbound batch with per-slot elapsed time" [[triggers.http]] id = "config_get" @@ -117,6 +142,7 @@ path = "/_app-demo/manifest" methods = ["GET"] handler = "edgezero_core::introspection::manifest" adapters = ["axum", "cloudflare", "fastly", "spin"] +class = "diagnostic" description = "App manifest as JSON" [[triggers.http]] @@ -125,6 +151,7 @@ path = "/_app-demo/config" methods = ["GET"] handler = "edgezero_core::introspection::config" adapters = ["axum", "cloudflare", "fastly", "spin"] +class = "diagnostic" description = "Effective app config (secret-safe)" [[triggers.http]] @@ -133,6 +160,7 @@ path = "/_app-demo/routes" methods = ["GET"] handler = "edgezero_core::introspection::routes" adapters = ["axum", "cloudflare", "fastly", "spin"] +class = "diagnostic" description = "Registered route table" # -- Secrets demo route -------------------------------------------------------- @@ -163,14 +191,34 @@ default = "app_config" ids = ["default"] default = "default" -# [environment] -# -# [[environment.variables]] -# name = "API_BASE_URL" -# description = "Base URL for upstream API calls" -# env = "API_BASE_URL" -# value = "https://example.com/api" -# +[environment] + +[[environment.variables]] +name = "API_BASE_URL" +description = "Base URL for upstream API calls" +env = "API_BASE_URL" + +# Native pre-admission HTTP/1 resource limits; parent environment values win. +[[environment.variables]] +name = "EDGEZERO__ADAPTER__INGRESS__MAX_CONNECTIONS" +value = "256" +adapters = ["axum"] + +[[environment.variables]] +name = "EDGEZERO__ADAPTER__INGRESS__MAX_RAW_HEAD_BYTES" +value = "65536" +adapters = ["axum"] + +[[environment.variables]] +name = "EDGEZERO__ADAPTER__INGRESS__MAX_HEADER_COUNT" +value = "100" +adapters = ["axum"] + +[[environment.variables]] +name = "EDGEZERO__ADAPTER__INGRESS__HEAD_READ_TIMEOUT_MS" +value = "10000" +adapters = ["axum"] + # [[environment.secrets]] # name = "API_TOKEN" # description = "Secret token used when calling the upstream API" diff --git a/scripts/brotli_dependency_contract.json b/scripts/brotli_dependency_contract.json new file mode 100644 index 000000000..bbb4e6b30 --- /dev/null +++ b/scripts/brotli_dependency_contract.json @@ -0,0 +1,37 @@ +[ + [ + "alloc-no-stdlib", + "2.0.4", + "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + ], + [ + "alloc-stdlib", + "0.2.4", + "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" + ], + [ + "async-compression", + "0.4.43", + "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" + ], + [ + "brotli", + "8.0.4", + "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" + ], + [ + "brotli-decompressor", + "5.0.1", + "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" + ], + [ + "compression-codecs", + "0.4.38", + "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" + ], + [ + "compression-core", + "0.4.32", + "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + ] +] diff --git a/scripts/check_adapter_feature_matrix.sh b/scripts/check_adapter_feature_matrix.sh new file mode 100644 index 000000000..87d78a84f --- /dev/null +++ b/scripts/check_adapter_feature_matrix.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +adapter="$1" +target="$2" + +case "${adapter}:${target}" in + axum:native | cloudflare:native | fastly:native | spin:native) ;; + cloudflare:wasm32-unknown-unknown | fastly:wasm32-wasip1 | spin:wasm32-wasip2) ;; + *) + echo "unsupported adapter/target pair: ${adapter}:${target}" >&2 + exit 2 + ;; +esac + +feature_sets=( + "" + "${adapter}" + "cli" + "test-utils" + "${adapter},cli" + "${adapter},test-utils" + "cli,test-utils" + "${adapter},cli,test-utils" +) + +for features in "${feature_sets[@]}"; do + args=( + cargo check + --offline + --locked + --package "edgezero-adapter-${adapter}" + --no-default-features + --all-targets + ) + if [[ "${target}" != "native" ]]; then + args+=(--target "${target}") + fi + label="none" + if [[ -n "${features}" ]]; then + args+=(--features "${features}") + label="${features}" + fi + + echo "==> edgezero-adapter-${adapter} ${target} features=[${label}]" + "${args[@]}" +done diff --git a/scripts/check_brotli_dependency_contract.mjs b/scripts/check_brotli_dependency_contract.mjs new file mode 100644 index 000000000..b4023bf83 --- /dev/null +++ b/scripts/check_brotli_dependency_contract.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' + +const expected = JSON.parse( + readFileSync('scripts/brotli_dependency_contract.json', 'utf8'), +) + +const registrySource = 'registry+https://github.com/rust-lang/crates.io-index' + +function fail(workspace, message) { + process.stderr.write(`brotli dependency contract (${workspace}): ${message}\n`) + process.exitCode = 1 +} + +function metadata(workspace) { + const output = execFileSync( + 'cargo', + ['metadata', '--locked', '--offline', '--format-version', '1', '--all-features'], + { cwd: workspace, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, + ) + return JSON.parse(output) +} + +function lockValue(packageBlock, key) { + return packageBlock.match(new RegExp(`^${key} = "([^"]+)"$`, 'mu'))?.[1] +} + +function auditLockfile(workspace) { + const lockPath = workspace === '.' ? 'Cargo.lock' : `${workspace}/Cargo.lock` + const packageBlocks = readFileSync(lockPath, 'utf8') + .split('[[package]]') + .slice(1) + + for (const [name, version, checksum] of expected) { + const matches = packageBlocks.filter( + (block) => lockValue(block, 'name') === name, + ) + if (matches.length !== 1) { + fail(workspace, `expected one locked ${name} package, found ${matches.length}`) + continue + } + const block = matches[0] + if ( + lockValue(block, 'version') !== version || + lockValue(block, 'source') !== registrySource || + lockValue(block, 'checksum') !== checksum + ) { + fail( + workspace, + `locked ${name} source, version, or checksum differs from the audited contract`, + ) + } + } +} + +function auditWorkflowFetchOrder() { + const workflow = readFileSync('.github/workflows/test.yml', 'utf8') + const lines = workflow.split('\n').map((line) => line.trim()) + const commandIndex = (command) => + lines.findIndex((line) => line === command || line === `run: ${command}`) + const rootFetch = commandIndex('cargo fetch --locked') + const demoFetch = commandIndex( + 'cargo fetch --locked --manifest-path examples/app-demo/Cargo.toml', + ) + const audit = commandIndex('node scripts/check_brotli_dependency_contract.mjs') + + if ( + rootFetch === -1 || + demoFetch === -1 || + audit === -1 || + rootFetch >= audit || + demoFetch >= audit + ) { + fail( + 'workflow', + 'root and app-demo locked fetches must run before the offline dependency audit', + ) + } +} + +function audit(workspace) { + auditLockfile(workspace) + const graph = metadata(workspace) + const packagesById = new Map(graph.packages.map((pkg) => [pkg.id, pkg])) + const nodesById = new Map(graph.resolve.nodes.map((node) => [node.id, node])) + + for (const [name, version] of expected) { + const matches = graph.packages.filter((pkg) => pkg.name === name) + if (matches.length !== 1) { + fail(workspace, `expected one ${name} package, found ${matches.length}`) + continue + } + if (matches[0].version !== version) { + fail( + workspace, + `expected ${name} ${version}, resolved ${matches[0].version}`, + ) + } + } + + const core = graph.packages.find((pkg) => pkg.name === 'edgezero-core') + if (core === undefined) { + fail(workspace, 'edgezero-core package is missing') + return + } + const coreNode = nodesById.get(core.id) + if (coreNode === undefined) { + fail(workspace, 'edgezero-core resolve node is missing') + return + } + + for (const [name, version] of expected) { + const declarations = core.dependencies.filter( + (dependency) => dependency.name === name && dependency.kind === null, + ) + if (declarations.length !== 1 || declarations[0].req !== `=${version}`) { + fail( + workspace, + `edgezero-core must declare exactly one normal ${name} dependency pinned as =${version}`, + ) + } + const direct = coreNode.dependencies + .map((id) => packagesById.get(id)) + .find((pkg) => pkg?.name === name) + if (direct === undefined) { + fail(workspace, `edgezero-core does not pin ${name} as a normal dependency`) + } + } +} + +auditWorkflowFetchOrder() +audit('.') +audit('examples/app-demo') + +if (process.exitCode === undefined) { + process.stdout.write('brotli dependency contract: ok\n') +} diff --git a/scripts/check_flate_dependency_contract.mjs b/scripts/check_flate_dependency_contract.mjs new file mode 100644 index 000000000..e73ca7a4d --- /dev/null +++ b/scripts/check_flate_dependency_contract.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' + +const expected = JSON.parse( + readFileSync('scripts/flate_dependency_contract.json', 'utf8'), +) +const registrySource = 'registry+https://github.com/rust-lang/crates.io-index' + +function fail(workspace, message) { + process.stderr.write(`flate dependency contract (${workspace}): ${message}\n`) + process.exitCode = 1 +} + +function metadata(workspace) { + const output = execFileSync( + 'cargo', + ['metadata', '--locked', '--offline', '--format-version', '1', '--all-features'], + { cwd: workspace, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, + ) + return JSON.parse(output) +} + +function lockValue(packageBlock, key) { + return packageBlock.match(new RegExp(`^${key} = "([^"]+)"$`, 'mu'))?.[1] +} + +function auditLockfile(workspace) { + const lockPath = workspace === '.' ? 'Cargo.lock' : `${workspace}/Cargo.lock` + const packageBlocks = readFileSync(lockPath, 'utf8') + .split('[[package]]') + .slice(1) + + for (const [name, version, checksum] of expected) { + const matches = packageBlocks.filter( + (block) => lockValue(block, 'name') === name, + ) + if (matches.length !== 1) { + fail(workspace, `expected one locked ${name} package, found ${matches.length}`) + continue + } + const block = matches[0] + if ( + lockValue(block, 'version') !== version || + lockValue(block, 'source') !== registrySource || + lockValue(block, 'checksum') !== checksum + ) { + fail( + workspace, + `locked ${name} source, version, or checksum differs from the audited contract`, + ) + } + } +} + +function auditWorkflowFetchOrder() { + const workflow = readFileSync('.github/workflows/test.yml', 'utf8') + const lines = workflow.split('\n').map((line) => line.trim()) + const commandIndex = (command) => + lines.findIndex((line) => line === command || line === `run: ${command}`) + const rootFetch = commandIndex('cargo fetch --locked') + const demoFetch = commandIndex( + 'cargo fetch --locked --manifest-path examples/app-demo/Cargo.toml', + ) + const audit = commandIndex('node scripts/check_flate_dependency_contract.mjs') + + if ( + rootFetch === -1 || + demoFetch === -1 || + audit === -1 || + rootFetch >= audit || + demoFetch >= audit + ) { + fail( + 'workflow', + 'root and app-demo locked fetches must run before the offline flate dependency audit', + ) + } +} + +function audit(workspace) { + auditLockfile(workspace) + const graph = metadata(workspace) + const packagesById = new Map(graph.packages.map((pkg) => [pkg.id, pkg])) + const nodesById = new Map(graph.resolve.nodes.map((node) => [node.id, node])) + + for (const [name, version] of expected) { + const matches = graph.packages.filter((pkg) => pkg.name === name) + if (matches.length !== 1) { + fail(workspace, `expected one ${name} package, found ${matches.length}`) + continue + } + if (matches[0].version !== version) { + fail( + workspace, + `expected ${name} ${version}, resolved ${matches[0].version}`, + ) + } + } + + const core = graph.packages.find((pkg) => pkg.name === 'edgezero-core') + if (core === undefined) { + fail(workspace, 'edgezero-core package is missing') + return + } + const coreNode = nodesById.get(core.id) + if (coreNode === undefined) { + fail(workspace, 'edgezero-core resolve node is missing') + return + } + + for (const [name, version] of expected) { + const declarations = core.dependencies.filter( + (dependency) => dependency.name === name && dependency.kind === null, + ) + if (declarations.length !== 1 || declarations[0].req !== `=${version}`) { + fail( + workspace, + `edgezero-core must declare exactly one normal ${name} dependency pinned as =${version}`, + ) + } + const direct = coreNode.dependencies + .map((id) => packagesById.get(id)) + .find((pkg) => pkg?.name === name) + if (direct === undefined) { + fail(workspace, `edgezero-core does not pin ${name} as a normal dependency`) + } + } +} + +auditWorkflowFetchOrder() +audit('.') +audit('examples/app-demo') + +if (process.exitCode === undefined) { + process.stdout.write('flate dependency contract: ok\n') +} diff --git a/scripts/check_no_legacy_typed_reads.sh b/scripts/check_no_legacy_typed_reads.sh index 2bf4407e2..d2a5fd06f 100755 --- a/scripts/check_no_legacy_typed_reads.sh +++ b/scripts/check_no_legacy_typed_reads.sh @@ -69,6 +69,21 @@ done < <( 2>/dev/null || true ) +# ------------------------------------------------------------------ +# Pattern 4: superseded serde extraction constructor +# ------------------------------------------------------------------ +while IFS= read -r hit; do + printf '%s: violation: superseded config serde constructor — use store_deserialization_from_serde\n' "${hit}" + VIOLATIONS=$((VIOLATIONS + 1)) +done < <( + grep -rn --include="*.rs" \ + --exclude-dir=target \ + 'config_out_of_date_from_serde' \ + "${REPO_ROOT}/crates" \ + "${REPO_ROOT}/examples" \ + 2>/dev/null || true +) + if [ "${VIOLATIONS}" -gt 0 ]; then printf '\n%d violation(s) found. Migrate to the AppConfig blob-model extractor.\n' "${VIOLATIONS}" >&2 exit 1 diff --git a/scripts/check_outbound_docs_contract.mjs b/scripts/check_outbound_docs_contract.mjs new file mode 100644 index 000000000..7a2f59f98 --- /dev/null +++ b/scripts/check_outbound_docs_contract.mjs @@ -0,0 +1,1791 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs' + +const capabilityPath = 'docs/guide/capabilities.md' +const adapterOverviewPath = 'docs/guide/adapters/overview.md' +const changelogPath = 'CHANGELOG.md' +const handlersGuidePath = 'docs/guide/handlers.md' +const axumGuidePath = 'docs/guide/adapters/axum.md' +const cloudflareGuidePath = 'docs/guide/adapters/cloudflare.md' +const proxyGuidePath = 'docs/guide/proxying.md' +const routingGuidePath = 'docs/guide/routing.md' +const fastlyGuidePath = 'docs/guide/adapters/fastly.md' +const scaffoldReadmePath = + 'crates/edgezero-cli/src/templates/root/README.md.hbs' +const outboundCorePath = 'crates/edgezero-core/src/outbound.rs' +const compressionCorePath = 'crates/edgezero-core/src/compression.rs' +const outboundAdapterPaths = [ + 'crates/edgezero-adapter-axum/src/outbound.rs', + 'crates/edgezero-adapter-cloudflare/src/outbound.rs', + 'crates/edgezero-adapter-fastly/src/outbound.rs', + 'crates/edgezero-adapter-spin/src/outbound.rs', +] +const templateHandlersPath = + 'crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs' +const demoHandlersPath = + 'examples/app-demo/crates/app-demo-core/src/handlers.rs' +const observationCutoffSourcePaths = [ + outboundCorePath, + 'crates/edgezero-core/src/context.rs', + 'crates/edgezero-core/src/time.rs', + ...outboundAdapterPaths, + ...['axum', 'cloudflare', 'fastly', 'spin'].map( + (adapter) => `crates/edgezero-adapter-${adapter}/tests/contract.rs`, + ), + templateHandlersPath, + demoHandlersPath, + proxyGuidePath, +] +const brotliAuditPath = + 'docs/audits/2026-09-27-brotli-decoder-memory-accounting.md' +const outboundSpecPath = + 'docs/superpowers/specs/2026-05-21-outbound-http-design.md' +const platformMetadataDesignPath = + 'docs/superpowers/specs/2026-09-28-platform-resource-metadata-design.md' +const inboundSpecPath = + 'docs/superpowers/specs/2026-08-22-inbound-body-design.md' +const responseEgressSpecPath = + 'docs/superpowers/specs/2026-09-08-response-egress-design.md' +const outboundImplementationIndexPath = + 'docs/superpowers/plans/2026-07-10-outbound-http-implementation.md' +const outboundBatchTerminationPlanPath = + 'docs/superpowers/plans/2026-09-16-outbound-batch-termination.md' +const outboundReviewHardeningPlanPath = + 'docs/superpowers/plans/2026-09-10-outbound-http-review-hardening.md' +const spinPhasePath = + 'docs/superpowers/plans/2026-09-06-outbound-http-phase5-spin.md' +const cloudflarePhasePath = + 'docs/superpowers/plans/2026-09-06-outbound-http-phase4-axum-cloudflare.md' +const fastlyPhasePath = + 'docs/superpowers/plans/2026-09-06-outbound-http-phase6-fastly.md' +const migrationPhasePath = + 'docs/superpowers/plans/2026-09-06-outbound-http-phase7-migration-docs.md' +const sidebarPath = 'docs/.vitepress/config.mts' +const expectedHeader = ['Capability', 'Axum', 'Cloudflare', 'Fastly', 'Spin'] +const expectedIngressRows = [ + ['ingress-admission', 'Native', 'Native', 'Native', 'Native'], + [ + 'ingress-admission-abort', + 'Native', + 'BestEffort', + 'BestEffort', + 'BestEffort', + ], + [ + 'inbound-read-deadlines', + 'Native', + 'BestEffort', + 'BestEffort', + 'BestEffort', + ], + [ + 'raw-ingress-head-limits', + 'Unsupported', + 'Unsupported', + 'Unsupported', + 'Unsupported', + ], + [ + 'raw-ingress-framing-validation', + 'Unsupported', + 'Unsupported', + 'Unsupported', + 'Unsupported', + ], +] +const expectedConfigRows = [ + [ + 'config-read-allocation-bounds', + 'Unsupported', + 'Unsupported', + 'Unsupported', + 'Unsupported', + ], + [ + 'config-read-deadlines', + 'BestEffort', + 'BestEffort', + 'BestEffort', + 'BestEffort', + ], +] +const expectedOutboundRows = [ + ['outbound-http', 'Native', 'Native', 'BestEffort', 'Native'], + [ + 'outbound-authority-override', + 'Native', + 'BestEffort', + 'Native', + 'Unsupported', + ], + [ + 'outbound-batch-cancellation', + 'Native', + 'BestEffort', + 'BestEffort', + 'BestEffort', + ], + [ + 'outbound-batch-completion-order', + 'Native', + 'Native', + 'BestEffort', + 'Native', + ], + ['outbound-batch-slot-isolation', 'Native', 'Native', 'BestEffort', 'Native'], + ['outbound-cache-bypass', 'Native', 'Native', 'Native', 'Native'], + [ + 'outbound-complete-resource-accounting', + 'Unsupported', + 'Unsupported', + 'Unsupported', + 'Unsupported', + ], + ['outbound-header-fidelity', 'Native', 'BestEffort', 'Native', 'Native'], + ['outbound-deadlines', 'Native', 'BestEffort', 'BestEffort', 'BestEffort'], + [ + 'outbound-flexible-phase-budget', + 'Native', + 'Native', + 'BestEffort', + 'BestEffort', + ], + [ + 'streamed-upload-deadlines', + 'Native', + 'BestEffort', + 'BestEffort', + 'BestEffort', + ], + [ + 'lazy-streamed-response-passthrough', + 'Native', + 'Native', + 'BestEffort', + 'BestEffort', + ], +] +const expectedResponseEgressRows = [ + [ + 'response-egress-abort', + 'BestEffort', + 'BestEffort', + 'BestEffort', + 'BestEffort', + ], + [ + 'response-egress-backpressure', + 'BestEffort', + 'BestEffort', + 'BestEffort', + 'BestEffort', + ], + [ + 'response-egress-completion', + 'BestEffort', + 'BestEffort', + 'BestEffort', + 'BestEffort', + ], + [ + 'response-write-deadlines', + 'BestEffort', + 'BestEffort', + 'BestEffort', + 'BestEffort', + ], +] +const expectedLimitHeader = ['Control', 'Scope', 'Default'] +const expectedLimitRows = [ + ['max_request_body_bytes', 'Buffered or streamed request bytes', '8 MiB'], + [ + 'max_encoded_response_bytes', + 'Upstream transport bytes before decoding', + 'Unset', + ], + [ + 'max_decoded_response_bytes', + 'Identity or EdgeZero-decoded gzip/deflate/Brotli output', + 'Unset', + ], + [ + 'max_response_bytes', + 'Final buffered response, including raw passthrough', + '1 MiB', + ], + [ + 'max_response_header_bytes', + 'Adapter-visible upstream header name/value bytes before normalization, plus `x-edgezero-proxy`', + 'Unset', + ], + [ + 'max_response_header_count', + 'Adapter-visible upstream header fields before normalization, plus `x-edgezero-proxy`', + 'Unset', + ], + [ + 'max_brotli_window_bits', + 'Brotli stream header checked before decoder allocation', + '24', + ], + [ + 'max_decoder_bytes', + 'Pinned policy charge for Brotli/gzip/deflate decoder state', + '32 MiB', + ], + [ + 'max_chunk_bytes', + 'Maximum emitted item size after decoding or passthrough', + 'Unset', + ], +] +const expectedMemoryHeader = [ + 'Target', + 'Primary ceiling', + 'Separate stack', + 'Scope', + 'Memory source / reason', + 'Live inbound requests', + 'Population source / reason', + 'Host ingress/framing charge', + 'Accounting source / reason', +] +const expectedMemoryRows = [ + [ + 'Axum', + 'Unknown', + 'Unknown', + 'Unknown', + 'Unknown: Operator configured', + 'Unknown', + 'Unknown: Operator configured', + 'Unknown', + 'Unknown: Operator configured', + ], + [ + 'Cloudflare Workers', + '128000000 bytes (128 MB)', + 'None', + 'Per instance', + 'Platform limit: Cloudflare Workers', + 'Unknown', + 'Unknown: Provider unpublished', + 'Unknown', + 'Unknown: Provider unpublished', + ], + [ + 'Fastly Compute', + '128000000 bytes (128 MB)', + '1000000 bytes (1 MB)', + 'Per execution', + 'Platform limit: Fastly Compute', + '1', + 'Platform limit: Fastly Compute', + 'Unknown', + 'Unknown: Provider unpublished', + ], + [ + 'Spin (generic)', + 'Unknown', + 'Unknown', + 'Unknown', + 'Unknown: Runtime configured', + 'Unknown', + 'Unknown: Runtime configured', + 'Unknown', + 'Unknown: Runtime configured', + ], + [ + 'Akamai Functions (Spin)', + '134217728 bytes (128 MiB)', + 'None', + 'Per execution', + 'Hosted default: Akamai Functions', + '1', + 'Hosted default: Akamai Functions', + 'Unknown', + 'Unknown: Provider unpublished', + ], +] + +function fail(message) { + process.stderr.write(`outbound docs contract: ${message}\n`) + process.exit(1) +} + +function cells(line) { + const trimmed = line.trim() + if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) return null + return trimmed + .slice(1, -1) + .split('|') + .map((cell) => cell.trim()) +} + +function normalizeCapability(value) { + return value.startsWith('`') && value.endsWith('`') + ? value.slice(1, -1) + : value +} + +function normalizeSupport(value) { + return value + .replace(/\[\^[^\]]+\]$/u, '') + .replace(/[¹²³⁴⁵⁶⁷⁸⁹]+$/u, '') + .trim() +} + +function readRustU64Constant(source, name) { + const match = source.match( + new RegExp(`pub const ${name}: u64 = ([^;]+);`, 'u'), + ) + const expression = match?.[1] + if (expression === undefined) { + fail(`cannot find Rust constant ${name}`) + } + const factors = expression.split('*').map((factor) => factor.trim()) + if (!factors.every((factor) => /^[0-9][0-9_]*$/u.test(factor))) { + fail(`Rust constant ${name} has an unsupported expression: ${expression}`) + } + return factors.reduce( + (product, factor) => product * BigInt(factor.replaceAll('_', '')), + 1n, + ) +} + +function formatBinaryBytes(bytes) { + const mebibyte = 1024n * 1024n + if (bytes % mebibyte === 0n) { + return `${bytes / mebibyte} MiB` + } + return `${bytes} bytes` +} + +const qualifiedDeadline = String.raw`(?:[A-Za-z_][A-Za-z0-9_]*\s*::\s*)*Deadline` +const optionalDeadline = String.raw`Option\s*<\s*${qualifiedDeadline}\s*>` +const deadlineType = String.raw`(?:${qualifiedDeadline}|${optionalDeadline})` + +function maskObservationCutoffNonCode(source) { + const masked = source.split('') + const blank = (start, end) => { + for (let index = start; index < end; index += 1) { + if (!/[\r\n]/u.test(masked[index])) masked[index] = ' ' + } + } + let index = 0 + while (index < source.length) { + if (source.startsWith('//', index)) { + const newline = source.indexOf('\n', index + 2) + const end = newline === -1 ? source.length : newline + blank(index, end) + index = end + continue + } + if (source.startsWith('/*', index)) { + let depth = 1 + let end = index + 2 + while (end < source.length && depth > 0) { + if (source.startsWith('/*', end)) { + depth += 1 + end += 2 + } else if (source.startsWith('*/', end)) { + depth -= 1 + end += 2 + } else { + end += 1 + } + } + blank(index, end) + index = end + continue + } + const rest = source.slice(index) + const rawStart = rest.match(/^(?:b|c)?r(#{0,255})"/u) + if (rawStart !== null) { + const delimiter = `"${rawStart[1]}` + const closing = source.indexOf(delimiter, index + rawStart[0].length) + const end = closing === -1 ? source.length : closing + delimiter.length + blank(index, end) + index = end + continue + } + const quoted = rest.match( + /^(?:(?:b|c)?"(?:\\[\s\S]|[^"\\])*"|b?'(?:\\(?:u\{[0-9A-Fa-f_]+\}|x[0-9A-Fa-f]{2}|.)|[^'\\\r\n])')/u, + ) + if (quoted !== null) { + blank(index, index + quoted[0].length) + index += quoted[0].length + continue + } + index += 1 + } + return masked.join('') +} + +function markdownFenceOpening(line) { + const match = /^ {0,3}(`{3,}|~{3,})[ \t]*([^\r\n]*)$/u.exec(line) + if (match === null) return null + if (match[1][0] === '`' && match[2].includes('`')) return null + const language = match[2] + .trim() + .split(/[:,\s]/u, 1)[0] + .toLowerCase() + return { + marker: match[1][0], + minimumLength: match[1].length, + rust: language === 'rust' || language === 'rs', + } +} + +function markdownFenceClosing(line, fence) { + let index = 0 + while (index < 3 && line[index] === ' ') index += 1 + let markerLength = 0 + while (line[index + markerLength] === fence.marker) markerLength += 1 + return ( + markerLength >= fence.minimumLength && + line.slice(index + markerLength).trim() === '' + ) +} + +function markdownViews(source) { + const prose = source.split('') + const rust = source.split('') + const blank = (view) => { + for (let index = 0; index < source.length; index += 1) { + if (!/[\r\n]/u.test(source[index])) view[index] = ' ' + } + } + const copy = (view, start, end) => { + for (let index = start; index < end; index += 1) view[index] = source[index] + } + blank(prose) + blank(rust) + + let fence = null + let lineStart = 0 + while (lineStart < source.length) { + const newline = source.indexOf('\n', lineStart) + const lineEnd = newline === -1 ? source.length : newline + const nextLine = newline === -1 ? source.length : newline + 1 + const line = source.slice(lineStart, lineEnd).replace(/\r$/u, '') + + if (fence === null) { + const opening = markdownFenceOpening(line) + if (opening === null) copy(prose, lineStart, nextLine) + else { + fence = { + ...opening, + contentStart: newline === -1 ? source.length : newline + 1, + } + } + } else if (markdownFenceClosing(line, fence)) { + if (fence.rust) copy(rust, fence.contentStart, lineStart) + fence = null + } + + if (newline === -1) break + lineStart = nextLine + } + + if (fence?.rust) copy(rust, fence.contentStart, source.length) + return { prose: prose.join(''), rust: rust.join('') } +} + +function closingDelimiter(source, open) { + const pairs = new Map([ + ['(', ')'], + ['[', ']'], + ['{', '}'], + ]) + const stack = [pairs.get(source[open])] + for (let index = open + 1; index < source.length; index += 1) { + if (pairs.has(source[index])) stack.push(pairs.get(source[index])) + else if (source[index] === stack.at(-1)) { + stack.pop() + if (stack.length === 0) return index + } + } + return -1 +} + +function topLevelArguments(source, open, close) { + const argumentsList = [] + let start = open + 1 + for (let index = start; index <= close; index += 1) { + if (index === close || source[index] === ',') { + const argument = source.slice(start, index).trim() + if (argument !== '') argumentsList.push(argument) + start = index + 1 + } else if ('([{'.includes(source[index])) { + index = closingDelimiter(source, index) + if (index === -1) return [] + } + } + return argumentsList +} + +function signatureContractError( + source, + name, + count, + typeShape, + unusedCount = 0, +) { + const code = maskObservationCutoffNonCode(source) + const signatures = + code.match(new RegExp(String.raw`\bfn\s+${name}\s*\([^)]*\)`, 'gu')) ?? [] + const expectedType = + typeShape === 'optional' ? optionalDeadline : qualifiedDeadline + const parameterCount = (parameter) => + signatures.filter((signature) => + new RegExp( + String.raw`(?:^|,)\s*${parameter}\s*:\s*${expectedType}\s*(?=,|\))`, + 'u', + ).test(signature.slice(signature.indexOf('(') + 1)), + ).length + const staleParameter = new RegExp( + String.raw`(?:^|,)\s*(?:cutoff|_cutoff|batch_cutoff)\s*:\s*${deadlineType}\s*(?=,|\))`, + 'u', + ) + return signatures.some((signature) => staleParameter.test(signature)) || + signatures.length !== count || + parameterCount('observation_cutoff') !== count - unusedCount || + parameterCount('_observation_cutoff') !== unusedCount + ? `${name} must use ${typeShape} observation_cutoff: Deadline in ${count} signature(s)` + : null +} + +function exampleCodeError(code) { + const staleName = String.raw`(?:cutoff|_cutoff|batch_cutoff)` + const staleBinding = new RegExp( + String.raw`^\s*let\s+(?:mut\s+)?${staleName}\b`, + 'mu', + ) + if (staleBinding.test(code)) return 'a stale cutoff binding' + const batchCall = /\b(?:start_batch_until|send_all_until)\s*\(/gu + for (const match of code.matchAll(batchCall)) { + const open = match.index + match[0].lastIndexOf('(') + const close = closingDelimiter(code, open) + if (close === -1) continue + const finalArgument = topLevelArguments(code, open, close).at(-1) + if (new RegExp(String.raw`^${staleName}$`, 'u').test(finalArgument ?? '')) { + return 'a stale batch cutoff argument' + } + } + return null +} + +function exampleContractError(path, source) { + if (!path.endsWith('.md') && !path.endsWith('.mdx')) { + return exampleCodeError(maskObservationCutoffNonCode(source)) + } + const views = markdownViews(source) + return ( + exampleCodeError(views.prose) ?? + exampleCodeError(maskObservationCutoffNonCode(views.rust)) + ) +} + +function runObservationCutoffContractSelfTests() { + const bareCutoff = ['cut', 'off'].join('') + const ignoredCutoff = `_${bareCutoff}` + const batchCutoff = `batch_${bareCutoff}` + for (const [parameters, typeShape, valid] of [ + ['observation_cutoff: Deadline', 'plain', true], + ['observation_cutoff: edgezero_core :: Deadline', 'plain', true], + [ + 'observation_cutoff: Option < edgezero_core :: Deadline >', + 'optional', + true, + ], + ['observation_cutoff: Option', 'plain', false], + ['observation_cutoff: Deadline', 'optional', false], + [`${bareCutoff}: Deadline`, 'plain', false], + [`${ignoredCutoff}: edgezero_core::Deadline`, 'plain', false], + [`${batchCutoff}: Option < Deadline >`, 'optional', false], + [`${bareCutoff}: Deadline, observation_cutoff: Deadline`, 'plain', false], + ]) { + const source = `fn dispatch_budget(request: Request, ${parameters}) {}` + const passed = + signatureContractError(source, 'dispatch_budget', 1, typeShape) === null + if (passed !== valid) { + fail(`observation cutoff signature self-test failed for ${parameters}`) + } + } + for (const [name, source, valid] of [ + ['mutable binding', `let mut ${bareCutoff} = deadline();`, false], + ['ignored binding', `let ${ignoredCutoff} = deadline();`, false], + ['batch binding', `let ${batchCutoff} = deadline();`, false], + [ + 'start call', + `client.start_batch_until(vec![request()], ${bareCutoff});`, + false, + ], + ['ignored call', `client.send_all_until(x, ${ignoredCutoff});`, false], + ['send call', `client.send_all_until(x, ${batchCutoff});`, false], + [ + 'multiline trailing call', + `client.start_batch_until(\n make_requests(),\n ${bareCutoff},\n);`, + false, + ], + [ + 'nested cutoff', + `client.start_batch_until(make_requests(x, ${bareCutoff}), observation_cutoff);`, + true, + ], + ['comment', `// let mut ${bareCutoff} = deadline();`, true], + [ + 'string', + `const TEXT: &str = "send_all_until(requests, ${bareCutoff})";`, + true, + ], + ['budget source', 'let source = BudgetSource::BatchCutoff;', true], + ['constructor', 'let batch = OutboundBatch::cutoff(2);', true], + ]) { + if ((exampleContractError('fixture.rs', source) === null) !== valid) { + fail(`observation cutoff example self-test failed for ${name}`) + } + } + if ( + exampleContractError( + 'fixture.md', + `Prose: send_all_until(requests, ${bareCutoff})\n` + + '```rust\nlet observation_cutoff = deadline();\n```', + ) === null + ) { + fail('observation cutoff self-test missed Markdown prose') + } + if ( + exampleContractError( + 'fixture.md', + `\`\`\`rust,no_run\nlet ${bareCutoff} = deadline();\n\`\`\``, + ) === null + ) { + fail('observation cutoff self-test missed a modified Rust fence') + } + if ( + exampleContractError( + 'fixture.md', + `\`\`\`rust\`bad\nlet ${bareCutoff} = deadline();\n\`\`\``, + ) === null + ) { + fail('observation cutoff self-test treated an invalid backtick fence as a fence') + } + for (const [name, fence] of [ + [ + 'tilde colon modifier', + `~~~rust:no_run\nlet ${bareCutoff} = deadline();\n~~~~`, + ], + [ + 'indented space modifier', + ` \`\`\`rust no_run\nlet ${bareCutoff} = deadline();\n \`\`\``, + ], + [ + 'variable-length rs fence', + `\`\`\`\`rs:ignore\nlet ${bareCutoff} = deadline();\n\`\`\`\`\``, + ], + ]) { + if (exampleContractError('fixture.md', fence) === null) { + fail(`observation cutoff self-test missed ${name}`) + } + } + for (const [name, fence] of [ + ['non-Rust fence', `\`\`\`text\nlet ${bareCutoff} = deadline();\n\`\`\``], + [ + 'Rust fence comments and literals', + `\`\`\`rust\n// let ${bareCutoff} = deadline();\n` + + `const TEXT: &str = "let ${bareCutoff} = deadline();";\n` + + `const RAW: &str = r#"let ${bareCutoff} = deadline();"#;\n\`\`\``, + ], + ]) { + if (exampleContractError('fixture.md', fence) !== null) { + fail(`observation cutoff self-test scanned ${name}`) + } + } + for (const [name, source] of [ + [ + 'block comment', + `fn dispatch_budget(x: Request, observation_cutoff: Deadline) {}\n/*\nlet ${bareCutoff} = deadline();\nfn dispatch_budget(x: Request, ${bareCutoff}: Deadline) {}\n*/`, + ], + [ + 'raw string', + `fn dispatch_budget(x: Request, observation_cutoff: Deadline) {}\nconst TEXT: &str = r#"\nlet ${bareCutoff} = deadline();\nfn dispatch_budget(x: Request, ${bareCutoff}: Deadline) {}\n"#;`, + ], + ]) { + if ( + signatureContractError(source, 'dispatch_budget', 1, 'plain') !== null || + exampleContractError('fixture.rs', source) !== null + ) { + fail(`observation cutoff self-test rejected ${name}`) + } + } +} + +function observationCutoffContract(outboundCoreSource) { + const publicRustdoc = outboundCoreSource.match( + /(?(?:\s*\/\/\/[^\n]*\n)+)\s*fn start_batch_until\(/u, + )?.groups?.rustdoc + if (publicRustdoc === undefined) fail('missing start_batch_until Rustdoc') + const normalizedPublicRustdoc = publicRustdoc + .replaceAll(/\s*\/\/\/\s?/gu, ' ') + .replaceAll(/\s+/gu, ' ') + .trim() + for (const requiredFragment of [ + 'The observation cutoff stops batch observation regardless of any remaining per-request budget or later per-request deadline.', + 'Passing an earlier observation cutoff intentionally leaves unresolved slots.', + ]) { + if (!normalizedPublicRustdoc.includes(requiredFragment)) { + fail(`public batch Rustdoc is missing: ${requiredFragment}`) + } + } + + const signatureContracts = [ + [outboundCorePath, 'start_batch_until', 3, 'plain', 1], + [outboundCorePath, 'send_all_until', 1, 'plain'], + [outboundCorePath, 'finish_batch_item', 1, 'plain'], + ['crates/edgezero-core/src/context.rs', 'start_batch_until', 1, 'plain', 1], + ['crates/edgezero-core/src/time.rs', 'dispatch_budget', 1, 'optional'], + ...outboundAdapterPaths.flatMap((path) => [ + [path, 'prepare_batch', 1, 'plain'], + [path, 'prepare_validated', 1, 'optional'], + [path, 'start_batch_until', 1, 'plain'], + ]), + [outboundAdapterPaths[2], 'finish_fastly_batch_observation', 1, 'plain'], + [templateHandlersPath, 'start_batch_until', 1, 'plain'], + [demoHandlersPath, 'start_batch_until', 1, 'plain'], + ] + for (const [ + path, + name, + count, + typeShape, + unusedCount, + ] of signatureContracts) { + const source = readFileSync(path, 'utf8') + const error = signatureContractError( + source, + name, + count, + typeShape, + unusedCount, + ) + if (error !== null) fail(`${path}: ${error}`) + } + for (const path of observationCutoffSourcePaths) { + const error = exampleContractError(path, readFileSync(path, 'utf8')) + if (error !== null) fail(`${path} contains ${error}`) + } +} + +runObservationCutoffContractSelfTests() +if (process.argv.includes('--observation-cutoff-self-test')) { + process.exit(0) +} + +let capabilitySource +try { + capabilitySource = readFileSync(capabilityPath, 'utf8') +} catch (error) { + fail(`cannot read ${capabilityPath}: ${error.message}`) +} + +const lines = capabilitySource.split(/\r?\n/u) + +function readExactTable(sectionTitle, header, name) { + const headingIndex = lines.findIndex( + (line) => line.trim() === `## ${sectionTitle}`, + ) + if (headingIndex === -1) fail(`${name} section is missing`) + const nextHeadingIndex = lines.findIndex( + (line, index) => index > headingIndex && line.startsWith('## '), + ) + const sectionEnd = nextHeadingIndex === -1 ? lines.length : nextHeadingIndex + const headerIndex = lines.findIndex( + (line, index) => + index > headingIndex && + index < sectionEnd && + JSON.stringify(cells(line)) === JSON.stringify(header), + ) + if (headerIndex === -1) fail(`${name} table is missing`) + const separator = cells(lines[headerIndex + 1] ?? '') + if ( + separator === null || + separator.length !== header.length || + !separator.every((value) => /^:?-{3,}:?$/u.test(value)) + ) { + fail(`${name} table has an invalid separator row`) + } + const rows = [] + for (let index = headerIndex + 2; index < sectionEnd; index += 1) { + const row = cells(lines[index]) + if (row === null) break + if (row.length !== header.length) { + fail( + `${name} row ${index + 1} has ${row.length} cells, expected ${header.length}`, + ) + } + rows.push(row) + } + return rows +} + +const actualMemoryRows = readExactTable( + 'Platform Memory Ceilings', + expectedMemoryHeader, + 'platform memory', +) +if (JSON.stringify(actualMemoryRows) !== JSON.stringify(expectedMemoryRows)) { + fail( + `platform memory mismatch\nexpected=${JSON.stringify(expectedMemoryRows)}\nactual=${JSON.stringify(actualMemoryRows)}`, + ) +} +const normalizedCapabilitySource = capabilitySource.replaceAll(/\s+/gu, ' ') +for (const requiredFragment of [ + 'six simultaneous outbound connections', + 'it is not an inbound population bound', + 'host parser, HPACK, or framing allocations', + 'only `Fits` is a complete validation result', +]) { + if (!normalizedCapabilitySource.includes(requiredFragment)) { + fail(`platform memory narrative is missing: ${requiredFragment}`) + } +} + +function tableAfterHeading(source, heading, header) { + const sourceLines = source.split(/\r?\n/u) + const headingIndex = sourceLines.findIndex((line) => line.trim() === heading) + if (headingIndex === -1) fail(`missing heading: ${heading}`) + const nextHeadingOffset = sourceLines + .slice(headingIndex + 1) + .findIndex((line) => /^#{1,6}\s/u.test(line.trim())) + const sectionEnd = + nextHeadingOffset === -1 + ? sourceLines.length + : headingIndex + 1 + nextHeadingOffset + const headerIndex = sourceLines.findIndex( + (line, index) => + index > headingIndex && + index < sectionEnd && + JSON.stringify(cells(line)) === JSON.stringify(header), + ) + if (headerIndex === -1) fail(`missing table under ${heading}`) + const separator = cells(sourceLines[headerIndex + 1] ?? '') + if ( + separator === null || + separator.length !== header.length || + !separator.every((cell) => /^:?-{3,}:?$/u.test(cell)) + ) { + fail(`invalid table separator under ${heading}`) + } + const rows = [] + for (let index = headerIndex + 2; index < sectionEnd; index += 1) { + const row = cells(sourceLines[index]) + if (row === null) break + if (row.length !== header.length) { + fail(`invalid table row width under ${heading}`) + } + rows.push(row) + } + return rows +} + +const migrationHeader = ['Removed or changed API', 'Replacement'] +const designMigrationRows = tableAfterHeading( + readFileSync(platformMetadataDesignPath, 'utf8'), + '## Migration record', + migrationHeader, +) +const changelogMigrationRows = tableAfterHeading( + readFileSync(changelogPath, 'utf8'), + '### Platform resource metadata', + migrationHeader, +) +if ( + designMigrationRows.length !== 8 || + JSON.stringify(changelogMigrationRows) !== JSON.stringify(designMigrationRows) +) { + fail( + 'Unreleased changelog must contain the focused design migration table exactly', + ) +} +if ( + !readFileSync(adapterOverviewPath, 'utf8').includes( + 'CHANGELOG.md#platform-resource-metadata', + ) +) { + fail('adapter overview must link to the Unreleased migration table') +} + +function findMatrixHeader(sectionTitle, name) { + const headingIndex = lines.findIndex( + (line) => line.trim() === `## ${sectionTitle}`, + ) + if (headingIndex === -1) { + fail(`${name} capability section is missing`) + } + const nextHeadingIndex = lines.findIndex( + (line, index) => index > headingIndex && line.startsWith('## '), + ) + const sectionEnd = nextHeadingIndex === -1 ? lines.length : nextHeadingIndex + const headerIndex = lines.findIndex( + (line, index) => + index > headingIndex && + index < sectionEnd && + JSON.stringify(cells(line)) === JSON.stringify(expectedHeader), + ) + if (headerIndex === -1) { + fail(`${name} capability matrix is missing`) + } + return headerIndex +} + +function readMatrix(headerIndex, name) { + const separator = cells(lines[headerIndex + 1] ?? '') + if ( + separator === null || + separator.length !== expectedHeader.length || + !separator.every((value) => /^:?-{3,}:?$/u.test(value)) + ) { + fail(`${name} capability matrix is missing its five-column separator row`) + } + + const actualRows = [] + for (let index = headerIndex + 2; index < lines.length; index += 1) { + const row = cells(lines[index]) + if (row === null) break + if (row.length !== expectedHeader.length) { + fail( + `${name} capability matrix row ${index + 1} has ${row.length} cells, expected 5`, + ) + } + actualRows.push([ + normalizeCapability(row[0]), + ...row.slice(1).map(normalizeSupport), + ]) + } + return actualRows +} + +const matrices = [ + [ + 'ingress', + expectedIngressRows, + readMatrix(findMatrixHeader('Ingress Matrix', 'ingress'), 'ingress'), + ], + [ + 'config', + expectedConfigRows, + readMatrix(findMatrixHeader('Config Matrix', 'config'), 'config'), + ], + [ + 'response egress', + expectedResponseEgressRows, + readMatrix( + findMatrixHeader('Response Egress Matrix', 'response egress'), + 'response egress', + ), + ], + [ + 'outbound', + expectedOutboundRows, + readMatrix(findMatrixHeader('Outbound Matrix', 'outbound'), 'outbound'), + ], +] +for (const [name, expectedRows, actualRows] of matrices) { + if (JSON.stringify(actualRows) !== JSON.stringify(expectedRows)) { + fail( + `${name} capability matrix mismatch\nexpected=${JSON.stringify(expectedRows)}\nactual=${JSON.stringify(actualRows)}`, + ) + } +} + +const hardCutSurfaces = [ + [capabilityPath, capabilitySource], + [handlersGuidePath, readFileSync(handlersGuidePath, 'utf8')], + [proxyGuidePath, readFileSync(proxyGuidePath, 'utf8')], + [fastlyGuidePath, readFileSync(fastlyGuidePath, 'utf8')], + [scaffoldReadmePath, readFileSync(scaffoldReadmePath, 'utf8')], +] +for (const [path, source] of hardCutSurfaces) { + for (const staleFragment of [ + 'HttpClient::send_all`', + '.send_all(', + 'send-all-slot-isolation', + 'run_app_with_request_extensions', + ]) { + if (source.includes(staleFragment)) { + fail(`${path} contains removed outbound API text: ${staleFragment}`) + } + } +} +for (const staleFragment of [ + 'harvests response bodies in input order', + 'harvests responses in input order', +]) { + if (capabilitySource.includes(staleFragment)) { + fail( + `${capabilityPath} contains stale Fastly batch behavior: ${staleFragment}`, + ) + } +} +const handlersGuideSource = hardCutSurfaces[1][1] +for (const requiredFragment of [ + 'fn configure_app(app: &mut edgezero_core::app::App) -> Result<(), EdgeError>', + 'completion: ResponseEgressCompletion::empty()', + 'DetachedResponseEgressDecision::Send {', + 'deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET),', + 'ResponseEgressCompletion::new(move |report| ... )', + 'Use `left.join(right)`', + '`AdmissionDecision::Abort`', + '`App::set_detached_response_egress_decision_factory`', + 'app.set_error_response_renderer(|error|', + '`DetachedResponseEgressDecision::Abort`', + 'Every `Send` deadline is mandatory and absolute', + '`Abort` creates no response or egress attempt and invokes no completion callback', + 'sorted, deduplicated `Allow` field', + 'Effective `config_out_of_date` responses retain `Retry-After: 60`', + 'status returned by the callback is ignored', + 'never belongs in response extensions', +]) { + if (!handlersGuideSource.includes(requiredFragment)) { + fail( + `${handlersGuidePath} is missing lifecycle contract: ${requiredFragment}`, + ) + } +} +const cloudflareGuideSource = readFileSync(cloudflareGuidePath, 'utf8') +if ( + !cloudflareGuideSource.includes( + 'EdgeZeroApp::build::(CLOUDFLARE_PLATFORM)', + ) || + cloudflareGuideSource.includes('build_app') +) { + fail( + `${cloudflareGuidePath} must construct manual applications with Cloudflare platform metadata`, + ) +} +const axumGuideSource = readFileSync(axumGuidePath, 'utf8') +if ( + !axumGuideSource.includes( + 'EdgeZeroApp::build::(edgezero_adapter_axum::AXUM_PLATFORM)', + ) || + axumGuideSource.includes('build_app') +) { + fail( + `${axumGuidePath} must construct manual applications with Axum platform metadata`, + ) +} +const routingGuideSource = readFileSync(routingGuidePath, 'utf8') +if ( + !routingGuideSource.includes('App::build::(platform)') || + routingGuideSource.includes('build_app') +) { + fail( + `${routingGuidePath} must document the platform-aware application builder`, + ) +} +for (const [path, source] of [ + [capabilityPath, capabilitySource], + [handlersGuidePath, handlersGuideSource], +]) { + if (source.includes('build_app_for_platform')) { + fail(`${path} contains the removed application builder name`) + } +} +if ( + !hardCutSurfaces[2][1].includes('start_batch_until') || + !hardCutSurfaces[2][1].includes('send_all_until') +) { + fail('outbound guide must document completion-order and ordered batch access') +} +for (const requiredFragment of [ + 'OutboundBatchNext::Item', + 'OutboundBatchNext::Finished', + 'OutboundBatchTermination::Completed', + 'OutboundBatchTermination::Cutoff', + 'OutboundBatchFailure', + 'send_all_until(requests, observation_cutoff)', +]) { + if (!hardCutSurfaces[2][1].includes(requiredFragment)) { + fail( + `${proxyGuidePath} is missing typed batch contract: ${requiredFragment}`, + ) + } +} +if ( + !hardCutSurfaces[2][1].includes('.deadline_after(Duration::from_secs(2))') || + hardCutSurfaces[2][1].includes( + 'let observation_cutoff = Deadline::after(Duration::from_secs(2))', + ) +) { + fail(`${proxyGuidePath} must anchor batch cutoffs to the request clock`) +} +if ( + !hardCutSurfaces[3][1].includes('run_app_with_hooks') || + !hardCutSurfaces[3][1].includes('send_request_with_hooks') +) { + fail('Fastly guide must document the closed request/response lifecycle APIs') +} + +let outboundSpecSource +try { + outboundSpecSource = readFileSync(outboundSpecPath, 'utf8') +} catch (error) { + fail(`cannot read ${outboundSpecPath}: ${error.message}`) +} + +if (!outboundSpecSource.includes('## 2. Historical implementation baseline')) { + fail( + 'outbound specification must label section 2 as a historical implementation baseline', + ) +} +if (outboundSpecSource.includes('## 2. Current state (summary)')) { + fail( + 'outbound specification still labels its historical baseline as current state', + ) +} +if (outboundSpecSource.includes("Today's Fastly client")) { + fail( + 'outbound specification still describes the historical Fastly client as current', + ) +} +for (const staleFragment of [ + 'explicit low-level constructors use a documented default clock', + 'explicit default-clock constructors may select the', + 'Preserve default-clock constructors for low-level use', + 'low-level defaults', +]) { + if (outboundSpecSource.includes(staleFragment)) { + fail( + `outbound specification contains removed clock behavior: ${staleFragment}`, + ) + } +} +const budgetSourceDefinitions = outboundSpecSource.match( + /pub enum BudgetSource\s*\{/gu, +) +if (budgetSourceDefinitions?.length !== 1) { + fail('outbound specification must define BudgetSource exactly once') +} +for (const staleFragment of [ + 'So the collapse today is', + 'The current adapter maps', + 'two real bugs to fix', + 'The current code (`proxy.rs', + 'crates/edgezero-adapter-spin/src/proxy.rs', + '(`spin/proxy.rs`)', + 'harvests every successfully dispatched `PendingRequest` via blocking `wait()`/`poll()`', + 'cold registration, serial harvest, and streamed-upload cooperative checks', + 'The target-neutral batch probe exercises both still-pending and later-ready harvest branches', + 'send_all_preflight_precedence_and_indices', + 'send_all_dispatches_every_slot_before_wait', + 'ordered harvest', + 'harvest-order', + 'serial harvest', + 'send_one_validated', + 'exact eight outbound', + 'all eight outbound', + 'Fastly send_all_until adapter overhead', + 'pub async fn next(&mut self) -> Option', + 'pub async fn collect(self) -> OutboundBatchResults', + 'OutboundBatch::from_stream', + 'Result', + 'let Ok((selection, metadata)) = select_pending_slot(&mut pending) else', + 'private failure event', + 'four of the eight outbound capabilities', + 'the eight outbound capabilities', + 'all eight rows/support values', + 'allocation-tracking adversarial', +]) { + if (outboundSpecSource.includes(staleFragment)) { + fail( + `outbound specification contains stale implementation text: ${staleFragment}`, + ) + } +} +for (const requiredFragment of [ + '`BudgetSource::BatchCutoff` attribution alone never emits `OutboundBatchDriverEvent::Cutoff`', + 'monotonic clock shows that the absolute observation cutoff has expired; equality is expired', + "An earlier Fastly phase timeout remains that slot's terminal `GatewayTimeout` item", + 'pub enum OutboundBatchDriverEvent', + 'pub enum OutboundBatchTermination', + 'pub enum OutboundBatchNext', + 'pub struct OutboundBatchFailure', + 'pub fn cutoff(slot_count: usize) -> Self', + 'pub fn finish_batch_item(', + 'pub fn from_driver', + 'Result', + 'premature driver EOF', + 'duplicate or out-of-range', + 'Core reports normal completion only at driver EOF', +]) { + if (!outboundSpecSource.includes(requiredFragment)) { + fail( + `outbound specification is missing typed batch contract: ${requiredFragment}`, + ) + } +} +if ( + !/terminal samples follow\s+poll-observation order in the shared monotonic clock domain/u.test( + outboundSpecSource, + ) +) { + fail( + 'outbound specification is missing the monotonic terminal-observation ordering proof', + ) +} + +const cloudflareExactDeadlineRecommendation = + /\btarget(?:s|ing)?\s+(?:Axum\s+(?:or|and)\s+Cloudflare|Cloudflare\s+(?:or|and)\s+Axum)\b/u +if (cloudflareExactDeadlineRecommendation.test(outboundSpecSource)) { + fail( + 'outbound specification recommends Cloudflare for exact deadlines despite its BestEffort capability', + ) +} + +const inboundSpecSource = readFileSync(inboundSpecPath, 'utf8') +for (const requiredFragment of [ + ' Abort,', + 'pub enum DetachedResponseEgressDecision', + ' Send {', + ' completion: ResponseEgressCompletion,', + ' deadline: Deadline,', + 'DetachedResponseEgressDecision::Send {', + 'deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET),', + 'The deadline is mandatory, absolute', + 'Joined completions run left then right with the same borrowed report at most once', + 'Factory `Abort` creates no response or attempt', + 'make no transport-observed reset claim', + 'ResponseEgressCompletion::empty()', + 'Hooks::configure(&mut App) -> Result<(), EdgeError>', + '`App::set_error_response_renderer(Fn(EdgeError) -> Response)`', + '`Vec` rather than flattening protocol data into display text', + 'custom renderer cannot reinterpret the error status', + 'the canonical `Allow` field', + '`Retry-After: 60` for every effective `config_out_of_date` outcome after custom rendering', +]) { + if (!inboundSpecSource.includes(requiredFragment)) { + fail( + `${inboundSpecPath} is missing lifecycle contract: ${requiredFragment}`, + ) + } +} +for (const staleFragment of [ + 'current Tower adapter nevertheless drives', + 'block_in_place` plus a nested runtime `block_on', + 'MethodNotAllowed { allowed: String', + 'Tokio cannot cancel that blocking closure', +]) { + if (inboundSpecSource.includes(staleFragment)) { + fail( + `inbound specification contains removed Axum bridge text: ${staleFragment}`, + ) + } +} + +const outboundReviewHardeningPlanSource = readFileSync( + outboundReviewHardeningPlanPath, + 'utf8', +) +for (const staleFragment of [ + 'Preserve default-clock constructors for low-level use', + 'low-level defaults', +]) { + if (outboundReviewHardeningPlanSource.includes(staleFragment)) { + fail(`${outboundReviewHardeningPlanPath} contains removed clock behavior`) + } +} + +const currentDocumentation = [ + [ + outboundImplementationIndexPath, + readFileSync(outboundImplementationIndexPath, 'utf8'), + ], + [ + outboundBatchTerminationPlanPath, + readFileSync(outboundBatchTerminationPlanPath, 'utf8'), + ], + [cloudflarePhasePath, readFileSync(cloudflarePhasePath, 'utf8')], + [spinPhasePath, readFileSync(spinPhasePath, 'utf8')], + [fastlyPhasePath, readFileSync(fastlyPhasePath, 'utf8')], + [migrationPhasePath, readFileSync(migrationPhasePath, 'utf8')], + [outboundSpecPath, outboundSpecSource], +] +for (const [path, source] of currentDocumentation) { + for (const staleVersion of [ + 'Spin SDK 6.0.0', + 'spin-sdk v6.0.0', + 'Viceroy 0.17.0', + 'viceroy 0.17.0', + 'Fastly SDK 0.12.1', + 'Fastly 0.12.1', + 'fastly v0.12.1', + 'fastly = "=0.12.1"', + 'fastly --precise 0.12.1', + 'Worker 0.8.3', + 'worker = "=0.8.3"', + 'worker --precise 0.8.3', + 'worker v0.8.3', + ]) { + if (source.includes(staleVersion)) { + fail(`${path} contains stale runtime tooling: ${staleVersion}`) + } + } +} + +const outboundImplementationIndexSource = currentDocumentation[0][1] +if (!outboundImplementationIndexSource.includes('Spin SDK 7 / WASI HTTP 0.3')) { + fail( + 'outbound implementation index must name the current Spin SDK 7 baseline', + ) +} +if ( + outboundImplementationIndexSource.includes('| Executable:') || + outboundImplementationIndexSource.includes('Tasks 1-6 blocked') || + outboundImplementationIndexSource.includes('all downstream phases stay') || + outboundImplementationIndexSource.includes( + 'exactly seven **outbound** capabilities', + ) || + outboundImplementationIndexSource.includes( + 'block_in_place` + `Handle::block_on', + ) +) { + fail('outbound implementation index contains stale implementation state') +} + +let brotliAuditSource +try { + brotliAuditSource = readFileSync(brotliAuditPath, 'utf8') +} catch (error) { + fail(`cannot read ${brotliAuditPath}: ${error.message}`) +} +const normalizedBrotliAuditSource = brotliAuditSource.replace(/\s+/gu, ' ') +const expectedBrotliDependencies = JSON.parse( + readFileSync('scripts/brotli_dependency_contract.json', 'utf8'), +) +const auditedBrotliDependencies = new Map( + [ + ...brotliAuditSource.matchAll( + /^\| `([^`]+)`\s+\| `([^`]+)`\s+\| `([0-9a-f]{64})` \|$/gmu, + ), + ].map((match) => [match[1], [match[2], match[3]]]), +) +if (auditedBrotliDependencies.size !== expectedBrotliDependencies.length) { + fail(`${brotliAuditPath} must contain exactly the pinned dependency graph`) +} +for (const [name, version, checksum] of expectedBrotliDependencies) { + const audited = auditedBrotliDependencies.get(name) + if (audited?.[0] !== version || audited?.[1] !== checksum) { + fail( + `${brotliAuditPath} has stale version or checksum evidence for ${name}`, + ) + } +} +for (const requiredFragment of [ + '`brotli-decompressor`', + '`5.0.1`', + '`compression-codecs`', + '`0.4.38`', + '`alloc-stdlib`', + '`0.2.4`', + '3 * 256 * 1080 * 4', + '3,442,530', + '16,777,216', + 'Source-audited payload subtotal', + "The ring buffer's `2^WBITS` bytes are excluded from the fixed term", +]) { + if (!normalizedBrotliAuditSource.includes(requiredFragment)) { + fail(`${brotliAuditPath} is missing audited evidence: ${requiredFragment}`) + } +} + +const outboundBatchTerminationPlanSource = currentDocumentation[1][1] +if (outboundBatchTerminationPlanSource.includes('send_all_until(...).await?')) { + fail( + `${outboundBatchTerminationPlanPath} contains the stale batch collector error conversion`, + ) +} +if ( + outboundBatchTerminationPlanSource.includes('doc-hidden adapter driver event') +) { + fail( + `${outboundBatchTerminationPlanPath} still describes the public adapter driver protocol as doc-hidden`, + ) +} + +const cloudflarePhaseSource = currentDocumentation[2][1] +for (const staleFragment of [ + "Cloudflare's Native timing claim", + 'Cloudflare: Native for HTTP, deadlines', + '| `outbound-deadlines` | Native | Native |', + '| `streamed-upload-deadlines` | Native | Native |', +]) { + if (cloudflarePhaseSource.includes(staleFragment)) { + fail( + `${cloudflarePhasePath} contains stale Cloudflare capability text: ${staleFragment}`, + ) + } +} + +for (const [path, source] of [ + [cloudflarePhasePath, currentDocumentation[2][1]], + [spinPhasePath, currentDocumentation[3][1]], + [fastlyPhasePath, currentDocumentation[4][1]], +]) { + if (!source.includes('**Superseded batch/lifecycle API:**')) { + fail(`${path} does not mark its removed batch API as superseded`) + } +} + +const migrationPhaseSource = currentDocumentation[5][1] +for (const currentRow of [ + '| `outbound-deadlines` | Native | BestEffort | BestEffort | BestEffort |', + '| `streamed-upload-deadlines` | Native | BestEffort | BestEffort | BestEffort |', + '| `lazy-streamed-response-passthrough` | Native | Native | BestEffort | BestEffort |', +]) { + if (!migrationPhaseSource.includes(currentRow)) { + fail( + `${migrationPhasePath} is missing current capability row: ${currentRow}`, + ) + } +} + +for (const dependency of [ + 'async-compression 0.4.43', + 'adler2 = "=2.0.1"', + 'brotli 8.0.4', + 'brotli-decompressor 5.0.1', + 'compression-codecs 0.4.38', + 'compression-core 0.4.32', + 'crc32fast = "=1.5.0"', + 'flate2 = "=1.1.9"', + 'miniz_oxide = "=0.8.9"', + 'simd-adler32 = "=0.3.9"', +]) { + if (!outboundSpecSource.includes(`\`${dependency}\``)) { + fail( + `outbound specification is missing audited dependency pin ${dependency}`, + ) + } +} +if ( + !outboundSpecSource.includes( + "plus EdgeZero's synthetic `x-edgezero-proxy` marker", + ) +) { + fail( + 'outbound specification does not include the proxy header in response caps', + ) +} + +const responseEgressSpecSource = readFileSync(responseEgressSpecPath, 'utf8') +for (const requiredFragment of [ + 'pub struct ResponseEgressDeadline { /* private */ }', + 'pub const fn at(deadline: Deadline) -> Self;', + 'pub const fn after(duration: Duration) -> Self;', + 'egress-start-relative deadline', + 'resolves `after(duration)` from that', + 'exact sample before invoking the callback', + 'retired ambiguous `new()` constructor and unresolved', + 'pub struct ResponseEgressCompletion', + 'pub enum DetachedResponseEgressDecision', + ' Send {', + ' completion: ResponseEgressCompletion,', + ' deadline: Deadline,', + 'DetachedResponseEgressDecision::Send {', + 'deadline: head.write_deadline_after(DEFAULT_RESPONSE_WRITE_BUDGET),', + 'mandatory absolute deadline', + 'pub fn join(self, other: Self) -> Self;', + '`ResponseEgressCompletion::join` consumes a left and right completion', + 'the same borrowed `ResponseEgressReport`; the joined owner executes at most once', + 'existing non-response abort/error boundary', + 'Normalized pre-admission validation invokes the detached-egress decision factory exactly once', + '`Abort` constructs no response or attempt and invokes no completion, observer, handler, middleware, or body poll', + 'ResponseEgressCompletion::empty()', + 'not attached to a `Response` and never enters `http::Extensions`', + 'ResponseEgressHead::application_deadline()', + 'queue-through-egress', + 'application configuration changed adapter-selected platform metadata', + 'validates source EOF before handing that frame to Hyper', + 'A transmitted zero-length stream is validated asynchronously before Hyper receives the response', +]) { + if (!responseEgressSpecSource.includes(requiredFragment)) { + fail( + `${responseEgressSpecPath} is missing application deadline contract: ${requiredFragment}`, + ) + } +} + +if ( + responseEgressSpecSource.includes( + 'Normalized pre-admission validation obtains exactly one completion', + ) +) { + fail( + 'response-egress evidence still claims every normalized rejection has a completion', + ) +} + +for (const requiredFragment of [ + 'detached-egress decision factory', + 'this does not promote their `BestEffort` capability cells', +]) { + if (!capabilitySource.includes(requiredFragment)) { + fail( + `${capabilityPath} is missing detached-ingress abort caveat: ${requiredFragment}`, + ) + } +} + +const responseEgressHeadingIndex = lines.findIndex( + (line) => line.trim() === '## Response Egress Matrix', +) +if (responseEgressHeadingIndex === -1) { + fail(`${capabilityPath} is missing the response-egress section`) +} +const nextCapabilityHeadingIndex = lines.findIndex( + (line, index) => index > responseEgressHeadingIndex && line.startsWith('## '), +) +const responseEgressSectionEnd = + nextCapabilityHeadingIndex === -1 ? lines.length : nextCapabilityHeadingIndex +const responseEgressSection = lines + .slice(responseEgressHeadingIndex, responseEgressSectionEnd) + .join('\n') +for (const requiredFragment of [ + 'The boundaries remain `BestEffort` because none proves end-client receipt:', + 'Provider-owned buffering, SDK allocations, and bytes beyond each stated acceptance boundary are', +]) { + if (!responseEgressSection.includes(requiredFragment)) { + fail( + `${capabilityPath} response-egress section is missing caveat: ${requiredFragment}`, + ) + } +} + +function responseEgressProviderBullet(provider) { + const bulletIndex = lines.findIndex( + (line, index) => + index > responseEgressHeadingIndex && + index < responseEgressSectionEnd && + line.startsWith(`- ${provider} `), + ) + if (bulletIndex === -1) { + fail(`${capabilityPath} is missing the ${provider} response-egress caveat`) + } + const bullet = [lines[bulletIndex]] + for ( + let index = bulletIndex + 1; + index < responseEgressSectionEnd; + index += 1 + ) { + if (!lines[index].startsWith(' ')) break + bullet.push(lines[index].trim()) + } + return bullet.join(' ') +} + +const responseEgressProviderCaveats = new Map([ + [ + 'Axum', + [ + 'commits when Hyper accepts the response head', + 'closes the owned HTTP/1 connection on deadline', + 'reports clean source EOF as `HostHandoff`', + ], + ], + [ + 'Cloudflare', + [ + 'awaits JavaScript writer promises', + 'races request abort/deadline signals', + 'coordinator alive with `waitUntil`', + 'deployed disconnect and completion timing remain unproved', + ], + ], + [ + 'Fastly', + [ + 'accounts synchronous body-handle writes through `stream_to_client` close', + 'cannot preempt a blocked source poll or hostcall', + 'loses the body handle if `finish` itself fails', + ], + ], + [ + 'Spin', + [ + 'owns the WASI response/body/result writers through handoff', + 'races pending operations against its timer', + 'host teardown and network completion remain provider-observable only', + ], + ], +]) +for (const [provider, requiredFragments] of responseEgressProviderCaveats) { + const bullet = responseEgressProviderBullet(provider) + for (const requiredFragment of requiredFragments) { + if (!bullet.includes(requiredFragment)) { + fail( + `${capabilityPath} ${provider} response-egress caveat is missing: ${requiredFragment}`, + ) + } + } +} + +const limitsHeadingIndex = lines.findIndex( + (line) => line.trim() === '## Limits And Accounting', +) +if (limitsHeadingIndex === -1) { + fail('limits and accounting section is missing') +} +const limitsSectionEnd = lines.findIndex( + (line, index) => index > limitsHeadingIndex && line.startsWith('## '), +) +const limitsHeaderIndex = lines.findIndex( + (line, index) => + index > limitsHeadingIndex && + (limitsSectionEnd === -1 || index < limitsSectionEnd) && + JSON.stringify(cells(line)) === JSON.stringify(expectedLimitHeader), +) +if (limitsHeaderIndex === -1) { + fail('outbound limits table is missing') +} +const limitsSeparator = cells(lines[limitsHeaderIndex + 1] ?? '') +if ( + limitsSeparator === null || + limitsSeparator.length !== expectedLimitHeader.length || + !limitsSeparator.every((value) => /^:?-{3,}:?$/u.test(value)) +) { + fail('outbound limits table is missing its three-column separator row') +} +const actualLimitRows = [] +for (let index = limitsHeaderIndex + 2; index < lines.length; index += 1) { + const row = cells(lines[index]) + if (row === null) break + if (row.length !== expectedLimitHeader.length) { + fail(`outbound limits row ${index + 1} has ${row.length} cells, expected 3`) + } + actualLimitRows.push([normalizeCapability(row[0]), row[1], row[2]]) +} +if (JSON.stringify(actualLimitRows) !== JSON.stringify(expectedLimitRows)) { + fail( + `outbound limits mismatch\nexpected=${JSON.stringify(expectedLimitRows)}\nactual=${JSON.stringify(actualLimitRows)}`, + ) +} +for (const requiredFragment of [ + 'including fields later stripped', + "charge EdgeZero's synthetic proxy marker", +]) { + if (!capabilitySource.includes(requiredFragment)) { + fail( + `${capabilityPath} is missing the response-header accounting rule: ${requiredFragment}`, + ) + } +} + +let outboundCoreSource +try { + outboundCoreSource = readFileSync(outboundCorePath, 'utf8') +} catch (error) { + fail(`cannot read ${outboundCorePath}: ${error.message}`) +} +let compressionCoreSource +try { + compressionCoreSource = readFileSync(compressionCorePath, 'utf8') +} catch (error) { + fail(`cannot read ${compressionCorePath}: ${error.message}`) +} +for (const requiredFragment of [ + 'Deflate,', + 'Passthrough(PassthroughReason)', + 'pub enum PassthroughReason', + 'PassthroughReason::Stacked', + 'PassthroughReason::Malformed', + 'PassthroughReason::Unsupported(token)', + 'pub fn decode_deflate_stream', +]) { + if (!compressionCoreSource.includes(requiredFragment)) { + fail(`${compressionCorePath} is missing the content-encoding contract: ${requiredFragment}`) + } +} +for (const requiredFragment of [ + 'single `deflate`', + 'Passthrough `Unsupported(token)`', + 'Passthrough `Stacked`', + 'Passthrough `Malformed`', + 'decode_deflate_stream', +]) { + if (!outboundSpecSource.includes(requiredFragment)) { + fail(`${outboundSpecPath} is missing the content-encoding documentation: ${requiredFragment}`) + } +} +observationCutoffContract(outboundCoreSource) +if ( + !/#\[non_exhaustive\]\s*pub enum OutboundBatchDriverEvent\s*\{/u.test( + outboundCoreSource, + ) +) { + fail('OutboundBatchDriverEvent must remain non-exhaustive') +} +const retiredClockConstructor = ['new_with_', 'monotonic_clock'].join('') +if (outboundCoreSource.includes(retiredClockConstructor)) { + fail('OutboundResponse must not retain the hidden clock-paired constructor') +} +const outboundResponseConstructor = + /impl OutboundResponse \{[\s\S]*?pub fn new\(\s*request_method: Method,\s*status: StatusCode,\s*headers: HeaderMap,\s*body: Body,\s*monotonic_clock: MonotonicClock,\s*content_type_provenance: ContentTypeProvenance,\s*\) -> Self \{/u +if (!outboundResponseConstructor.test(outboundCoreSource)) { + fail( + 'OutboundResponse::new must require the application clock and original Content-Type provenance', + ) +} +for (const adapterPath of outboundAdapterPaths) { + const adapterSource = readFileSync(adapterPath, 'utf8') + const constructorCount = + adapterSource.match(/OutboundResponse::new\(/gu)?.length ?? 0 + const provenance = adapterPath.includes('cloudflare') + ? 'ContentTypeProvenance::Unknown' + : 'normalization\\.content_type_provenance' + const pairedClockCount = + adapterSource.match( + new RegExp(`response_clock,\\s*${provenance},\\s*\\)\\)`, 'gu'), + )?.length ?? 0 + if ( + constructorCount === 0 || + constructorCount !== pairedClockCount || + !adapterSource.includes('let response_clock = clock.clone();') + ) { + fail( + `${adapterPath} must pass the paired client clock and original Content-Type provenance (Unknown on Cloudflare) to every outbound response`, + ) + } + if (!adapterSource.includes('converted_response_retains_injected_clock')) { + fail(`${adapterPath} must behaviorally test the returned response clock`) + } +} +const documentedDefaults = new Map( + actualLimitRows.map((row) => [row[0], row[2]]), +) +for (const [control, constant] of [ + ['max_request_body_bytes', 'DEFAULT_OUTBOUND_REQUEST_BODY_BYTES'], + ['max_response_bytes', 'DEFAULT_MAX_RESPONSE_BYTES'], + ['max_decoder_bytes', 'DEFAULT_MAX_DECODER_BYTES'], +]) { + const rustDefault = formatBinaryBytes( + readRustU64Constant(outboundCoreSource, constant), + ) + const documentedDefault = documentedDefaults.get(control) + if (documentedDefault !== rustDefault) { + fail( + `${control} default mismatch: Rust ${constant} is ${rustDefault}, documentation says ${documentedDefault}`, + ) + } +} + +const sidebarSource = readFileSync(sidebarPath, 'utf8') +const sidebarLinks = + sidebarSource.match(/link:\s*['"]\/guide\/capabilities['"]/gu) ?? [] +if (sidebarLinks.length !== 1) { + fail( + `expected exactly one /guide/capabilities sidebar link, found ${sidebarLinks.length}`, + ) +} diff --git a/scripts/check_outbound_legacy_api.sh b/scripts/check_outbound_legacy_api.sh new file mode 100755 index 000000000..fdaf08c64 --- /dev/null +++ b/scripts/check_outbound_legacy_api.sh @@ -0,0 +1,1067 @@ +#!/usr/bin/env bash +set -uo pipefail + +scan() { + local pattern=$1 + shift + + if matches="$(git grep -nE "$pattern" -- "$@")"; then + printf '%s\n' "$matches" + return 1 + fi + + local status=$? + if [ "$status" -eq 1 ]; then + return 0 + fi + return "$status" +} + +scan_detached_send_tuple() { + node --input-type=module - "$@" <<'NODE' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' + +const send = ['DetachedResponseEgressDecision::', 'Send'].join('') +const [decision, sendVariant] = send.split('::') + +function skipRustTrivia(source, start) { + let index = start + + while (index < source.length) { + if (/\s/u.test(source[index])) { + index += 1 + continue + } + if (source.startsWith('//', index)) { + const newline = source.indexOf('\n', index + 2) + index = newline === -1 ? source.length : newline + 1 + continue + } + if (source.startsWith('/*', index)) { + let depth = 1 + index += 2 + while (index < source.length && depth > 0) { + if (source.startsWith('/*', index)) { + depth += 1 + index += 2 + } else if (source.startsWith('*/', index)) { + depth -= 1 + index += 2 + } else { + index += 1 + } + } + if (depth > 0) { + return null + } + continue + } + break + } + + return index +} + +function findLegacyDetachedSendConstructors(source) { + const matches = [] + let searchFrom = 0 + + while (searchFrom < source.length) { + const start = source.indexOf(decision, searchFrom) + if (start === -1) { + break + } + searchFrom = start + decision.length + + if (start > 0 && /[A-Za-z0-9_]/u.test(source[start - 1])) { + continue + } + + let index = skipRustTrivia(source, searchFrom) + if (index === null || !source.startsWith('::', index)) { + continue + } + index = skipRustTrivia(source, index + 2) + if (index === null || !source.startsWith(sendVariant, index)) { + continue + } + index = skipRustTrivia(source, index + sendVariant.length) + if (index === null || source[index] !== '(') { + continue + } + + matches.push({ + index: start, + text: source.slice(start, index + 1), + }) + } + + return matches +} + +let selfTestFailed = false +for (const [name, source] of [ + ['same-line tuple', `${send}(completion)`], + ['newline tuple', `${send}\n(completion)`], + ['line-comment tuple', `${send} // legacy\n(completion)`], + ['block-comment tuple', `${send} /* legacy */ (completion)`], + [ + 'comment before variant', + `${decision}:: /* legacy */ ${sendVariant}(completion)`, + ], + ['newline before path separator', `${decision}\n::${sendVariant}(completion)`], + [ + 'trivia around path separator', + `${decision} /* before */ :: /* after */ ${sendVariant}(completion)`, + ], + [ + 'nested block-comment trivia', + `${decision} /* outer /* nested */ outer */ :: ` + + `/* outer /* nested */ outer */ ${sendVariant} ` + + '/* outer /* nested */ outer */ (completion)', + ], +]) { + if (findLegacyDetachedSendConstructors(source).length !== 1) { + process.stderr.write(`legacy tuple checker self-test failed: ${name}\n`) + selfTestFailed = true + } +} +for (const source of [ + `${send} { completion, deadline }`, + ['"DetachedResponseEgressDecision::",', '"Send("'].join('\n'), +]) { + if (findLegacyDetachedSendConstructors(source).length !== 0) { + process.stderr.write('legacy tuple checker self-test matched a supported form\n') + selfTestFailed = true + } +} +if (selfTestFailed) { + process.exit(2) +} + +const roots = process.argv.slice(2) +const docsIndexFixture = { + path: 'docs/index.md', + source: `${send} /* legacy */ (completion)`, +} +if ( + !roots.includes(docsIndexFixture.path) || + findLegacyDetachedSendConstructors(docsIndexFixture.source).length !== 1 +) { + process.stderr.write('legacy tuple checker did not reject the docs index fixture\n') + process.exit(2) +} +const tracked = execFileSync('git', ['ls-files', '-z', '--', ...roots]) + .toString('utf8') + .split('\0') + .filter(Boolean) +let failed = false +for (const path of tracked) { + const source = readFileSync(path, 'utf8') + for (const match of findLegacyDetachedSendConstructors(source)) { + const line = source.slice(0, match.index).split(/\r?\n/u).length + process.stdout.write(`${path}:${line}:${match.text.replace(/\s+/gu, ' ')}\n`) + failed = true + } +} +process.exit(failed ? 1 : 0) +NODE +} + +scan_platform_resource_legacy() { + node --input-type=module - "$@" <<'NODE' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' + +const memory = ['Memory', 'Ceiling'].join('') +const metadata = ['Platform', 'Metadata'].join('') +const retiredSource = [memory, 'Source'].join('') +const retiredAdapterMethod = ['Adapter::', 'memory_', 'ceiling'].join('') +const retiredClockConstructor = ['new_with_', 'monotonic_clock'].join('') +const retiredByValueAccessor = + `pub const fn memory_${'ceiling'}(self) -> Option<${memory}>` +const allowlist = new Set([ + 'CHANGELOG.md', + 'docs/superpowers/specs/2026-09-28-platform-resource-metadata-design.md', +]) +const authoritativePlan = + 'docs/superpowers/plans/2026-09-28-platform-resource-metadata.md' + +function maskRange(masked, source, start, end) { + for (let index = start; index < end; index += 1) { + if (source[index] !== '\n' && source[index] !== '\r') masked[index] = ' ' + } +} + +function rawStringEnd(source, start) { + let rawPrefix = start + if ( + (source[start] === 'b' || source[start] === 'c') && + source[start + 1] === 'r' + ) { + rawPrefix += 1 + } + if (source[rawPrefix] !== 'r') return null + if (start > 0 && /[A-Za-z0-9_]/u.test(source[start - 1])) return null + + let quote = rawPrefix + 1 + while (source[quote] === '#') quote += 1 + if (source[quote] !== '"') return null + const hashes = source.slice(rawPrefix + 1, quote) + const closing = `"${hashes}` + const closingStart = source.indexOf(closing, quote + 1) + return closingStart === -1 ? source.length : closingStart + closing.length +} + +function characterLiteralEnd(source, start) { + if (source[start] !== "'") return null + let index = start + 1 + if (source[index] === '\\') { + index += 1 + if (source[index] === 'u' && source[index + 1] === '{') { + const brace = source.indexOf('}', index + 2) + if (brace === -1) return null + index = brace + 1 + } else { + index += 1 + } + } else { + const codePoint = source.codePointAt(index) + if (codePoint === undefined || source[index] === '\n' || source[index] === '\r') { + return null + } + index += codePoint > 0xffff ? 2 : 1 + } + return source[index] === "'" ? index + 1 : null +} + +function maskRustCommentsAndLiterals(source) { + const masked = source.split('') + let index = 0 + while (index < source.length) { + if (source.startsWith('//', index)) { + const newline = source.indexOf('\n', index + 2) + const end = newline === -1 ? source.length : newline + maskRange(masked, source, index, end) + index = end + continue + } + if (source.startsWith('/*', index)) { + let depth = 1 + let end = index + 2 + while (end < source.length && depth > 0) { + if (source.startsWith('/*', end)) { + depth += 1 + end += 2 + } else if (source.startsWith('*/', end)) { + depth -= 1 + end += 2 + } else { + end += 1 + } + } + maskRange(masked, source, index, end) + index = end + continue + } + const rawEnd = rawStringEnd(source, index) + if (rawEnd !== null) { + maskRange(masked, source, index, rawEnd) + index = rawEnd + continue + } + if (source[index] === '"') { + let end = index + 1 + while (end < source.length) { + if (source[end] === '\\') { + end += 2 + } else if (source[end] === '"') { + end += 1 + break + } else { + end += 1 + } + } + maskRange(masked, source, index, end) + index = end + continue + } + const characterEnd = characterLiteralEnd(source, index) + if (characterEnd !== null) { + maskRange(masked, source, index, characterEnd) + index = characterEnd + continue + } + index += 1 + } + return masked.join('') +} + +function closingDelimiter(source, open) { + const pairs = new Map([ + ['(', ')'], + ['[', ']'], + ['{', '}'], + ]) + const stack = [pairs.get(source[open])] + for (let index = open + 1; index < source.length; index += 1) { + if (source.startsWith('//', index)) { + const newline = source.indexOf('\n', index + 2) + index = newline === -1 ? source.length : newline + } else if (source.startsWith('/*', index)) { + let depth = 1 + index += 2 + while (index < source.length && depth > 0) { + if (source.startsWith('/*', index)) { + depth += 1 + index += 2 + } else if (source.startsWith('*/', index)) { + depth -= 1 + index += 2 + } else { + index += 1 + } + } + index -= 1 + } else { + const rawEnd = rawStringEnd(source, index) + if (rawEnd !== null) { + index = rawEnd - 1 + continue + } + if (source[index] === '"') { + index += 1 + while (index < source.length && source[index] !== '"') { + if (source[index] === '\\') index += 1 + index += 1 + } + continue + } + const characterEnd = characterLiteralEnd(source, index) + if (characterEnd !== null) { + index = characterEnd - 1 + continue + } + if (pairs.has(source[index])) { + stack.push(pairs.get(source[index])) + } else if (source[index] === stack.at(-1)) { + stack.pop() + if (stack.length === 0) return index + } + } + } + return -1 +} + +function closingGenericList(source, open) { + let angleDepth = 1 + const delimiters = [] + const pairs = new Map([ + ['(', ')'], + ['[', ']'], + ['{', '}'], + ]) + for (let index = open + 1; index < source.length; index += 1) { + if (pairs.has(source[index])) { + delimiters.push(pairs.get(source[index])) + continue + } + if (source[index] === delimiters.at(-1)) { + delimiters.pop() + continue + } + if (delimiters.length !== 0) continue + if (source[index] === '<') angleDepth += 1 + else if (source[index] === '>' && source[index - 1] !== '-') { + angleDepth -= 1 + if (angleDepth === 0) return index + } + } + return -1 +} + +function previousNonWhitespace(source, start, count) { + const characters = [] + for (let index = start - 1; index >= 0 && characters.length < count; index -= 1) { + if (!/\s/u.test(source[index])) characters.unshift(source[index]) + } + return characters.join('') +} + +function closureParametersStart(source, argumentStart, pipe) { + const prefix = source.slice(argumentStart, pipe).trim() + return /^(?:(?:&(?:\s*mut)?|[*!-])\s*)*(?:async\s+)?(?:move\s+)?$/u.test(prefix) +} + +function topLevelArguments(source, open, close) { + const argumentsList = [] + let start = open + 1 + const delimiters = [] + let angleDepth = 0 + let closureParameters = false + + for (let index = start; index <= close; index += 1) { + if (source.startsWith('//', index)) { + const newline = source.indexOf('\n', index + 2) + index = newline === -1 ? close : Math.min(newline, close) + } else if (source.startsWith('/*', index)) { + let depth = 1 + index += 2 + while (index < close && depth > 0) { + if (source.startsWith('/*', index)) { + depth += 1 + index += 2 + } else if (source.startsWith('*/', index)) { + depth -= 1 + index += 2 + } else { + index += 1 + } + } + index -= 1 + continue + } + + const rawEnd = rawStringEnd(source, index) + if (rawEnd !== null) { + index = rawEnd - 1 + continue + } + if (source[index] === '"') { + index += 1 + while (index < close && source[index] !== '"') { + if (source[index] === '\\') index += 1 + index += 1 + } + continue + } + const characterEnd = characterLiteralEnd(source, index) + if (characterEnd !== null) { + index = characterEnd - 1 + continue + } + + if (closureParameters) { + if (source[index] === '|') closureParameters = false + continue + } + if ( + delimiters.length === 0 && + angleDepth === 0 && + source[index] === '|' && + closureParametersStart(source, start, index) + ) { + if (source[index + 1] === '|') index += 1 + else closureParameters = true + continue + } + + if (source[index] === '<' && (angleDepth > 0 || previousNonWhitespace(source, index, 2) === '::')) { + angleDepth += 1 + continue + } + if (source[index] === '>' && angleDepth > 0) { + angleDepth -= 1 + continue + } + if (angleDepth > 0) continue + + if ('([{'.includes(source[index])) { + delimiters.push(new Map([ + ['(', ')'], + ['[', ']'], + ['{', '}'], + ]).get(source[index])) + continue + } + if (source[index] === delimiters.at(-1)) { + delimiters.pop() + continue + } + if (index === close || (source[index] === ',' && delimiters.length === 0)) { + const argument = source.slice(start, index).trim() + if (argument !== '') argumentsList.push(argument) + start = index + 1 + } + } + return argumentsList +} + +function receiverKind(receiver) { + if (/^(?:mut\s+)?self(?:\s*:|$)/u.test(receiver)) return 'owned' + if (/^&\s*(?:'[A-Za-z_][A-Za-z0-9_]*\s+)?(?:mut\s+)?self(?:\s*:|$)/u.test(receiver)) { + return 'borrowed' + } + return null +} + +function retiredMemoryAccessorDeclarations(source, originalSource) { + const matches = [] + const declaration = /\bfn\s+memory_ceiling\b/gu + for (const match of source.matchAll(declaration)) { + let open = match.index + match[0].length + while (/\s/u.test(source[open] ?? '')) open += 1 + if (source[open] === '<') { + const genericClose = closingGenericList(source, open) + if (genericClose === -1) continue + open = genericClose + 1 + while (/\s/u.test(source[open] ?? '')) open += 1 + } + if (source[open] !== '(') continue + const close = closingDelimiter(source, open) + if (close === -1) continue + const argumentsList = topLevelArguments(source, open, close) + if (argumentsList.length !== 1) continue + const receiver = receiverKind(argumentsList[0]) + if (receiver === null) continue + + const returnType = source.slice(close + 1).match(/^\s*->\s*((?:(?:std|core)\s*::\s*option\s*::\s*)?Option)\b/u) + if (returnType === null) continue + const end = close + 1 + returnType.index + returnType[0].length + matches.push({ index: match.index, text: originalSource.slice(match.index, end) }) + } + return matches +} + +function constructorMatches(source, originalSource, typeName, argumentCount) { + const constructor = new RegExp( + `\\b${typeName}\\b\\s*::\\s*new\\s*\\(`, + 'gu', + ) + const matches = [] + for (const match of source.matchAll(constructor)) { + const start = match.index + const open = start + match[0].lastIndexOf('(') + const close = closingDelimiter(source, open) + if (close === -1) continue + if (topLevelArguments(source, open, close).length === argumentCount) { + matches.push({ index: start, text: originalSource.slice(start, close + 1) }) + } + } + return matches +} + +function finalNamedCutoffArguments(source, originalSource) { + const matches = [] + const call = /\b(?:start_batch_until|send_all_until)\s*\(/gu + for (const match of source.matchAll(call)) { + const open = match.index + match[0].lastIndexOf('(') + const close = closingDelimiter(source, open) + if (close === -1) continue + const finalArgument = topLevelArguments(source, open, close).at(-1) + if (/^(?:cutoff|_cutoff|batch_cutoff)$/u.test(finalArgument ?? '')) { + matches.push({ + index: match.index, + text: originalSource.slice(match.index, close + 1), + }) + } + } + return matches +} + +function regexMatches(source, pattern, originalSource) { + return [...source.matchAll(pattern)].map((match) => ({ + index: match.index, + text: originalSource.slice(match.index, match.index + match[0].length), + })) +} + +function findLegacyForms(source, rustSource = false, originalSource = source) { + const searchable = rustSource ? maskRustCommentsAndLiterals(source) : source + const memoryAccessor = ['memory_', 'ceiling'].join('') + return [ + ...regexMatches( + searchable, + new RegExp(`\\b${retiredSource}\\b`, 'gu'), + originalSource, + ), + ...regexMatches( + searchable, + new RegExp(`\\b${retiredAdapterMethod.replace('::', '\\s*::\\s*')}\\b`, 'gu'), + originalSource, + ), + ...retiredMemoryAccessorDeclarations(searchable, originalSource), + ...regexMatches( + searchable, + new RegExp(`\\b${retiredClockConstructor}\\b`, 'gu'), + originalSource, + ), + ...regexMatches( + searchable, + new RegExp( + `(?:${metadata}\\s*::\\s*|\\.)${memoryAccessor}\\s*\\(\\s*\\)\\s*->\\s*Option\\b`, + 'gu', + ), + originalSource, + ), + ...regexMatches( + searchable, + /\b(?:cutoff|_cutoff|batch_cutoff)\s*:\s*(?:Option\s*<\s*)?(?:[A-Za-z_][A-Za-z0-9_]*\s*::\s*)*Deadline\s*>?/gu, + originalSource, + ), + ...regexMatches( + searchable, + /\blet\s+(?:mut\s+)?(?:cutoff|_cutoff|batch_cutoff)\b/gu, + originalSource, + ), + ...constructorMatches(searchable, originalSource, metadata, 1), + ...constructorMatches(searchable, originalSource, memory, 4), + ...finalNamedCutoffArguments(searchable, originalSource), + ] +} + +function isRustSource(path) { + return path.endsWith('.rs') || path.endsWith('.rs.hbs') +} + +function isMarkdownSource(path) { + return path.endsWith('.md') || path.endsWith('.mdx') +} + +function fenceOpening(line) { + const match = /^ {0,3}(`{3,}|~{3,})[ \t]*([^\r\n]*)$/u.exec(line) + if (match === null) return null + if (match[1][0] === '`' && match[2].includes('`')) return null + const language = match[2].trim().split(/[:,\s]/u, 1)[0].toLowerCase() + return { + marker: match[1][0], + minimumLength: match[1].length, + rust: language === 'rust' || language === 'rs', + } +} + +function isFenceClosing(line, fence) { + let index = 0 + while (index < 3 && line[index] === ' ') index += 1 + let markerLength = 0 + while (line[index + markerLength] === fence.marker) markerLength += 1 + return ( + markerLength >= fence.minimumLength && + line.slice(index + markerLength).trim() === '' + ) +} + +function copyRange(view, source, start, end) { + for (let index = start; index < end; index += 1) view[index] = source[index] +} + +function markdownViews(source) { + const prose = source.split('') + const rust = source.split('') + maskRange(prose, source, 0, source.length) + maskRange(rust, source, 0, source.length) + let fence = null + let lineStart = 0 + + while (lineStart < source.length) { + const newline = source.indexOf('\n', lineStart) + const lineEnd = newline === -1 ? source.length : newline + const nextLine = newline === -1 ? source.length : newline + 1 + const line = source.slice(lineStart, lineEnd).replace(/\r$/u, '') + + if (fence === null) { + const opening = fenceOpening(line) + if (opening !== null) { + fence = { + ...opening, + contentStart: newline === -1 ? source.length : newline + 1, + } + } else { + copyRange(prose, source, lineStart, nextLine) + } + } else if (isFenceClosing(line, fence)) { + if (fence.rust) copyRange(rust, source, fence.contentStart, lineStart) + fence = null + } + + if (newline === -1) break + lineStart = nextLine + } + + if (fence?.rust) copyRange(rust, source, fence.contentStart, source.length) + return { prose: prose.join(''), rust: rust.join('') } +} + +function deduplicateMatches(matches) { + const unique = new Map() + for (const match of matches) { + unique.set(`${match.index}:${match.text}`, match) + } + return [...unique.values()].sort( + (left, right) => left.index - right.index || left.text.localeCompare(right.text), + ) +} + +function findLegacyFormsForPath(path, source) { + if (isRustSource(path)) return findLegacyForms(source, true) + if (!isMarkdownSource(path)) return findLegacyForms(source) + const views = markdownViews(source) + const proseMatches = findLegacyForms(views.prose, false, source) + const rustFenceMatches = findLegacyForms(views.rust, true, source) + return deduplicateMatches([...proseMatches, ...rustFenceMatches]) +} + +const bareCutoff = ['cut', 'off'].join('') +const batchCutoff = `batch_${bareCutoff}` +const fixtures = [ + retiredSource, + `pub type ${retiredSource} = PlatformResourceSource;`, + `pub /* compatibility */ type /* name */ ${retiredSource} = PlatformResourceSource;`, + retiredAdapterMethod, + `Adapter /* path */ :: /* method */ memory_${'ceiling'}`, + `fn /* trait */ memory_${'ceiling'} /* call */ ( /* receiver */ & /* borrow */ self /* end */ ) -> Option<${memory}> {}`, + retiredByValueAccessor, + `pub(crate) unsafe fn memory_${'ceiling'}(mut self) -> Option<${memory}>`, + `pub async fn memory_${'ceiling'}(&mut self) -> Option<${memory}>`, + `pub fn memory_${'ceiling'}<'a>(&'a self) -> Option<${memory}>`, + `pub fn memory_${'ceiling'} Option>(&self) -> Option<${memory}>`, + `${metadata}::new(memory)`, + `${metadata} /* type */ :: /* path */ new /* call */ (memory /*, ignored */)`, + `${metadata}::new(make::<'a, A, B>(memory))`, + `${metadata}::new(make::())`, + `${metadata}::new(|left, right| combine(left, right))`, + `${metadata}::new(&|left, right| combine(left, right))`, + `${metadata}::new(&mut |left, right| combine(left, right))`, + `${memory}::new(total, scope, stack, source)`, + `${memory} /* type */ :: /* path */ new /* call */ (total, scope /*, ignored */, stack, source)`, + `${memory}::new(make::(), scope, stack, source)`, + `${memory}::new(total, |left, right| scope(left, right), stack, source)`, + `${memory}::new(total, &|left, right| scope(left, right), stack, source)`, + retiredClockConstructor, + `${bareCutoff}: Deadline`, + `let ${batchCutoff} = deadline();`, + `client.send_all_until(requests, ${bareCutoff})`, +] +const supported = [ + `${metadata}::new(memory, population, accounting)`, + `${metadata}::new(memory, population /*, ignored */, accounting)`, + `${metadata}::new(make::(), population, |left, right| accounting(left, right))`, + `${metadata}::new(make::(), population, &|left, right| accounting(left, right))`, + `${memory}::new(total, scope, stack)`, + `${memory}::new(total, scope /*, ignored */, stack)`, + `${memory}::new(make::<'a, A, B>(), |left, right| scope(left, right), stack)`, + `pub const fn memory_${'ceiling'}(self) -> PlatformFact<${memory}>`, + `pub const fn memory_${'ceiling'}(&self) -> PlatformFact<${memory}>`, + `pub fn memory_${'ceiling'} Option>(&self) -> PlatformFact<${memory}>`, + 'fn platform_metadata(&self) -> PlatformMetadata {}', + 'observation_cutoff: Deadline', + 'let observation_cutoff = deadline();', + 'client.send_all_until(requests, observation_cutoff)', + `// ${retiredSource}\nconst VALUE: usize = 1;`, + `/* ${retiredAdapterMethod} */ const VALUE: usize = 1;`, + `const TEXT: &str = "${metadata}::new(memory)";`, + `const RAW: &str = r#"fn memory_${'ceiling'}(&self)"#;`, + `const RAW_C: &CStr = cr#"quoted " ${retiredSource}"#;`, + `const BYTE: u8 = b'${bareCutoff.charAt(0)}';`, +] + +for (const fixture of fixtures) { + if (findLegacyForms(fixture, true).length === 0) { + process.stderr.write(`platform metadata checker missed fixture: ${fixture}\n`) + process.exit(2) + } +} +for (const fixture of supported) { + if (findLegacyForms(fixture, true).length !== 0) { + process.stderr.write(`platform metadata checker rejected supported fixture: ${fixture}\n`) + process.exit(2) + } +} +for (const fixture of [ + `Prose names ${retiredSource}.`, + `\`${metadata}::new(memory)\``, + ``, +]) { + if (findLegacyFormsForPath('docs/guide/legacy-fixture.md', fixture).length === 0) { + process.stderr.write('platform metadata checker masked a Markdown fixture\n') + process.exit(2) + } +} +for (const [name, fixture, expectedStart] of [ + [ + 'plain Rust fence', + `before\n\`\`\`rust\n${metadata} /* trivia */ :: new(memory)\n\`\`\`\nafter`, + metadata, + ], + [ + 'modified Rust fence', + `\`\`\`rust,no_run\nAdapter /* path */ :: /* method */ memory_${'ceiling'}\n\`\`\``, + 'Adapter', + ], + [ + 'modified rs fence', + `\`\`\`rs,ignore\nfn /* trait */ memory_${'ceiling'} /* call */ ( & /* receiver */ self ) -> Option<${memory}> {}\n\`\`\``, + 'fn', + ], + [ + 'tilde Rust fence with colon modifier', + `~~~rust:no_run\n${metadata} /* trivia */ :: new(memory)\n~~~~`, + metadata, + ], + [ + 'indented Rust fence with space modifier', + ` \`\`\`rust no_run\nAdapter /* path */ :: memory_${'ceiling'}\n \`\`\``, + 'Adapter', + ], + [ + 'variable-length rs fence', + `\`\`\`\`rs:ignore\n${metadata} /* trivia */ :: new(memory)\n\`\`\`\`\``, + metadata, + ], +]) { + const matches = findLegacyFormsForPath('docs/guide/legacy-fixture.md', fixture) + if (matches.length !== 1 || matches[0].index !== fixture.indexOf(expectedStart)) { + process.stderr.write(`platform metadata checker missed ${name}\n`) + process.exit(2) + } +} +const nonRustFence = + `\`\`\`text\n${metadata} /* trivia */ :: new(memory)\n\`\`\`` +if (findLegacyFormsForPath('docs/guide/legacy-fixture.md', nonRustFence).length !== 0) { + process.stderr.write('platform metadata checker parsed a non-Rust fence as Rust\n') + process.exit(2) +} +const nonRustRawHistory = `\`\`\`text\n${retiredSource}\n\`\`\`` +if (findLegacyFormsForPath('docs/guide/legacy-fixture.md', nonRustRawHistory).length !== 0) { + process.stderr.write('platform metadata checker scanned a non-Rust fence\n') + process.exit(2) +} +const invalidBacktickFence = + `\`\`\`rust\`bad\n${metadata}::new(memory)\n\`\`\`` +if (findLegacyFormsForPath('docs/guide/legacy-fixture.md', invalidBacktickFence).length === 0) { + process.stderr.write('platform metadata checker treated an invalid backtick fence as a fence\n') + process.exit(2) +} +const rustNonCodeFixture = + `\`\`\`rust\n// ${retiredSource}\n` + + `const TEXT: &str = "${retiredByValueAccessor}";\n` + + `const RAW: &str = r#"${retiredAdapterMethod}"#;\n\`\`\`` +if ( + findLegacyFormsForPath('docs/guide/legacy-fixture.md', rustNonCodeFixture).length !== 0 +) { + process.stderr.write('platform metadata checker scanned Rust fence non-code\n') + process.exit(2) +} +const duplicateFixture = `\`\`\`rust\n${metadata}::new(memory)\n\`\`\`` +if (findLegacyFormsForPath('docs/guide/legacy-fixture.md', duplicateFixture).length !== 1) { + process.stderr.write('platform metadata checker did not deduplicate Markdown matches\n') + process.exit(2) +} + +const roots = process.argv.slice(2) +const isAllowlisted = (path) => allowlist.has(path) +const rootCovers = (root, path) => path === root || path.startsWith(`${root}/`) +const requiredRootError = (candidateRoots) => + candidateRoots.some((root) => rootCovers(root, authoritativePlan)) + ? null + : `platform metadata checker does not scan ${authoritativePlan}` +const rootsWithPlan = [...roots, authoritativePlan] +const rootsWithoutPlan = rootsWithPlan.filter( + (root) => !rootCovers(root, authoritativePlan), +) +if ( + requiredRootError(rootsWithPlan) !== null || + requiredRootError(rootsWithoutPlan) === null +) { + process.stderr.write('platform metadata checker plan-root self-test failed\n') + process.exit(2) +} +const rootError = requiredRootError(roots) +if (rootError !== null) { + process.stderr.write(`${rootError}\n`) + process.exit(2) +} +for (const root of roots) { + const fixturePath = root.includes('.') ? root : `${root}/legacy-fixture.rs` + if (isAllowlisted(fixturePath)) continue + if (findLegacyFormsForPath(fixturePath, retiredSource).length === 0) { + process.stderr.write( + `platform metadata checker did not reject a fixture in ${fixturePath}\n`, + ) + process.exit(2) + } +} +for (const path of allowlist) { + const nearMiss = `${path}.active` + if ( + !isAllowlisted(path) || + isAllowlisted(nearMiss) || + findLegacyForms(fixtures.join('\n')).length === 0 + ) { + process.stderr.write(`platform metadata checker allowlist self-test failed for ${path}\n`) + process.exit(2) + } +} +if (allowlist.size !== 2) { + process.stderr.write('platform metadata checker allowlist must contain exactly two paths\n') + process.exit(2) +} + +const tracked = execFileSync('git', [ + 'ls-files', + '--cached', + '--others', + '--exclude-standard', + '-z', + '--', + ...roots, +]) + .toString('utf8') + .split('\0') + .filter(Boolean) +let failed = false +for (const path of tracked) { + if (isAllowlisted(path)) continue + const source = readFileSync(path, 'utf8') + for (const match of findLegacyFormsForPath(path, source)) { + const line = source.slice(0, match.index).split(/\r?\n/u).length + process.stdout.write(`${path}:${line}:${match.text.replace(/\s+/gu, ' ')}\n`) + failed = true + } +} +process.exit(failed ? 1 : 0) +NODE +} + +active_surfaces=( + crates + examples/app-demo + tests/fixtures/reusable-app + .github/actions + .github/workflows + docs/.vitepress + docs/guide + docs/index.md + docs/specs + docs/superpowers/specs + docs/superpowers/plans/2026-09-26-platform-memory-ceiling.md + docs/superpowers/plans/2026-09-28-platform-resource-metadata.md + scripts + README.md + CLAUDE.md + TODO.md + Cargo.toml + CHANGELOG.md +) + +assert_active_surface_fixture_rejected() { + local path=$1 + local pattern=$2 + local source=$3 + local active + + for active in "${active_surfaces[@]}"; do + if [ "$active" = "$path" ]; then + if printf '%s\n' "$source" | grep -Eq "$pattern"; then + return 0 + fi + printf 'legacy checker did not reject the %s fixture\n' "$path" >&2 + return 2 + fi + done + + printf 'legacy checker does not scan the %s fixture\n' "$path" >&2 + return 2 +} + +failed=0 + +scan \ + 'Proxy(Client|Handle|Request|Response|Service)|proxy_handle|edgezero_core::proxy|crate::proxy|pub mod proxy' \ + crates examples/app-demo tests/fixtures/reusable-app docs/guide README.md CLAUDE.md TODO.md Cargo.toml \ + .claude/agents/code-architect.md || failed=1 + +# Downstream response delivery is now adapter-owned. Keep the outbound-fetch +# buffering helpers: they implement ResponseMode::Buffered and are unrelated to +# response egress. +scan \ + 'ResponseReturned|begin_owned|[A-Z]+_RESPONSE_STREAM_BUFFER_BYTES|SpinFullResponse|Captured(Cloudflare|Spin)Response|EdgeZeroAxumService|dispatch_for_test|pub (async )?fn (from_core_response|into_axum_response)|buffered (fallback|passthrough)|16 MiB downstream conversion fallback|Response -> Workers Response \(buffered bodies\)' \ + crates examples/app-demo tests/fixtures/reusable-app docs/guide README.md TODO.md Cargo.toml \ + ':(exclude)crates/edgezero-cli/src/generator.rs' || failed=1 + +# The generator keeps a negative assertion containing this spelling. Scan the +# rendered source inputs, demos, and user-facing guides where an attribute would +# preserve the retired Fastly response-returning entrypoint. +scan \ + '#\[fastly::main\]' \ + crates/edgezero-adapter-fastly/src/templates examples/app-demo docs/guide README.md || failed=1 + +# The completion-driven hard cut removes the terminal-vector-only API and the +# request-extension-only Fastly lifecycle. The generator's negative assertion +# intentionally contains `.send_all(` and is excluded here. +scan \ + '\.send_all\(|SendAllSlotIsolation|send-all-slot-isolation|(run|serve)_app_with_request_extensions' \ + crates examples/app-demo tests/fixtures/reusable-app docs/guide README.md CLAUDE.md TODO.md \ + ':(exclude)crates/edgezero-cli/src/generator.rs' || failed=1 + +# Batch termination is explicit. Adapter drivers cannot use stream EOF as a +# cutoff signal, and callers cannot interpret every missing item as timeout. +scan \ + 'OutboundBatch::from_stream|while[[:space:]]+let[[:space:]]+Some\([^)]*\)[[:space:]]*=[[:space:]]*batch\.next\(\)\.await|send_all_until\(.*\)\.await\.slots|Result' \ + crates examples/app-demo tests/fixtures/reusable-app docs/guide README.md CLAUDE.md TODO.md || failed=1 + +# Generated/demo Fastly entrypoints must delegate transmission to EdgeZero. +scan \ + 'stream_to_client|send_to_client|send_with_registries' \ + crates/edgezero-adapter-fastly/src/templates examples/app-demo/crates/app-demo-adapter-fastly || failed=1 + +# Application assembly and ingress response ownership are hard-cut contracts. +# Configuration must be fallible, every response-producing admission variant +# uses named fields, and response completion never travels through HTTP +# extensions or an envelope escape hatch. +scan \ + 'fn configure(_app)?\([^)]*&mut (EdgeZeroApp|edgezero_core::app::App|App)[^)]*\)[[:space:]]*\{' \ + crates examples/app-demo tests/fixtures/reusable-app docs/guide README.md || failed=1 + +scan \ + 'fn build_app\(\)[[:space:]]*->[[:space:]]*App|AdmissionDecision::Refuse[[:space:]]*\(|ResponseEgressEnvelope[^[:space:]]*\.into_response|envelope\.into_response\(\)' \ + crates examples/app-demo tests/fixtures/reusable-app docs/guide README.md || failed=1 + +scan \ + '(extensions(_mut)?\(\)|extensions)\.insert\(ResponseEgressCompletion' \ + crates examples/app-demo tests/fixtures/reusable-app docs/guide README.md \ + ':(exclude)crates/edgezero-core/src/response_egress.rs' || failed=1 + +# Normalized ingress errors use one typed send-or-abort decision. The generator +# keeps the retired setter spelling only in a negative assertion. +scan \ + 'DetachedResponseEgressCompletionFactory|set_detached_response_egress_completion_factory' \ + crates examples/app-demo tests/fixtures/reusable-app docs/guide docs/superpowers/specs README.md \ + ':(exclude)crates/edgezero-cli/src/generator.rs' || failed=1 + +# Detached response egress uses named fields only. This scanner spans comments +# and newlines, and its embedded fixtures keep those cases covered in CI. +scan_detached_send_tuple "${active_surfaces[@]}" || failed=1 + +# Platform resource facts hard-cut the old memory-only trait and constructors. +# Only the migration record and focused design retain historical signatures. +scan_platform_resource_legacy "${active_surfaces[@]}" || failed=1 + +# Response-owned deadlines use explicit absolute or egress-start-relative constructors. Keep the +# removed ambiguous constructor and unresolved accessor out of active code and documentation. +deadline_legacy_pattern='ResponseEgressDeadline::ne' +deadline_legacy_pattern+='w[[:space:]]*\(|ResponseEgressDeadline::dead' +deadline_legacy_pattern+='line|pub[[:space:]]+(const[[:space:]]+)?fn[[:space:]]+dead' +deadline_legacy_pattern+='line\(self\)' +deadline_legacy_fixture='ResponseEgressDeadline::ne' +deadline_legacy_fixture+='w(deadline)' +assert_active_surface_fixture_rejected \ + docs/index.md "$deadline_legacy_pattern" "$deadline_legacy_fixture" || failed=1 +scan "$deadline_legacy_pattern" "${active_surfaces[@]}" || failed=1 + +# Retired staging command identifiers must not return to active code or public +# documentation. Historical plans are outside these paths, and the exact terms +# below intentionally do not match ordinary Fastly "staged" provider-state prose. +staging_pattern='DeploySta' +staging_pattern+='ged|deploy_sta' +staging_pattern+='ged|deploy-sta' +staging_pattern+='ged|--sta' +staging_pattern+='ged' +staging_fixture='deploy-sta' +staging_fixture+='ged' +assert_active_surface_fixture_rejected \ + docs/index.md "$staging_pattern" "$staging_fixture" || failed=1 +scan "$staging_pattern" "${active_surfaces[@]}" || failed=1 + +exit "$failed" diff --git a/scripts/check_serde_json_map_contract.mjs b/scripts/check_serde_json_map_contract.mjs new file mode 100644 index 000000000..ffcd99fac --- /dev/null +++ b/scripts/check_serde_json_map_contract.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' + +const tree = execFileSync( + 'cargo', + [ + 'tree', + '--locked', + '--offline', + '--package', + 'edgezero-cli', + '--no-default-features', + '--edges', + 'features,no-dev', + '--invert', + 'serde_json', + ], + { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, +) + +const forbiddenFeatures = [ + 'serde_json feature "indexmap"', + 'serde_json feature "preserve_order"', +] + +for (const feature of forbiddenFeatures) { + if (tree.includes(feature)) { + process.stderr.write( + `serde_json map contract: no-CLI graph enables ${feature}\n`, + ) + process.exitCode = 1 + } +} + +if (process.exitCode === undefined) { + process.stdout.write('serde_json map contract: ok\n') +} diff --git a/scripts/flate_dependency_contract.json b/scripts/flate_dependency_contract.json new file mode 100644 index 000000000..a6cba8c19 --- /dev/null +++ b/scripts/flate_dependency_contract.json @@ -0,0 +1,42 @@ +[ + [ + "adler2", + "2.0.1", + "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + ], + [ + "async-compression", + "0.4.43", + "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" + ], + [ + "compression-codecs", + "0.4.38", + "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" + ], + [ + "compression-core", + "0.4.32", + "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + ], + [ + "crc32fast", + "1.5.0", + "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" + ], + [ + "flate2", + "1.1.9", + "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" + ], + [ + "miniz_oxide", + "0.8.9", + "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" + ], + [ + "simd-adler32", + "0.3.9", + "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + ] +] diff --git a/scripts/qualify_response_egress.mjs b/scripts/qualify_response_egress.mjs new file mode 100644 index 000000000..db4c2af71 --- /dev/null +++ b/scripts/qualify_response_egress.mjs @@ -0,0 +1,241 @@ +import { writeFile } from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import { request } from "node:https"; +import { pathToFileURL } from "node:url"; + +export const CASES = ["normal", "head", "204", "205", "304", "slow", "nonread", + "disconnect", "cancel", "pending", "expired", "source-error", "conversion-error"]; +const TOKEN = /^[a-z0-9-]{1,64}$/; +const MAX_INPUT = 4 * 1024 * 1024; +const MAX_TRANSFER = 64 * 1024 * 1024; + +function token(value) { + return typeof value === "string" && TOKEN.test(value); +} + +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function hex(value, length) { + return typeof value === "string" && value.length === length && /^[a-f0-9]+$/.test(value); +} + +function clientAction(name) { + return ["nonread", "disconnect", "cancel", "slow"].includes(name) ? name : "read"; +} + +function validIdentity(spec) { + return token(spec.run) && hex(spec.revision, 40) && + hex(spec.artifact, 64) && hex(spec.binary_sha256, 64) && + ["axum", "cloudflare", "fastly", "spin"].includes(spec.adapter) && + typeof spec.sdk === "string" && spec.sdk.length > 0 && spec.sdk.length < 80 && + object(spec.runtime) && + ((typeof spec.runtime.revision === "string" && spec.runtime.revision.length > 0 && spec.runtime.revision.length <= 100) || + ["provider-unpublished", "operator-unavailable"].includes(spec.runtime.unknown)) && + object(spec.compatibility) && Array.isArray(spec.compatibility.flags) && spec.compatibility.flags.length <= 32 && + spec.compatibility.flags.every(flag => typeof flag === "string" && /^[a-zA-Z0-9_-]{1,80}$/.test(flag)) && + (spec.adapter !== "cloudflare" || (typeof spec.compatibility.date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(spec.compatibility.date))) && + typeof spec.deployment === "string" && + spec.deployment.length > 0 && spec.deployment.length <= 200; +} + +function requireFact(condition, reason) { + if (!condition) throw new Error(reason); +} + +export function validateTarget(spec) { + const fields = new Set(["origin", "allowed_origin", "isolated_nonproduction", "run", "adapter", "revision", + "artifact", "binary_sha256", "sdk", "runtime", "compatibility", "deployment", "max_probe_ms", "execution_ceiling_ms"]); + requireFact(Object.keys(spec).every(key => fields.has(key)), "Unexpected target field"); + const url = new URL(spec.origin); + requireFact(url.protocol === "https:" && !url.username && !url.password && + url.pathname === "/" && !url.search && !url.hash && url.origin === spec.allowed_origin, + "An explicitly allowlisted HTTPS origin without credentials or a path is required"); + requireFact(validIdentity(spec), + "SDK, runtime, compatibility and independently recorded deployment identity are required"); + requireFact(Number.isInteger(spec.max_probe_ms) && spec.max_probe_ms >= 1000 && + spec.max_probe_ms <= 10_000, "Probe time budget must be 1000..10000 ms"); + requireFact(spec.isolated_nonproduction === true, "A designated isolated nonproduction target is required"); + requireFact(spec.adapter !== "fastly" || (Number.isInteger(spec.execution_ceiling_ms) && + spec.execution_ceiling_ms > 0 && spec.execution_ceiling_ms <= 60_000), + "Fastly pending-source probes require an independently enforced finite execution ceiling"); + return url; +} + +export function validateEvidence(manifest, clients, events) { + try { + requireFact(manifest.schema === 1 && validIdentity(manifest), + "Revision-pinned deployment metadata is incomplete"); + requireFact(manifest.telemetry_complete === true, "Complete unsampled telemetry export is required"); + requireFact(Array.isArray(manifest.expected) && manifest.expected.length > 0 && + manifest.expected.length <= 2 * CASES.length, "Invalid expected probe set"); + const expected = new Map(); + for (const probe of manifest.expected) { + requireFact(token(probe.probe) && CASES.includes(probe.case) && !expected.has(probe.probe), + "Invalid or duplicate scheduled probe"); + expected.set(probe.probe, probe.case); + } + requireFact(clients.length === expected.size, "Missing or extra client observations"); + const observations = new Map(); + for (const client of clients) { + requireFact(expected.get(client.probe) === client.case && !observations.has(client.probe) && + client.action === clientAction(client.case) && + Number.isSafeInteger(client.bytes) && client.bytes >= 0 && client.bytes <= MAX_TRANSFER, + "Invalid client observation"); + observations.set(client.probe, client); + } + requireFact(events.length <= 4 * expected.size, "Unexpected or duplicate telemetry"); + for (const event of events) { + requireFact(event.schema === 1 && event.run === manifest.run && event.revision === manifest.revision && + event.artifact === manifest.artifact && expected.get(event.probe) === event.case && + token(event.instance), "Telemetry identity mismatch"); + } + for (const [probe, name] of expected) { + const trace = events.filter(event => event.probe === probe).sort((a, b) => a.sequence - b.sequence); + requireFact(trace.length === 4 && trace.every((event, index) => + event.sequence === index && event.instance === trace[0].instance), "Incomplete or conflicting trace sequence"); + const one = kind => { + const selected = trace.filter(event => event.event === kind); + requireFact(selected.length === 1, "Missing or duplicate lifecycle event"); + return selected[0]; + }; + one("source-drop"); + const terminal = one("terminal"); + const resource = one("resource-drop"); + const closed = one("trace-closed"); + requireFact(closed.sequence === 3 && closed.terminals === 1 && closed.source_drops === 1 && + closed.resource_drops === 1 && closed.polls_after_terminal === 0 && + Number.isSafeInteger(closed.polls) && closed.polls >= 0 && terminal.sequence < resource.sequence, + "Ownership counters or release ordering disagree"); + requireFact(Number.isSafeInteger(terminal.bytes_written) && terminal.bytes_written >= 0 && + terminal.bytes_written <= MAX_TRANSFER && ["Application", "Fallback"].includes(terminal.body_kind), + "Invalid terminal byte accounting"); + requireFact(["Completed", "HostHandoff", "ClientDisconnected", "ConversionError", "DeadlineExceeded", + "RequestCancelled", "SourceError", "TransportError"].includes(terminal.outcome), "Unknown terminal outcome"); + const bodyless = ["head", "204", "205", "304"].includes(name); + const client = observations.get(probe); + if (["normal", "head", "204", "205", "304"].includes(name)) { + requireFact(["Completed", "HostHandoff"].includes(terminal.outcome) && + terminal.body_kind === "Application" && terminal.fallback === null && client.ended === true && + client.bytes === (bodyless ? 0 : 3072) && client.status === (bodyless && name !== "head" ? Number(name) : 200), + "Normal/bodyless client observation or terminal outcome disagrees"); + } + if (bodyless) requireFact(closed.polls === 0 && terminal.bytes_written === 0, "Suppressed body was polled or written"); + if (name === "normal") requireFact(closed.polls >= 4 && terminal.bytes_written === 3072, + "Normal response lacks source/accepted-byte evidence"); + if (name === "pending") requireFact(closed.polls >= 1, "Pending producer was never polled"); + if (name === "expired") requireFact(closed.polls === 0 && terminal.body_kind === "Fallback" && + (client.status === null || client.status === 504), "Expired precommit response was not suppressed"); + if (["nonread", "disconnect", "cancel"].includes(name)) requireFact(client.ended === false && + client.result === (name === "disconnect" ? "client-close" : "client-deadline"), + "Client did not exercise the scheduled interruption"); + if (name === "slow") requireFact(Number.isSafeInteger(client.pauses) && client.pauses > 0, + "Slow-client backpressure was not exercised"); + if (["expired", "pending"].includes(name)) requireFact(terminal.outcome === "DeadlineExceeded", "Deadline was not observed"); + if (name === "source-error") requireFact(terminal.outcome === "SourceError", "Source failure was misclassified"); + if (name === "conversion-error") requireFact(terminal.outcome === "ConversionError", "Conversion failure was misclassified"); + if (terminal.body_kind === "Fallback") requireFact(["Completed", "Aborted"].includes(terminal.fallback) && + ["DeadlineExceeded", "ConversionError", "SourceError"].includes(terminal.outcome) && terminal.bytes_written <= 1024, + "Fallback cause or bounded disposition is invalid"); + else requireFact(terminal.fallback === null, "Application report cannot have fallback disposition"); + } + return { status: "qualified-ownership", scope: "scheduled probes only; not confirmed client delivery or provider allocation", + probes: expected.size, capabilities_changed: false }; + } catch (error) { + return { status: "unverified", reason: error.message }; + } +} + +async function readJson(path) { + const chunks = []; + let length = 0; + for await (const chunk of createReadStream(path, { highWaterMark: 16 * 1024 })) { + length += chunk.length; + requireFact(length <= MAX_INPUT, "Evidence input exceeds 4 MiB"); + chunks.push(chunk); + } + const bytes = Buffer.concat(chunks, length); + return JSON.parse(bytes.toString("utf8")); +} + +function runClient(origin, spec, probe, remaining) { + return new Promise(resolve => { + let bytes = 0; + let status = null; + let settled = false; + let pauseTimer; + let pauses = 0; + const action = clientAction(probe.case); + const started = performance.now(); + const finish = (ended, result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(pauseTimer); + req.destroy(); + resolve({ ...probe, action, ended, result, status, bytes, pauses, elapsed_ms: Math.ceil(performance.now() - started) }); + }; + const req = request(new URL(`/qualification/${probe.case}`, origin), { + method: probe.case === "head" ? "HEAD" : "GET", agent: false, + headers: { "x-qualification-run": spec.run, "x-qualification-probe": probe.probe, "accept-encoding": "identity" }, + }, response => { + status = response.statusCode; + response.on("error", () => finish(false, "response-error")); + response.on("end", () => finish(true, "end")); + if (action === "nonread") { response.pause(); return; } + if (action === "disconnect") { finish(false, "client-close"); return; } + response.on("data", chunk => { + bytes += chunk.length; + if (bytes > remaining) { finish(false, "transfer-cap"); return; } + if (action === "slow") { + pauses++; + response.pause(); + pauseTimer = setTimeout(() => response.resume(), 50); + } + }); + }); + const timer = setTimeout(() => finish(false, "client-deadline"), action === "cancel" ? 100 : spec.max_probe_ms); + req.on("error", () => finish(false, "request-error")); + req.end(); + }); +} + +async function main(args) { + if (args[0] === "--execute-hosted" && args.length === 3) { + const spec = await readJson(args[1]); + const origin = validateTarget(spec); + const expected = CASES.flatMap((name, index) => [ + { probe: `case-${index}`, case: name }, { probe: `recovery-${index}`, case: "normal" }, + ]); + const clients = []; + let remaining = MAX_TRANSFER; + for (const probe of expected) { + requireFact(remaining > 0, "Run transfer budget exhausted"); + const observed = await runClient(origin, spec, probe, remaining); + clients.push(observed); + remaining -= observed.bytes; + if (spec.adapter === "fastly" && ["pending", "cancel", "nonread", "disconnect"].includes(probe.case)) { + const teardown = Math.max(0, spec.execution_ceiling_ms - observed.elapsed_ms) + 100; + await new Promise(resolve => setTimeout(resolve, teardown)); + } + } + await writeFile(args[2], JSON.stringify({ manifest: { ...spec, schema: 1, expected, telemetry_complete: false }, clients }, null, 2), { flag: "wx", mode: 0o600 }); + process.stdout.write("Client observations recorded; hosted qualification requires a complete correlated telemetry export.\n"); + return 2; + } + requireFact(args[0] === "--validate" && args.length === 3, + "Use --execute-hosted TARGET.json CLIENTS.json or --validate CLIENTS.json EVENTS.json"); + const { manifest, clients } = await readJson(args[1]); + const events = await readJson(args[2]); + const result = validateEvidence(manifest, clients, events); + process.stdout.write(`${JSON.stringify(result)}\n`); + return result.status === "qualified-ownership" ? 0 : 2; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)).then(code => { process.exitCode = code; }).catch(() => { + process.stderr.write("Hosted lifecycle qualification failed; check the bounded target/evidence inputs.\n"); + process.exitCode = 1; + }); +} diff --git a/scripts/qualify_response_egress.test.mjs b/scripts/qualify_response_egress.test.mjs new file mode 100644 index 000000000..565df13c7 --- /dev/null +++ b/scripts/qualify_response_egress.test.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validateEvidence, validateTarget } from "./qualify_response_egress.mjs"; + +function fixture() { + const manifest = { + schema: 1, run: "run-1", revision: "a".repeat(40), artifact: "b".repeat(64), + adapter: "cloudflare", sdk: "worker 0.8.5", runtime: { unknown: "provider-unpublished" }, + compatibility: { date: "2026-10-07", flags: [] }, deployment: "isolated-test-deployment", + binary_sha256: "c".repeat(64), + telemetry_complete: true, expected: [{ probe: "normal-1", case: "normal" }], + }; + const clients = [{ probe: "normal-1", case: "normal", action: "read", ended: true, bytes: 3072, status: 200 }]; + const common = { schema: 1, run: manifest.run, revision: manifest.revision, artifact: manifest.artifact, + probe: "normal-1", case: "normal", instance: "instance-1" }; + const events = [ + { ...common, sequence: 0, event: "source-drop" }, + { ...common, sequence: 1, event: "terminal", outcome: "HostHandoff", body_kind: "Application", + bytes_written: 3072, fallback: null }, + { ...common, sequence: 2, event: "resource-drop" }, + { ...common, sequence: 3, event: "trace-closed", source_drops: 1, resource_drops: 1, + terminals: 1, polls: 4, polls_after_terminal: 0 }, + ]; + return { manifest, clients, events }; +} + +test("complete correlated ownership evidence is accepted without claiming client delivery", () => { + const { manifest, clients, events } = fixture(); + assert.equal(validateEvidence(manifest, clients, events).status, "qualified-ownership"); +}); + +test("missing, duplicated, truncated and mismatched telemetry fails closed", () => { + for (const mutate of [ + ({ events }) => events.splice(0, 1), + ({ events }) => events.push({ ...events[1] }), + ({ events }) => events.pop(), + ({ events }) => { events[1].revision = "c".repeat(40); }, + ({ events }) => { events[3].polls_after_terminal = 1; }, + ({ events }) => { events[3].source_drops = 2; }, + ({ manifest }) => { manifest.telemetry_complete = false; }, + ({ clients }) => clients.pop(), + ({ clients }) => { clients[0].action = "nonread"; }, + ({ manifest }) => { manifest.adapter = "invented-target"; }, + ({ manifest }) => { delete manifest.binary_sha256; }, + ({ manifest }) => manifest.expected.push({ probe: "recovery-1", case: "normal" }), + ({ events }) => { events[1].body_kind = "Fallback"; }, + ({ events }) => { events[1].bytes_written = 0; }, + ({ events }) => { events[3].polls = 0; }, + ({ manifest }) => { manifest.runtime = true; }, + ({ manifest }) => { manifest.compatibility = true; }, + ({ manifest }) => { manifest.binary_sha256 = [manifest.binary_sha256]; }, + ({ manifest, events }) => { delete manifest.run; for (const event of events) delete event.run; }, + ({ manifest, events, clients }) => { + delete manifest.expected[0].probe; delete clients[0].probe; + for (const event of events) delete event.probe; + }, + ]) { + const value = fixture(); + mutate(value); + assert.notEqual(validateEvidence(value.manifest, value.clients, value.events).status, "qualified-ownership"); + } +}); + +test("target requires allowlisting, finite budgets and no unexpected private fields", () => { + const { schema, expected, telemetry_complete, ...identity } = fixture().manifest; + const target = { ...identity, origin: "https://isolated.example.com", allowed_origin: "https://isolated.example.com", + isolated_nonproduction: true, max_probe_ms: 2000 }; + assert.equal(validateTarget(target).origin, target.origin); + for (const changed of [ + { ...target, origin: "http://isolated.example.com" }, + { ...target, origin: "https://token@isolated.example.com" }, + { ...target, allowed_origin: "https://another.example.com" }, + { ...target, isolated_nonproduction: false }, + { ...target, max_probe_ms: 0 }, + { ...target, private_token: "do-not-persist" }, + { ...target, adapter: "fastly" }, + ]) assert.throws(() => validateTarget(changed)); +}); + +test("bodyless and failure cases enforce source activity and terminal classifications", () => { + for (const name of ["head", "204", "205", "304", "pending", "expired", "source-error", "conversion-error"]) { + const value = fixture(); + value.manifest.expected[0].case = name; + value.clients[0].case = name; + for (const event of value.events) event.case = name; + const terminal = value.events[1]; + const closed = value.events[3]; + const client = value.clients[0]; + const bodyless = ["head", "204", "205", "304"].includes(name); + terminal.bytes_written = name === "source-error" ? 1024 : 0; + closed.polls = bodyless || ["expired", "conversion-error"].includes(name) ? 0 : 2; + client.bytes = 0; + client.status = bodyless ? (name === "head" ? 200 : Number(name)) : null; + if (!bodyless) { + terminal.outcome = ({pending: "DeadlineExceeded", expired: "DeadlineExceeded", + "source-error": "SourceError", "conversion-error": "ConversionError"})[name]; + client.ended = false; + } + if (["expired", "conversion-error"].includes(name)) { + terminal.body_kind = "Fallback"; + terminal.fallback = "Completed"; + } + assert.equal(validateEvidence(value.manifest, value.clients, value.events).status, "qualified-ownership", name); + terminal.outcome = "InventedOutcome"; + assert.equal(validateEvidence(value.manifest, value.clients, value.events).status, "unverified", name); + } +}); + +test("reordered complete telemetry is accepted; records from another instance are not", () => { + const value = fixture(); + value.events.reverse(); + assert.equal(validateEvidence(value.manifest, value.clients, value.events).status, "qualified-ownership"); + value.events[0].instance = "another-instance"; + assert.notEqual(validateEvidence(value.manifest, value.clients, value.events).status, "qualified-ownership"); +}); diff --git a/scripts/run_test_nonzero.sh b/scripts/run_test_nonzero.sh new file mode 100755 index 000000000..bff1bd084 --- /dev/null +++ b/scripts/run_test_nonzero.sh @@ -0,0 +1,52 @@ +#!/bin/sh + +set -eu + +ignored= +if [ "${1:-}" = "--ignored" ]; then + ignored=--ignored + shift +fi + +if [ "$#" -lt 2 ]; then + echo "usage: $0 [--ignored] " >&2 + exit 2 +fi + +sentinel=$1 +shift +listing=$(mktemp "${TMPDIR:-/tmp}/edgezero-tests.XXXXXX") +trap 'rm -f "$listing"' EXIT HUP INT TERM + +if [ -n "$ignored" ]; then + "$@" -- --ignored --list >"$listing" +else + "$@" -- --list >"$listing" +fi + +awk -v sentinel="$sentinel" ' + /: test$/ { + count += 1 + name = $0 + sub(/: test$/, "", name) + if (name == sentinel || name ~ ("::" sentinel "$")) { + found = 1 + } + } + END { + if (count == 0) { + print "selected test suite contains zero tests" > "/dev/stderr" + exit 1 + } + if (!found) { + print "required sentinel test not listed: " sentinel > "/dev/stderr" + exit 1 + } + } +' "$listing" + +if [ -n "$ignored" ]; then + "$@" -- --ignored +else + "$@" +fi diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 9d957fbc4..4a20d56f6 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -14,7 +14,7 @@ command -v rustup >/dev/null 2>&1 || { exit 1 } -for target in wasm32-wasip1 wasm32-wasip2; do +for target in wasm32-unknown-unknown wasm32-wasip1 wasm32-wasip2; do if ! rustup target list --installed | grep -Fxq "$target"; then echo "$target target is not installed. Run 'rustup target add $target' before re-running this script." >&2 exit 1 @@ -35,9 +35,34 @@ section() { section "Workspace Tests" run cargo test --workspace --all-targets +section "Dependency Contracts" +run node scripts/check_serde_json_map_contract.mjs +run node scripts/check_flate_dependency_contract.mjs + +section "Outbound Contract Tests" +run scripts/run_test_nonzero.sh batch_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-axum --no-default-features --features axum,test-utils --test contract +run scripts/run_test_nonzero.sh batch_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-cloudflare --no-default-features --features test-utils --test contract +run scripts/run_test_nonzero.sh batch_preflight_rejects_streamed_slots_without_poisoning_siblings cargo test --offline --locked -p edgezero-adapter-fastly --no-default-features --features test-utils --test contract +run scripts/run_test_nonzero.sh batch_preflight_precedence_and_indices cargo test --offline --locked -p edgezero-adapter-spin --no-default-features --features test-utils --test contract + +section "Outbound Capability Tests" +for adapter in axum cloudflare fastly spin; do + run scripts/run_test_nonzero.sh adapter_capability_matrix_matches_contracts cargo test --offline --locked -p "edgezero-adapter-${adapter}" --no-default-features --features cli --lib adapter_capability_matrix_matches_contracts +done + section "Workspace Feature Compilation" run cargo check --workspace --all-targets --features "fastly cloudflare spin" +section "Adapter Native Feature Matrices" +for adapter in axum cloudflare fastly spin; do + run bash scripts/check_adapter_feature_matrix.sh "${adapter}" native +done + +section "Adapter Wasm Feature Matrices" +run bash scripts/check_adapter_feature_matrix.sh cloudflare wasm32-unknown-unknown +run bash scripts/check_adapter_feature_matrix.sh fastly wasm32-wasip1 +run bash scripts/check_adapter_feature_matrix.sh spin wasm32-wasip2 + section "Fastly CLI Tests" run cargo test -p edgezero-adapter-fastly --no-default-features --features cli @@ -47,12 +72,15 @@ section "Fastly Wasm Tests" run cargo test --features fastly --target wasm32-wasip1 -- --nocapture ) -# Spin 6.0 compiles to wasm32-wasip2; CI runs the full contract +# Spin SDK 7 compiles to wasm32-wasip2; CI runs the full contract # test under wasmtime. Locally we just check it compiles — the # contract test needs wasmtime + the wasm runner pinned in CI. section "Spin Wasm Compile Check" run cargo check -p edgezero-adapter-spin --features spin --target wasm32-wasip2 +section "Generated Project" +run scripts/run_test_nonzero.sh --ignored generated_workspace_compiles cargo test --offline --locked -p edgezero-cli --test generated_project_builds + # `examples/app-demo` is excluded from the root workspace # (per `exclude = ["examples/app-demo"]`), so the workspace # test above doesn't cover it. Stage 8.6 wired this gate into @@ -60,7 +88,14 @@ run cargo check -p edgezero-adapter-spin --features spin --target wasm32-wasip2 section "app-demo Workspace Tests" ( cd examples/app-demo - run cargo test --workspace --all-targets + run cargo test --locked --workspace --all-targets + run cargo check --locked -p app-demo-adapter-cloudflare --target wasm32-unknown-unknown --no-default-features --features cloudflare + run cargo check --locked -p app-demo-adapter-fastly --target wasm32-wasip1 --no-default-features --features fastly + run cargo check --locked -p app-demo-adapter-spin --target wasm32-wasip2 --no-default-features --features spin ) +section "Outbound Documentation Contracts" +run bash scripts/check_outbound_legacy_api.sh +run node scripts/check_outbound_docs_contract.mjs + echo "All tests completed successfully." diff --git a/scripts/smoke_test_config_key_override.sh b/scripts/smoke_test_config_key_override.sh index 28087a7c7..da45aae29 100755 --- a/scripts/smoke_test_config_key_override.sh +++ b/scripts/smoke_test_config_key_override.sh @@ -351,7 +351,7 @@ boot_runtime() { fastly) # DEMO_API_TOKEN_SECRET is the env var viceroy reads to # populate the `demo_api_token` secret-store entry seeded - # by seed_secret_for_adapter. viceroy 0.17+ uses `serve` + # by seed_secret_for_adapter. Current Viceroy uses `serve` # for the long-running HTTP path (`run` was renamed). # # The wasm artifact lives in the app-demo workspace's @@ -404,10 +404,10 @@ for suite in "${SUITES[@]}"; do continue fi - # Spin compatibility pre-check: spin-sdk ~6 imports - # wasi:http/types@0.3.0-rc-2026-03-15 which Spin < 3.7 does - # not provide. Auto-skip the row with a clear note rather - # than fail in the wasm linker; operators can install 3.7+ + # Spin compatibility pre-check: spin-sdk 7 imports stable + # wasi:http/types@0.3.0, which Spin < 4.0 does not provide. + # Auto-skip the row with a clear note rather than fail in the + # wasm linker; operators can install Spin 4.x # and re-run. if [ "$adapter" = "spin" ]; then if ! command -v spin >/dev/null 2>&1; then @@ -417,8 +417,8 @@ for suite in "${SUITES[@]}"; do spin_ver=$(spin --version 2>/dev/null | awk '{print $2}' | head -1) # Extract MAJOR.MINOR portion, drop pre-release suffix. spin_minor=$(printf '%s' "$spin_ver" | awk -F'.' '{printf "%d%02d", $1, $2}') - if [ -n "$spin_minor" ] && [ "$spin_minor" -lt 307 ]; then - printf '\n=== 12.7 __KEY override smoke: spin SKIPPED (CLI %s; need >= 3.7 for spin-sdk 6 wasi:http 0.3) ===\n' "$spin_ver" + if [ -n "$spin_minor" ] && [ "$spin_minor" -lt 400 ]; then + printf '\n=== 12.7 __KEY override smoke: spin SKIPPED (CLI %s; need >= 4.0 for spin-sdk 7 wasi:http 0.3) ===\n' "$spin_ver" continue fi fi diff --git a/tests/fixtures/reusable-app/Cargo.lock b/tests/fixtures/reusable-app/Cargo.lock index 901ea39bd..d395b42ea 100644 --- a/tests/fixtures/reusable-app/Cargo.lock +++ b/tests/fixtures/reusable-app/Cargo.lock @@ -25,9 +25,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -319,7 +319,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -518,6 +518,7 @@ name = "edgezero-adapter-axum" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "async-trait", "axum", "bytes", @@ -525,9 +526,14 @@ dependencies = [ "futures", "futures-util", "http", + "http-body", + "hyper", + "hyper-util", + "libc", "log", "redb", "reqwest", + "serde", "serde_json", "simple_logger", "thiserror 2.0.20", @@ -541,6 +547,7 @@ name = "edgezero-adapter-cloudflare" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "async-trait", "brotli", "bytes", @@ -550,6 +557,8 @@ dependencies = [ "futures-util", "log", "serde_json", + "wasm-streams", + "web-sys", "worker", ] @@ -565,6 +574,8 @@ dependencies = [ "chrono", "edgezero-core", "fastly", + "fastly-shared", + "fastly-sys", "fern", "flate2", "futures", @@ -582,11 +593,10 @@ name = "edgezero-adapter-spin" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "async-trait", - "brotli", "bytes", "edgezero-core", - "flate2", "futures", "futures-util", "log", @@ -601,28 +611,40 @@ dependencies = [ name = "edgezero-core" version = "0.1.0" dependencies = [ + "adler2", + "alloc-no-stdlib", + "alloc-stdlib", "anyhow", "async-compression", "async-stream", "async-trait", + "brotli", + "brotli-decompressor", "bytes", + "compression-codecs", + "compression-core", + "crc32fast", "edgezero-macros", + "flate2", "futures", "futures-util", "http", "http-body", "log", "matchit 0.9.2", + "miniz_oxide", "ryu", "serde", "serde_json", "serde_path_to_error", "serde_urlencoded", "sha2 0.10.9", + "simd-adler32", "thiserror 2.0.20", "toml", "tower-service", "tracing", + "url", "validator", "web-time", ] @@ -670,14 +692,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "fastly" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531e4c3df48350d9f4fc95b4deaf87fd29820336b7926bb84bf460457c2a126b" +checksum = "3c5c07c0c67d0b5826d1cb2311e563a17860b50e2ae89e1c703d6400d904d1e2" dependencies = [ "anyhow", "bytes", @@ -688,7 +710,6 @@ dependencies = [ "fastly-sys", "http", "itertools", - "lazy_static", "mime", "serde", "serde_json", @@ -703,9 +724,9 @@ dependencies = [ [[package]] name = "fastly-macros" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2aef5f9690b04c8890f9a54ddb591b12b9779ec25ee0e572d207106e52e3d8" +checksum = "698025eab5612adab20df48963a5126650893014e8505af02a0af20939ee6a05" dependencies = [ "proc-macro2", "quote", @@ -714,9 +735,9 @@ dependencies = [ [[package]] name = "fastly-shared" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080ad138403159fd366d3e0b14bb49cb0c01dc18c25095bbbd1c85e3338f5413" +checksum = "191c2cf082f5d17f4ca0685cedaa0642ad4ade949fac8d1dbdd62ff756b13500" dependencies = [ "bitflags 1.3.2", "http", @@ -724,9 +745,9 @@ dependencies = [ [[package]] name = "fastly-sys" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de75ef193f6c29c43d667458bede648970715aedd5db2d42c2eba3ffa3ad738b" +checksum = "239c77706cf9110b423a89b6b03b8011729b144390d2a7f641d95ef66b2768b8" dependencies = [ "bitflags 1.3.2", "fastly-shared", @@ -776,8 +797,10 @@ name = "fixture-core" version = "0.1.0" dependencies = [ "async-trait", + "bytes", "edgezero-core", "futures", + "log", "serde_json", ] @@ -1067,13 +1090,12 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.10.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", - "futures-channel", "futures-core", "http", "http-body", @@ -1357,22 +1379,15 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -1399,9 +1414,9 @@ checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "log-fastly" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51dae5def13a2d557fdb63862d642f8d4641ec3773c036bb14092697b6764013" +checksum = "433713b6191ad3e1d5248db9536f3e2d99a12d7ff998afd272a98d96e6dbc2ed" dependencies = [ "fastly", "log", @@ -1503,9 +1518,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.5" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -1886,7 +1901,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2156,14 +2171,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "spin-macro" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11e483b94d5bcfac493caf0427fa875063e3e8604d0466a4ab491ec200a42857" +checksum = "ac8a641da857d4e7cada31a853c40180eeb10485a0f2f0282d0a7e5a2a4105d4" dependencies = [ "proc-macro2", "quote", @@ -2172,9 +2187,9 @@ dependencies = [ [[package]] name = "spin-sdk" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd2abac3eb2ee249c2241ab87f7b1287f36172c8cc1ea815c19c85e41ede44d" +checksum = "2b7a3d9a6805ccdb4d404e91ccf7198ac5e486b6199dc9387aeebaa368272112" dependencies = [ "anyhow", "bytes", @@ -2184,7 +2199,7 @@ dependencies = [ "http-body-util", "spin-macro", "thiserror 2.0.20", - "wasip3 0.6.0+wasi-0.3.0-rc-2026-03-15", + "wasip3 0.7.1+wasi-0.3.0", ] [[package]] @@ -2205,7 +2220,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -2220,6 +2244,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2627,9 +2663,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -2660,9 +2696,9 @@ dependencies = [ [[package]] name = "wasip3" -version = "0.6.0+wasi-0.3.0-rc-2026-03-15" +version = "0.7.1+wasi-0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed83456dd6a0b8581998c0365e4651fa2997e5093b49243b7f35391afaa7a3d9" +checksum = "75e9ed91ee5a995b6fe16c89d4511a8c12eb76967be20b57557618e02e781bcc" dependencies = [ "bytes", "http", @@ -2673,9 +2709,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -2686,19 +2722,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2706,22 +2743,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -2772,9 +2809,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +checksum = "e7d3be8814f5ba5f074491a469eed3d73c273ffad955f25ed1635efac4b0d269" dependencies = [ "futures-util", "js-sys", @@ -2809,9 +2846,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -2842,7 +2879,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3248,9 +3285,9 @@ dependencies = [ [[package]] name = "worker" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d3c60a70414db58e1890f3675d02692adace736657cb66994f220ae3780c90d" +checksum = "2f8adbf6c9ae45b665dee995c5e3a342c2bd7d58a2e8ca5c75b50ce8b1b8bfd9" dependencies = [ "async-trait", "bytes", @@ -3266,7 +3303,7 @@ dependencies = [ "serde-wasm-bindgen", "serde_json", "serde_urlencoded", - "strum", + "strum 0.27.2", "tokio", "url", "wasm-bindgen", @@ -3279,26 +3316,25 @@ dependencies = [ [[package]] name = "worker-macros" -version = "0.8.3" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60bcb459a67977fcb79698a3123ae58a928b1b24cc3035eaec033dbdfc139438" +checksum = "f3d7389670b55aab89760be149007aeff773cdad7b650d807f7fb556d04bb304" dependencies = [ "async-trait", "proc-macro2", "quote", - "strum", - "syn 2.0.119", + "strum 0.28.0", + "syn 3.0.3", "wasm-bindgen", - "wasm-bindgen-futures", "wasm-bindgen-macro-support", "worker-sys", ] [[package]] name = "worker-sys" -version = "0.8.3" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0e59a8504685d87649b8fda877d95fcc48f8c8177dbd77a4dc8e67f8fc80240" +checksum = "6a440092e7ae7c5feb5507fbdc6aee0cc241d132757c83e1729766a24cddff82" dependencies = [ "cfg-if", "js-sys", diff --git a/tests/fixtures/reusable-app/Cargo.toml b/tests/fixtures/reusable-app/Cargo.toml index 8134196e7..3ebfe6f9d 100644 --- a/tests/fixtures/reusable-app/Cargo.toml +++ b/tests/fixtures/reusable-app/Cargo.toml @@ -15,7 +15,9 @@ edgezero-adapter-cloudflare = { path = "../../../crates/edgezero-adapter-cloudfl edgezero-adapter-spin = { path = "../../../crates/edgezero-adapter-spin", features = ["spin"] } edgezero-adapter-axum = { path = "../../../crates/edgezero-adapter-axum" } fixture-core = { path = "crates/fixture-core" } -fastly = "0.12" +fastly = "=0.13.1" +worker = { version = "=0.8.5", default-features = false, features = ["http"] } +spin-sdk = { version = "=7.0.0", default-features = false } futures = "0.3" serde_json = "1" log = "0.4" diff --git a/tests/fixtures/reusable-app/README.md b/tests/fixtures/reusable-app/README.md index 4b03747a3..da57bd7b3 100644 --- a/tests/fixtures/reusable-app/README.md +++ b/tests/fixtures/reusable-app/README.md @@ -15,6 +15,81 @@ The driver is the native Rust `fixture-harness` crate. Its HTTP clients, control loopback backend, process management, and evidence checks run outside the WASM applications. The shell script only launches it through Cargo. +## Opt-in Hosted Response Qualification + +The default-off `qualification` feature selects a synthetic application through the standard +entrypoints. Use only a designated isolated nonproduction service with complete unsampled logging +at `Info` or above, including the `edgezero_qualification` target. No production stores, traffic, +credentials or log exports should be reused. Existing smoke profiles remain unchanged by default. + +Compile from a clean committed checkout with recorded build identity: + +```sh +export EDGEZERO_QUALIFICATION_REVISION="$(git rev-parse HEAD)" +export EDGEZERO_QUALIFICATION_ARTIFACT="$(shasum -a 256 tests/fixtures/reusable-app/Cargo.lock | awk '{print $1}')" +cargo build --locked --release --manifest-path tests/fixtures/reusable-app/Cargo.toml \ + -p fixture-cloudflare --features qualification --target wasm32-unknown-unknown +cargo build --locked --release --manifest-path tests/fixtures/reusable-app/Cargo.toml \ + -p fixture-fastly --features qualification --bin fixture-fastly-qualification --target wasm32-wasip1 +cargo build --locked --release --manifest-path tests/fixtures/reusable-app/Cargo.toml \ + -p fixture-spin --features qualification --target wasm32-wasip2 +cargo build --locked --release --manifest-path tests/fixtures/reusable-app/Cargo.toml \ + -p fixture-axum --features qualification +``` + +Deploy the selected artifact through the platform's normal isolated-service path. Record the +actual binary SHA-256 and deployment identity after build. `artifact` below is the compiled +lockfile/build-input digest, not the binary hash: a binary cannot embed its own hash. Record an +unavailable runtime revision as a reasoned unknown, not an invented version. + +Prepare a target JSON object using actual recorded identities: + +```json +{ + "origin": "https://isolated-qualification.example.com", + "allowed_origin": "https://isolated-qualification.example.com", + "isolated_nonproduction": true, + "run": "qualification-1", + "adapter": "cloudflare", + "revision": "<40-character committed source SHA>", + "artifact": "<64-character compiled build-input SHA-256>", + "binary_sha256": "<64-character deployed artifact SHA-256>", + "sdk": "worker 0.8.5", + "runtime": { "unknown": "provider-unpublished" }, + "compatibility": { "date": "", "flags": ["enable_request_signal", "enable_weak_ref"] }, + "deployment": "", + "max_probe_ms": 2000 +} +``` + +Other adapters use their actual SDK name/version and a compatibility object with a `flags` +array. Fastly additionally requires `execution_ceiling_ms` (1..60000), enforced independently by +the host: guest checks cannot wake a permanently pending synchronous source. The runner waits +for that ceiling before recovery where necessary. The server response budget is 500 ms; the +client budget must be 1000..10000 ms. Thirteen scenarios plus a healthy request after each run +sequentially, without redirects/retries, with a 64 MiB observed-transfer cap (plus one Node stream +chunk on crossing). No outbound backend is contacted. + +```sh +node scripts/qualify_response_egress.mjs --execute-hosted TARGET.json CLIENTS.json +node scripts/qualify_response_egress.mjs --validate CLIENTS.json EVENTS.json +``` + +Client collection intentionally exits 2: it is not qualification by itself. Export the complete +unsampled events as a JSON array in `EVENTS.json`, preserving all records/sequences and excluding +other runs; independently confirm completeness before setting the recorded manifest's +`telemetry_complete` to true. Missing/duplicate/conflicting records remain unverified. Evidence +files are capped at 4 MiB and output uses private file permissions. The validator does not +authenticate fabricated telemetry or prove the supplied deployment identity. + +Each probe emits source-drop, terminal, resource-drop and final counters for one late-bound +completion resource. `HostHandoff` remains distinct from delivery. Success qualifies ownership +only for the scheduled probes, not full Q4: instance identity is `unobserved`, so a healthy next +request does not prove same-isolate capacity recovery. Actual transport failures, host teardown, +raw parser behavior and provider memory need separate evidence. No capability is promoted by +these tools, local tests or successful compilation. The response-egress specification maintains +the per-target evidence matrix and limitations. + ## Understand the change in five minutes Think of the app as the route table plus any state you attach to it. A request @@ -43,8 +118,9 @@ Start with these entry points in `crates/fixture-fastly/src/bin/`: | B | `rebuild_per_request.rs` | Sandbox globals; app is rebuilt | | C | `retained_app.rs` | Sandbox globals and the app/router | -The `custom_` versions use the same three lifetimes with manual streaming and -response finalization. A/B/C remain short labels in experiment reports only. +The `custom_` versions use the same three lifetimes with request preparation, +response finalization, adapter-owned streaming, and post-send work. A/B/C remain +short labels in experiment reports only. With the pinned Viceroy installed, run a small synthetic comparison: @@ -67,8 +143,10 @@ a prediction of an application's production performance. Token, path, and body checks confirm that each request still sees its own data. Shared state equals the ordinal in retained probe sequences and one in per-request modes. -For production code, read `serve_app_with_request_extensions` in the Fastly -adapter: it owns one app and performs request setup inside the SDK callback. +For production code, read `serve_app` and `serve_app_with_hooks` in the Fastly +adapter: they retain one successfully configured app and perform fresh request +setup inside each SDK callback. Construction uses +`App::build::(FASTLY_PLATFORM) -> Result`, not an infallible hook builder. Cloudflare and Spin instead expose `dispatch_app` and let the caller retain a concrete app. Axum already retains its app. Existing entry points keep their lifecycle defaults until an application explicitly adopts retention. @@ -80,8 +158,10 @@ shared dependencies, and keep its Fastly SDK requirement aligned with the root. Select executables with `VICEROY_BIN`, `WORKER_BUILD_BIN`, `WRANGLER_BIN`, and `SPIN_BIN`. Install tools explicitly; the runner never deploys or installs them. -Use a worker-build version compatible with the locked wasm-bindgen CLI -(worker-build 0.8.3 is compatible with the current lockfile). The runner builds +The PR pins Fastly SDK 0.13.1, Viceroy 0.21.0, `worker` 0.8.5, and `spin-sdk` 7.0.0 +using its `wasip3` re-export; the repository pins Spin CLI 4.1.0. Use a worker-build +version compatible with the locked worker/wasm-bindgen graph rather than assuming +an older builder remains compatible. The runner builds once with the selected `WORKER_BUILD_BIN`, then starts Wrangler with a snapshot that disables automatic rebuilding. Axum's executable path is read from Cargo's artifact output, so configured build targets cannot select a stale default binary. @@ -102,7 +182,7 @@ crash are not zero attempted requests. ## Fastly comparisons The provider/application boundary is documented in -[`Custom lifecycle compatibility contract`](../../../docs/guide/adapters/fastly.md#custom-lifecycle-compatibility-contract). +[`Custom lifecycle contract`](../../../docs/guide/adapters/fastly.md#custom-lifecycle-contract). This workspace uses path dependencies on the checkout under test. The Fastly smoke suite additionally runs a fresh custom guest through health, a handled initialization failure, another health request, successful initialization, and @@ -116,9 +196,21 @@ preservation and isolation rather than merely setting a constant header. - C: production retained-app helper. - Custom A/B/C: EdgeZero `lifecycle::run_custom` / `serve_custom` and `Sandbox` own the serving boundary and successful-only initialization. Per-request mode uses a fresh - state slot per callback; C uses the retained slot. Raw request mutation/conversion, response-extension finalization, - appended headers, progressive stream pumping with explicit flush/finish, and - completed post-send work. + state slot per callback; C uses the retained slot. Callbacks return `Result<(), E>` + and own delivery by explicitly sending native replies or invoking + `send_request_with_registries_and_hooks(app, stores, req, &runtime.env, prepare, finalize)`. + The preparation hook mutates the native request and extensions; the routed-response + finalizer runs before egress policy and framing. The adapter owns appended headers, + progressive body writes, and terminal close/abandon without whole-body collection. + Routed finalizer state is available as `Some` only after terminal delivery; + detached admission responses skip finalization and return `None`. Complete + post-send work before returning. Neither wrapper requests a synthetic SDK send; + `run_custom` returns the callback result directly. + +Callbacks that dispatch the app use baked `Hooks::logging_for("fastly")` settings +for logger setup and reopen stores under their stable logical aliases per request. +Deployment selectors choose physical links, not runtime configuration. Native request bodies, extensions, store handles, +and pending work are not retained with the app. ```sh ./scripts/smoke_test_reusable_app.sh --adapter fastly --suite benchmark @@ -146,13 +238,31 @@ ID and distinguish `fixture-logs ::` endpoint records from echoed stdout. The negative control verifies that naively wrapping `run_app` with an enabled global logger fails on its second installation. +The interrupted-upstream case waits until the client observes its partial body before +releasing the backend abort, proving physical progressive delivery. The immediate +zero-byte source-failure case instead requires exactly one response-scoped terminal +report (`SourceError`, application body, zero accepted bytes, no fallback) and terminal +guest eviction. The host may reset before its head reaches the client; only EOF/reset +with no observed status is accepted, not timeouts, malformed responses, or replacement +error responses. Neither proof upgrades the documented egress capability guarantees. + ## Runtime and measurement limits -Raising `--max-requests` cannot extend the pinned Viceroy's per-guest request -ceiling (see the Fastly guide). Verify deployed eviction and long-lived memory -behavior separately. The runner checks the Viceroy version against +Raising `--max-requests` does not guarantee a longer guest lifetime. Any observed +Viceroy per-guest ceiling is local evidence, not a deployed guarantee or a promise +that Viceroy 0.21.0 reproduces an older runtime's ceiling. Verify deployed eviction +and long-lived memory behavior separately. The runner checks the Viceroy version against `.tool-versions` and reports a mismatch as unverified. +Building with `spin-sdk` 7.0.0 emits the SDK's `wasip3` WASI HTTP interface. A +successful build does not prove the selected Spin CLI 4.1.0 host can execute that +component. Retained dispatch also depends on provider-specific runtime support; +an unavailable host, interface mismatch, or missing HTTP/reuse evidence cannot +count as a passing lifecycle assertion. Do not substitute older SDKs or response +converters to make a provider check appear supported. Build/startup failures remain +failures; tools or runtime evidence the runner explicitly marks unsupported or +unverified remain exit 2. + Cloudflare and Spin may select different guests during overlap or recovery tests; those observations are unverified. A success requires the same instance and the specific injected failure. Overlap checks use each request's `before.inflight` @@ -172,9 +282,16 @@ snapshots over short guest lifetimes cannot establish absence of small leaks. The smoke suite exercises actual adapter failures with controlled inputs: -- Panic inside `MeasuredApp::build_app`, followed by a request in another guest. -- A truncated upstream body used as the inbound body, failing `into_core_request`. -- An erroring core body stream, failing buffered response conversion. +These cases describe fixture coverage, not a recorded pass for the current pinned +SDK/runtime graph. Only correlated evidence from a run of this checkout establishes +an assertion; results from older runtimes do not certify the migrated entry points. + +- Panic inside `MeasuredApp::configure` during `App::build`, followed by a request + in another guest. +- A truncated upstream body used as the inbound body, exercising the canonical + ingress read/error-response path rather than a public conversion helper. +- An erroring core body stream, exercising terminal response-egress failure and + close/abandon without collecting the whole response first. - A missing required KV selector terminating the SDK loop, with an attempted request summary. A separate custom policy catches that error and proves successful bindings and retained app state on the following request. @@ -188,9 +305,11 @@ The smoke suite exercises actual adapter failures with controlled inputs: `summary.json` groups observations by variant, repetition, and guest. It reports initialization phases, SDK attempts, recorded client completion, CPU deltas, and memory change/peak/slope/last-three-sample plateau over actual ordinals. -Missing phase events remain unknown. Standard helpers expose conversion-lifetime -samples; custom callbacks expose response commitment and guest completion. These -are different observation points. A standard request-start sample occurs after +Missing phase events remain unknown. Historical event labels such as +`conversion_completed` identify only the instrumented resource lifetime; they +are not evidence of whole-body collection, exact response commitment, or complete +cleanup. Custom callbacks additionally record terminal delivery and guest completion +where instrumented. These are different observation points. A standard request-start sample occurs after initialization, while the custom callback sample occurs before it. Each run saves its lockfile, configuration, compiler/runtime identity, and binary @@ -211,12 +330,13 @@ absence of small leaks or bounded growth for an arbitrary application. These are explicitly unverified, not assertions counted as passed: -| Case | Available evidence / limitation | -| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Optional runtime-store open fails, then recovers in the same guest | Host tests inject degraded then successful configuration snapshots; required-registry recovery is exercised above. Local runtimes cannot make the fixed optional store fail transiently. | -| `EdgeError` itself fails to render | The renderer builds fixed valid statuses/headers from JSON values; no public input or injection hook triggers a returned rendering error. | -| Unavailable heap hostcall | Only the preflight is implemented. Under a memory limit, a failed heap snapshot makes the SDK stop after the first request. Local runtimes supply the hostcall and cannot disable it. | -| Full standard callback/send/cleanup timing | The public retained helper has no post-send callback. Conversion observations and SDK summary timing are labeled separately; unavailable phases stay unknown. | -| Deployed eviction, endpoint handles, resource accounting, capacity, and workload gains | Require separately authorized deployment and application telemetry. Local CPU samples are not cross-run CPU benchmarks; finite origin tests do not establish service-wide capacity. | +| Case | Available evidence / limitation | +| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Optional runtime-store open fails, then recovers in the same guest | Host tests inject degraded then successful configuration snapshots; required-registry recovery is exercised above. Local runtimes cannot make the fixed optional store fail transiently. | +| Spin WASI HTTP interface or provider runtime unavailable | SDK compilation alone does not establish host support or reuse. Preserve build/startup failures and explicitly unsupported/unverified records; no older-component compatibility path is used. | +| `EdgeError` itself fails to render | The renderer builds fixed valid statuses/headers from JSON values; no public input or injection hook triggers a returned rendering error. | +| Unavailable heap hostcall | Only the preflight is implemented. Under a memory limit, a failed heap snapshot makes the SDK stop after the first request. Local runtimes supply the hostcall and cannot disable it. | +| Full standard callback/send/cleanup timing | The public retained helper has no post-send callback; its finalizer runs before delivery and its state is discarded. Instrumented resource observations and SDK summary timing are labeled separately; unavailable phases stay unknown. | +| Deployed eviction, endpoint handles, resource accounting, capacity, and workload gains | Require separately authorized deployment and application telemetry. Local CPU samples are not cross-run CPU benchmarks; finite origin tests do not establish service-wide capacity. | No deployment is performed by these fixtures. diff --git a/tests/fixtures/reusable-app/crates/fixture-axum/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-axum/Cargo.toml index b5d212b6a..91c3c86d1 100644 --- a/tests/fixtures/reusable-app/crates/fixture-axum/Cargo.toml +++ b/tests/fixtures/reusable-app/crates/fixture-axum/Cargo.toml @@ -4,6 +4,9 @@ version.workspace = true edition.workspace = true publish.workspace = true +[features] +qualification = ["fixture-core/qualification"] + [dependencies] fixture-core.workspace = true edgezero-core.workspace = true diff --git a/tests/fixtures/reusable-app/crates/fixture-axum/src/main.rs b/tests/fixtures/reusable-app/crates/fixture-axum/src/main.rs index a1da0b308..bbf1deb22 100644 --- a/tests/fixtures/reusable-app/crates/fixture-axum/src/main.rs +++ b/tests/fixtures/reusable-app/crates/fixture-axum/src/main.rs @@ -1,3 +1,8 @@ fn main() -> anyhow::Result<()> { - edgezero_adapter_axum::dev_server::run_app::() + edgezero_adapter_axum::dev_server::run_app::() } + +#[cfg(not(feature = "qualification"))] +use fixture_core::FixtureApp as ActiveApp; +#[cfg(feature = "qualification")] +use fixture_core::qualification::QualificationApp as ActiveApp; diff --git a/tests/fixtures/reusable-app/crates/fixture-cloudflare/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-cloudflare/Cargo.toml index 2e9f7f485..3b3c02ccb 100644 --- a/tests/fixtures/reusable-app/crates/fixture-cloudflare/Cargo.toml +++ b/tests/fixtures/reusable-app/crates/fixture-cloudflare/Cargo.toml @@ -4,12 +4,15 @@ version.workspace = true edition.workspace = true publish.workspace = true +[features] +qualification = ["fixture-core/qualification"] + [dependencies] serde_json.workspace = true fixture-core.workspace = true edgezero-core.workspace = true edgezero-adapter-cloudflare.workspace = true -worker = "0.8" +worker.workspace = true [lib] crate-type = ["cdylib", "rlib"] diff --git a/tests/fixtures/reusable-app/crates/fixture-cloudflare/src/lib.rs b/tests/fixtures/reusable-app/crates/fixture-cloudflare/src/lib.rs index 9b722fd85..9d272c7a8 100644 --- a/tests/fixtures/reusable-app/crates/fixture-cloudflare/src/lib.rs +++ b/tests/fixtures/reusable-app/crates/fixture-cloudflare/src/lib.rs @@ -1,10 +1,21 @@ +#![cfg(target_arch = "wasm32")] + use edgezero_core::app::{App, Hooks}; -use fixture_core::{FixtureApp, MissingBindingApp, OtherApp}; +#[cfg(not(feature = "qualification"))] +use fixture_core::FixtureApp; +#[cfg(feature = "qualification")] +use fixture_core::qualification::QualificationApp as FixtureApp; +use fixture_core::{MissingBindingApp, OtherApp, retained_app}; use std::sync::OnceLock; use worker::{Context, Env, Request, Response, event}; static APP: OnceLock = OnceLock::new(); static OTHER_APP: OnceLock = OnceLock::new(); +fn retained_app_for_dispatch(cell: &OnceLock) -> worker::Result<&App> { + retained_app::(cell, edgezero_adapter_cloudflare::CLOUDFLARE_PLATFORM) + .map_err(|_error| worker::Error::RustError("application configuration failed".to_owned())) +} + #[event(fetch)] async fn fetch(req: Request, env: Env, ctx: Context) -> worker::Result { if !FixtureApp::owns_logging() { @@ -15,15 +26,13 @@ async fn fetch(req: Request, env: Env, ctx: Context) -> worker::Result .map(|v| v.to_string() == "retained") .unwrap_or(false); if retained && req.path() == "/binding-failure" { - // Pairs MissingBindingApp with FixtureApp's router; valid only while - // dispatch_app reads nothing but A::stores() (see MissingBindingApp). - let result = edgezero_adapter_cloudflare::dispatch_app::( - APP.get_or_init(FixtureApp::build_app), - req, - env, - ctx, - ) - .await; + let app = App::build::(edgezero_adapter_cloudflare::CLOUDFLARE_PLATFORM) + .map_err(|_error| { + worker::Error::RustError("application configuration failed".to_owned()) + })?; + let result = + edgezero_adapter_cloudflare::dispatch_app::(&app, req, env, ctx) + .await; return match result { Err(error) => Response::from_json(&serde_json::json!({"instance":fixture_core::instance_id(),"source":"injected_required_binding","error":error.to_string()})).map(|r|r.with_status(503)), Ok(response) => Ok(response), @@ -34,7 +43,7 @@ async fn fetch(req: Request, env: Env, ctx: Context) -> worker::Result return edgezero_adapter_cloudflare::run_app::(req, env, ctx).await; } return edgezero_adapter_cloudflare::dispatch_app::( - OTHER_APP.get_or_init(OtherApp::build_app), + retained_app_for_dispatch::(&OTHER_APP)?, req, env, ctx, @@ -43,7 +52,7 @@ async fn fetch(req: Request, env: Env, ctx: Context) -> worker::Result } if retained { edgezero_adapter_cloudflare::dispatch_app::( - APP.get_or_init(FixtureApp::build_app), + retained_app_for_dispatch::(&APP)?, req, env, ctx, diff --git a/tests/fixtures/reusable-app/crates/fixture-cloudflare/wrangler.toml b/tests/fixtures/reusable-app/crates/fixture-cloudflare/wrangler.toml index 12957af18..a67631ef5 100644 --- a/tests/fixtures/reusable-app/crates/fixture-cloudflare/wrangler.toml +++ b/tests/fixtures/reusable-app/crates/fixture-cloudflare/wrangler.toml @@ -1,6 +1,7 @@ name = "reusable-app-fixture" main = "build/worker/shim.mjs" compatibility_date = "2026-04-22" +compatibility_flags = ["enable_request_signal", "enable_weak_ref"] [build] command = "worker-build --release . -- --locked" [vars] diff --git a/tests/fixtures/reusable-app/crates/fixture-core/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-core/Cargo.toml index 42582466d..af18ca455 100644 --- a/tests/fixtures/reusable-app/crates/fixture-core/Cargo.toml +++ b/tests/fixtures/reusable-app/crates/fixture-core/Cargo.toml @@ -4,11 +4,16 @@ version.workspace = true edition.workspace = true publish.workspace = true +[features] +qualification = [] + [dependencies] edgezero-core.workspace = true futures.workspace = true serde_json.workspace = true async-trait = "0.1" +bytes = "1" +log.workspace = true [lints] workspace = true diff --git a/tests/fixtures/reusable-app/crates/fixture-core/src/lib.rs b/tests/fixtures/reusable-app/crates/fixture-core/src/lib.rs index 0108b3893..346eb71b1 100644 --- a/tests/fixtures/reusable-app/crates/fixture-core/src/lib.rs +++ b/tests/fixtures/reusable-app/crates/fixture-core/src/lib.rs @@ -1,6 +1,9 @@ +#[cfg(feature = "qualification")] +pub mod qualification; + use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; use edgezero_core::http::{Method, Response, response_builder}; -use edgezero_core::proxy::ProxyRequest; +use edgezero_core::outbound::OutboundRequest; use edgezero_core::router::RouterService; use edgezero_core::{action, body::Body, context::RequestContext, error::EdgeError}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -10,11 +13,26 @@ static INSTANCE: OnceLock = OnceLock::new(); static BUILDS: AtomicUsize = AtomicUsize::new(0); static ORDINAL: AtomicUsize = AtomicUsize::new(0); static INFLIGHT: AtomicUsize = AtomicUsize::new(0); +const MAX_BODY_BYTES: usize = 1024 * 1024; pub fn instance_id() -> Option<&'static str> { INSTANCE.get().map(String::as_str) } +pub fn retained_app( + cell: &OnceLock, + platform: edgezero_core::PlatformMetadata, +) -> Result<&App, EdgeError> { + if let Some(app) = cell.get() { + return Ok(app); + } + let app = App::build::(platform)?; + // Keep an already-installed winner; configuration errors never enter the cell. + let _ = cell.set(app); + cell.get() + .ok_or_else(|| EdgeError::internal(std::io::Error::other("application retention failed"))) +} + /// Carry a fixture-owned lifetime observer through request and response conversion. #[derive(Clone)] pub struct ResponseLifetime( @@ -63,14 +81,10 @@ impl Hooks for FixtureApp { }), } } - fn build_app() -> App { + fn configure(app: &mut App) -> Result<(), EdgeError> { BUILDS.fetch_add(1, Ordering::SeqCst); - let mut app = App::new(Self::routes()); - Self::configure(&mut app); - app - } - fn configure(app: &mut App) { app.set_name("fixture"); + Ok(()) } fn routes() -> RouterService { RouterService::builder() @@ -90,12 +104,8 @@ impl Hooks for FixtureApp { } /// Swaps only the KV binding metadata to inject a required-binding failure. /// -/// Fixtures pair this type with the retained `App` built from `FixtureApp`, so a -/// second construction does not invalidate the recovery fixture's one-build -/// assertion. That deliberately breaks the `dispatch_app` precondition that the -/// app is built from `A`; it is correct only because `dispatch_app` currently -/// reads nothing but `A::stores()`. If an adapter starts consulting another hook -/// (for example `A::owns_logging()`), this pairing must be revisited. +/// Build and dispatch this app independently. Its default configuration leaves +/// the healthy fixture's build counter unchanged. pub struct MissingBindingApp; impl Hooks for MissingBindingApp { fn routes() -> RouterService { @@ -121,21 +131,21 @@ async fn other(_ctx: RequestContext) -> Result { Ok("other-app".into()) } -fn record(ctx: &RequestContext) -> Result { - let req = ctx.request(); - let token = req +async fn record(ctx: &RequestContext) -> Result { + let body = ctx.body_bytes(MAX_BODY_BYTES).await?; + let token = ctx .headers() .get("x-request-token") .and_then(|v| v.to_str().ok()) .unwrap_or("missing"); - let observation = req + let observation = ctx .extensions() .get::() .cloned() .ok_or_else(|| { EdgeError::internal(std::io::Error::other("observation middleware absent")) })?; - let shared = req + let shared = ctx .extensions() .get::>() .unwrap() @@ -146,28 +156,15 @@ fn record(ctx: &RequestContext) -> Result { "correlation": observation.correlation, "native_id": observation.native_id, "builds": BUILDS.load(Ordering::SeqCst), "path": ctx.path_params().get("id"), "token": token, - "mutated": req.headers().get("x-fixture-mutated").is_some(), - "body": String::from_utf8_lossy(ctx.body().as_bytes().unwrap_or_default()), + "mutated": ctx.headers().get("x-fixture-mutated").is_some(), + "body": String::from_utf8_lossy(&body), "shared": shared, "inflight": INFLIGHT.load(Ordering::SeqCst), })) } #[action] -async fn probe(mut ctx: RequestContext) -> Result { - use futures::StreamExt; - let body = std::mem::replace(ctx.request_mut().body_mut(), Body::empty()); - let bytes = match body { - Body::Once(bytes) => bytes.to_vec(), - Body::Stream(mut stream) => { - let mut bytes = Vec::new(); - while let Some(chunk) = stream.next().await { - bytes.extend_from_slice(&chunk.map_err(EdgeError::internal)?); - } - bytes - } - }; - *ctx.request_mut().body_mut() = Body::from(bytes); - Ok(record(&ctx)?.to_string()) +async fn probe(ctx: RequestContext) -> Result { + Ok(record(&ctx).await?.to_string()) } #[action] @@ -194,7 +191,6 @@ pub fn is_loopback_http(uri: &edgezero_core::http::Uri) -> bool { async fn fetch_backend(ctx: &RequestContext) -> Result { let uri = ctx - .request() .headers() .get("x-fixture-backend") .and_then(|v| v.to_str().ok()) @@ -205,10 +201,15 @@ async fn fetch_backend(ctx: &RequestContext) -> Result { if !is_loopback_http(&uri) { return Err(EdgeError::bad_request("backend must be loopback HTTP")); } - ctx.proxy_handle() - .ok_or_else(|| EdgeError::internal(std::io::Error::other("missing proxy")))? - .forward(ProxyRequest::new(Method::GET, uri)) - .await + let mut request = OutboundRequest::new(Method::GET, uri)?; + if matches!(ctx.uri().path(), "/stream" | "/stream-error") { + request = request.stream_response(); + } + ctx.http_client() + .ok_or_else(|| EdgeError::internal(std::io::Error::other("missing HTTP client")))? + .send(request) + .await? + .into_response() } #[action] @@ -226,9 +227,9 @@ impl Drop for InFlight { async fn overlap(ctx: RequestContext) -> Result { INFLIGHT.fetch_add(1, Ordering::SeqCst); let _guard = InFlight; - let before = record(&ctx)?; + let before = record(&ctx).await?; let _response = fetch_backend(&ctx).await?; - let mut after = record(&ctx)?; + let mut after = record(&ctx).await?; after["before"] = before; Ok(after.to_string()) } @@ -292,24 +293,18 @@ impl edgezero_core::middleware::Middleware for ObserveRequest { mut ctx: RequestContext, next: edgezero_core::middleware::Next<'_>, ) -> Result { - if ctx.request().extensions().get::().is_none() { + if ctx.extensions().get::().is_none() { let candidate = ctx - .request() .headers() .get("x-request-token") .and_then(|value| value.to_str().ok()) .unwrap_or("missing"); let observation = observe(candidate, None); - ctx.request_mut().extensions_mut().insert(observation); + ctx.extensions_mut().insert(observation); } - let lifetime = ctx - .request() - .extensions() - .get::() - .cloned(); + let lifetime = ctx.extensions().get::().cloned(); let finalization = Finalize( - ctx.request() - .headers() + ctx.headers() .get("x-request-token") .and_then(|value| value.to_str().ok()) .unwrap_or("missing") @@ -333,9 +328,75 @@ mod tests { use edgezero_core::http::request_builder; use futures::executor::block_on; + #[test] + fn retained_app_retries_failed_configuration_and_reuses_success() { + static ATTEMPTS: AtomicUsize = AtomicUsize::new(0); + struct RetryApp; + impl Hooks for RetryApp { + fn configure(_app: &mut App) -> Result<(), EdgeError> { + if ATTEMPTS.fetch_add(1, Ordering::SeqCst) == 0 { + Err(EdgeError::service_unavailable( + "injected configuration failure", + )) + } else { + Ok(()) + } + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + let cell = OnceLock::new(); + let platform = edgezero_core::PlatformMetadata::default(); + assert!(retained_app::(&cell, platform).is_err()); + assert!(cell.get().is_none()); + let first = retained_app::(&cell, platform).unwrap(); + let second = retained_app::(&cell, platform).unwrap(); + assert!(std::ptr::eq(first, second)); + assert_eq!(ATTEMPTS.load(Ordering::SeqCst), 2); + } + + #[test] + fn retained_app_returns_an_already_installed_winner() { + static CELL: OnceLock = OnceLock::new(); + struct RacingApp; + impl Hooks for RacingApp { + fn configure(app: &mut App) -> Result<(), EdgeError> { + let winner = App::with_name(RouterService::builder().build(), "winner"); + assert!(CELL.set(winner).is_ok()); + app.set_name("discarded"); + Ok(()) + } + + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + let app = + retained_app::(&CELL, edgezero_core::PlatformMetadata::default()).unwrap(); + assert_eq!(app.name(), "winner"); + } + + #[test] + fn probe_accepts_exact_body_bound_and_rejects_overflow() { + let app = App::build::(edgezero_core::PlatformMetadata::default()).unwrap(); + for (size, status) in [(1024 * 1024, 200), (1024 * 1024 + 1, 400)] { + let req = request_builder() + .uri("/probe/bounded") + .header("x-request-token", "bounded") + .body(Body::from(vec![b'x'; size])) + .unwrap(); + let response = block_on(app.router().oneshot(req)).unwrap(); + assert_eq!(response.status().as_u16(), status); + } + } + #[test] fn retained_probe_echoes_each_request_and_preserves_cookies() { - let app = FixtureApp::build_app(); + let app = App::build::(edgezero_core::PlatformMetadata::default()).unwrap(); for (index, token) in ["one", "two"].into_iter().enumerate() { let req = request_builder() .uri(format!("/probe/{token}")) @@ -360,8 +421,8 @@ mod tests { #[test] fn rejects_remote_origins_and_keeps_concrete_apps_separate() { - let app = FixtureApp::build_app(); - let other = OtherApp::build_app(); + let app = App::build::(edgezero_core::PlatformMetadata::default()).unwrap(); + let other = App::build::(edgezero_core::PlatformMetadata::default()).unwrap(); let req = request_builder() .uri("/origin/x") .header("x-fixture-backend", "https://example.com/") diff --git a/tests/fixtures/reusable-app/crates/fixture-core/src/qualification.rs b/tests/fixtures/reusable-app/crates/fixture-core/src/qualification.rs new file mode 100644 index 000000000..68e0c105c --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-core/src/qualification.rs @@ -0,0 +1,329 @@ +//! Opt-in, synthetic hosted probes through standard application/adaptor lifecycles. + +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use bytes::Bytes; +use edgezero_core::app::{App, Hooks}; +use edgezero_core::context::RequestContext; +use edgezero_core::http::{Method, Response, StatusCode, response_builder}; +use edgezero_core::ingress::{AdmissionDecision, IngressGrant}; +use edgezero_core::response_egress::{ + ResponseEgressCompletion, ResponseEgressDeadline, ResponseEgressResource, +}; +use edgezero_core::router::RouterService; +use edgezero_core::{Body, EdgeError, action}; +use futures::Stream; +use serde_json::{Value, json}; + +const CASES: &[&str] = &[ + "normal", + "head", + "204", + "205", + "304", + "slow", + "nonread", + "disconnect", + "cancel", + "pending", + "expired", + "source-error", + "conversion-error", +]; + +pub struct QualificationApp; + +#[derive(Default)] +struct Counters { + polls: u64, + polls_after_terminal: u64, + resource_drops: u64, + sequence: u32, + source_drops: u64, + terminals: u64, +} + +struct ProbeTrace { + artifact: String, + case: String, + counters: Mutex, + probe: String, + revision: String, + run: String, +} + +struct ResourceDropProbe(Arc); + +struct ProbeGrant { + resource: ResponseEgressResource, + trace: Arc, +} + +struct ProbeSource { + chunk: Bytes, + remaining: u32, + trace: Arc, +} + +impl Hooks for QualificationApp { + fn configure(app: &mut App) -> Result<(), EdgeError> { + let revision = option_env!("EDGEZERO_QUALIFICATION_REVISION").unwrap_or(""); + let artifact = option_env!("EDGEZERO_QUALIFICATION_ARTIFACT").unwrap_or(""); + if !hex_identity(revision, 40) || !hex_identity(artifact, 64) { + return Err(EdgeError::internal(std::io::Error::other( + "qualification build identity is absent", + ))); + } + app.set_name("lifecycle-qualification"); + app.set_ingress_admission_policy(move |head| { + let token = |name| head.headers().get(name).and_then(|value| value.to_str().ok()) + .filter(|value| valid_token(value)).map(str::to_owned); + let case = head.target().path().strip_prefix("/qualification/") + .filter(|name| CASES.contains(name)); + let (Some(run), Some(probe), Some(case)) = (token("x-qualification-run"), token("x-qualification-probe"), case) else { + let mut response = Response::new(Body::from("invalid qualification probe\n")); + *response.status_mut() = StatusCode::BAD_REQUEST; + return AdmissionDecision::Refuse { completion: ResponseEgressCompletion::empty(), + response }; + }; + let trace = Arc::new(ProbeTrace { artifact: artifact.to_owned(), case: case.to_owned(), + counters: Mutex::new(Counters::default()), probe, revision: revision.to_owned(), run }); + let (resource, ownership) = ResponseEgressCompletion::late_bound(); + let terminal_trace = Arc::clone(&trace); + let terminal = ResponseEgressCompletion::new(move |report| { + terminal_trace.record("terminal", |state| state.terminals += 1, json!({ + "outcome": format!("{:?}", report.outcome), "body_kind": format!("{:?}", report.body_kind), + "bytes_written": report.bytes_written, + "fallback": report.fallback_disposition.map(|value| format!("{value:?}")), + })); + }); + AdmissionDecision::Admit { + completion: terminal.join(ownership), grant: IngressGrant::new(ProbeGrant { resource, trace }), + read_deadline: head.read_deadline_after(Duration::from_secs(1)), + } + }); + Ok(()) + } + + fn routes() -> RouterService { + RouterService::builder() + .get("/qualification/{case}", response_probe) + .route("/qualification/{case}", Method::HEAD, response_probe) + .build() + } +} + +impl ProbeTrace { + fn event(&self, kind: &str, sequence: u32, fields: Value) -> Value { + let mut event = json!({ "schema": 1, "run": self.run, "probe": self.probe, "case": self.case, + "revision": self.revision, "artifact": self.artifact, "instance": "unobserved", + "event": kind, "sequence": sequence }); + if let (Some(object), Some(fields)) = (event.as_object_mut(), fields.as_object()) { + object.extend( + fields + .iter() + .map(|(name, value)| (name.clone(), value.clone())), + ); + } + event + } + + fn record(&self, kind: &str, update: impl FnOnce(&mut Counters), fields: Value) { + let mut state = self + .counters + .lock() + .unwrap_or_else(|error| error.into_inner()); + update(&mut state); + let event = self.event(kind, state.sequence, fields); + state.sequence += 1; + log::info!(target: "edgezero_qualification", "{event}"); + } +} + +impl Drop for ProbeTrace { + fn drop(&mut self) { + let state = self + .counters + .lock() + .unwrap_or_else(|error| error.into_inner()); + let event = self.event( + "trace-closed", + state.sequence, + json!({ "terminals": state.terminals, + "source_drops": state.source_drops, "resource_drops": state.resource_drops, + "polls": state.polls, "polls_after_terminal": state.polls_after_terminal }), + ); + log::info!(target: "edgezero_qualification", "{event}"); + } +} + +impl Drop for ResourceDropProbe { + fn drop(&mut self) { + self.0.record( + "resource-drop", + |state| state.resource_drops += 1, + json!({}), + ); + } +} + +impl ProbeSource { + fn new(trace: Arc) -> Self { + let large = matches!( + trace.case.as_str(), + "slow" | "nonread" | "disconnect" | "cancel" + ); + Self { + chunk: Bytes::from(vec![b'x'; if large { 16 * 1024 } else { 1024 }]), + remaining: if large { 512 } else { 3 }, + trace, + } + } +} + +impl Stream for ProbeSource { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + { + let mut state = self + .trace + .counters + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.polls += 1; + if state.terminals != 0 { + state.polls_after_terminal += 1; + } + } + if self.trace.case == "pending" { + return Poll::Pending; + } + if self.remaining == 0 { + return Poll::Ready(None); + } + if self.trace.case == "source-error" && self.remaining == 2 { + self.remaining = 0; + return Poll::Ready(Some(Err(EdgeError::bad_gateway( + "synthetic qualification source error", + )))); + } + self.remaining -= 1; + Poll::Ready(Some(Ok(self.chunk.clone()))) + } +} + +impl Drop for ProbeSource { + fn drop(&mut self) { + self.trace + .record("source-drop", |state| state.source_drops += 1, json!({})); + } +} + +fn hex_identity(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +fn valid_token(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +#[action] +async fn response_probe(ctx: RequestContext) -> Result { + let grant = ctx + .take_ingress_grant() + .and_then(|grant| grant.downcast::().ok()) + .ok_or_else(|| { + EdgeError::internal(std::io::Error::other("qualification grant is absent")) + })?; + grant + .resource + .install(ResourceDropProbe(Arc::clone(&grant.trace))) + .map_err(|_error| { + EdgeError::internal(std::io::Error::other("qualification resource is closed")) + })?; + let case = grant.trace.case.clone(); + let status = match case.as_str() { + "204" => StatusCode::NO_CONTENT, + "205" => StatusCode::RESET_CONTENT, + "304" => StatusCode::NOT_MODIFIED, + _ => StatusCode::OK, + }; + let mut response = response_builder() + .status(status) + .header("content-type", "application/octet-stream") + .body(Body::from_stream(ProbeSource::new(grant.trace))) + .map_err(EdgeError::internal)?; + if case == "conversion-error" { + response.headers_mut().insert( + "content-length", + "not-a-length".parse().map_err(EdgeError::internal)?, + ); + } + response + .extensions_mut() + .insert(ResponseEgressDeadline::after(if case == "expired" { + Duration::ZERO + } else { + Duration::from_millis(500) + })); + Ok(response) +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::StreamExt as _; + + #[test] + fn finite_source_releases_once_and_never_polls_after_terminal() { + let trace = test_trace("normal"); + let mut source = ProbeSource::new(Arc::clone(&trace)); + let mut bytes = 0; + futures::executor::block_on(async { + while let Some(chunk) = source.next().await { + bytes += chunk.expect("source chunk").len(); + } + }); + assert_eq!(bytes, 3072); + drop(source); + let counters = trace.counters.lock().expect("counters"); + assert_eq!(counters.source_drops, 1); + assert_eq!(counters.polls, 4); + assert_eq!(counters.polls_after_terminal, 0); + } + + #[test] + fn pending_source_is_pending_and_source_failure_is_real() { + let trace = test_trace("pending"); + let mut source = Box::pin(ProbeSource::new(trace)); + let mut cx = Context::from_waker(futures::task::noop_waker_ref()); + assert!(source.as_mut().poll_next(&mut cx).is_pending()); + let trace = test_trace("source-error"); + let source = ProbeSource::new(trace); + let chunks = futures::executor::block_on(source.collect::>()); + assert_eq!(chunks.len(), 2); + assert!(chunks.last().expect("source error").is_err()); + } + + fn test_trace(case: &str) -> Arc { + Arc::new(ProbeTrace { + artifact: "b".repeat(64), + case: case.to_owned(), + counters: Mutex::new(Counters::default()), + probe: "test-1".to_owned(), + revision: "a".repeat(40), + run: "test-run".to_owned(), + }) + } +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-fastly/Cargo.toml index db5870bf7..34e42b287 100644 --- a/tests/fixtures/reusable-app/crates/fixture-fastly/Cargo.toml +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/Cargo.toml @@ -4,6 +4,9 @@ version.workspace = true edition.workspace = true publish.workspace = true +[features] +qualification = ["fixture-core/qualification"] + [dependencies] edgezero-core.workspace = true edgezero-adapter-fastly.workspace = true @@ -17,6 +20,11 @@ log.workspace = true name = "fixture-fastly-single-request" path = "src/bin/single_request.rs" +[[bin]] +name = "fixture-fastly-qualification" +path = "src/bin/qualification.rs" +required-features = ["qualification"] + [[bin]] name = "fixture-fastly-rebuild-per-request" path = "src/bin/rebuild_per_request.rs" diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_lifetime.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_lifetime.rs index a4fbdbaa2..1d4f037f9 100644 --- a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_lifetime.rs +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_lifetime.rs @@ -1,4 +1,5 @@ use edgezero_core::app::{App, Hooks, StoresMetadata}; +use edgezero_core::error::EdgeError; use edgezero_core::router::RouterService; use std::time::Duration; @@ -10,16 +11,17 @@ impl Hooks for SlowInitialization { fn stores() -> StoresMetadata { fixture_fastly::MeasuredApp::stores() } - fn build_app() -> App { + fn configure(app: &mut App) -> Result<(), EdgeError> { // A real elapsed deadline, not only the SDK's zero-limit boundary. std::thread::sleep(Duration::from_millis(100)); - fixture_fastly::MeasuredApp::build_app() + fixture_fastly::MeasuredApp::configure(app) } } fn main() -> Result<(), fastly::Error> { - let summary = edgezero_adapter_fastly::serve_app_with_request_extensions::( + let summary = edgezero_adapter_fastly::serve_app_with_hooks::( fixture_fastly::serving().with_max_lifetime(Duration::from_millis(50)), fixture_fastly::extend, + |_response| (), ); let elapsed = summary.time_handler() >= Duration::from_millis(50); fixture_fastly::finish(summary)?; diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_memory.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_memory.rs index 612ffa6bf..4eaedba68 100644 --- a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_memory.rs +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_memory.rs @@ -1,14 +1,19 @@ fn main() -> Result<(), fastly::Error> { + fastly::init(); println!( "{}", serde_json::json!({"event":"heap_preflight", "heap_mib":fastly::compute_runtime::heap_memory_snapshot_mib().ok()}) ); // A one-MiB threshold is below this fixture's observed baseline. If the heap // snapshot fails, the SDK treats usage as u32::MAX and also stops after one request. - fixture_fastly::finish( - edgezero_adapter_fastly::serve_app_with_request_extensions::( - fixture_fastly::serving().with_max_memory(1), - fixture_fastly::extend, - ), - ) + fixture_fastly::finish(edgezero_adapter_fastly::serve_app_with_hooks::< + fixture_fastly::MeasuredApp, + _, + _, + _, + >( + fixture_fastly::serving().with_max_memory(1), + fixture_fastly::extend, + |_response| (), + )) } diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_requests.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_requests.rs index 8ce7b5b8d..c15c294f2 100644 --- a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_requests.rs +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_requests.rs @@ -1,9 +1,9 @@ fn main() -> Result<(), fastly::Error> { let serve = fixture_fastly::serving().with_max_requests(1); - fixture_fastly::finish( - edgezero_adapter_fastly::serve_app_with_request_extensions::( - serve, - fixture_fastly::extend, - ), - ) + fixture_fastly::finish(edgezero_adapter_fastly::serve_app_with_hooks::< + fixture_fastly::MeasuredApp, + _, + _, + _, + >(serve, fixture_fastly::extend, |_response| ())) } diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/logger_negative.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/logger_negative.rs index a0cee0de3..a648d299f 100644 --- a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/logger_negative.rs +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/logger_negative.rs @@ -1,11 +1,3 @@ fn main() -> Result<(), fastly::Error> { - fixture_fastly::finish(fixture_fastly::serving().run(|req| { - let observation = fixture_fastly::observation(&req); - edgezero_adapter_fastly::run_app_with_request_extensions::( - req, - |_, extensions| { - extensions.insert(observation); - }, - ) - })) + fixture_fastly::logger_negative() } diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/qualification.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/qualification.rs new file mode 100644 index 000000000..dbbef3ecb --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/qualification.rs @@ -0,0 +1,6 @@ +fn main() -> Result<(), fastly::Error> { + edgezero_adapter_fastly::serve_app::( + edgezero_adapter_fastly::Serve::new(), + ) + .into_result() +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/retained_app.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/retained_app.rs index 16381ab5d..3bbee3abc 100644 --- a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/retained_app.rs +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/retained_app.rs @@ -1,8 +1,12 @@ fn main() -> Result<(), fastly::Error> { - fixture_fastly::finish( - edgezero_adapter_fastly::serve_app_with_request_extensions::( - fixture_fastly::serving(), - fixture_fastly::extend, - ), - ) + fixture_fastly::finish(edgezero_adapter_fastly::serve_app_with_hooks::< + fixture_fastly::MeasuredApp, + _, + _, + _, + >( + fixture_fastly::serving(), + fixture_fastly::extend, + |_response| (), + )) } diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/single_request.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/single_request.rs index 1136a122a..b8748ddaf 100644 --- a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/single_request.rs +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/single_request.rs @@ -1,7 +1,7 @@ -#[fastly::main] -fn main(req: fastly::Request) -> Result { - edgezero_adapter_fastly::run_app_with_request_extensions::( - req, +fn main() -> Result<(), fastly::Error> { + edgezero_adapter_fastly::run_app_with_hooks::( fixture_fastly::extend, + |_response| (), ) + .map(|_state| ()) } diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/lib.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/lib.rs index cde8f6a46..26c740eb8 100644 --- a/tests/fixtures/reusable-app/crates/fixture-fastly/src/lib.rs +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/lib.rs @@ -1,11 +1,18 @@ -use edgezero_adapter_fastly::{FastlyLogging, Serve, ServeSummary, init_logger}; -use edgezero_core::app::{App, Hooks, StoreMetadata}; +use edgezero_adapter_fastly::lifecycle::{Sandbox, serve_custom}; +use edgezero_adapter_fastly::outbound::FastlyOutboundClient; +use edgezero_adapter_fastly::request::send_request_with_registries_and_hooks; +use edgezero_adapter_fastly::{FASTLY_PLATFORM, FastlyLogging, Serve, ServeSummary, init_logger}; +use edgezero_core::app::{App, Hooks}; use edgezero_core::body::Body; -use edgezero_core::http::Extensions; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{Extensions, Response as CoreResponse, StatusCode, Uri}; +use edgezero_core::outbound::{HttpClient, OutboundRequest}; +use edgezero_core::{ + AdmissionDecision, DEFAULT_INBOUND_READ_BUDGET, IngressGrant, ResponseEgressCompletion, +}; use fastly::{Error, Request, Response}; use fixture_core::{FixtureApp, Observation, observe}; -use futures::{StreamExt, executor::block_on}; -use std::io::Write; +use futures::executor::block_on; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -61,7 +68,7 @@ pub fn observation(req: &Request) -> Observation { ); value } -pub fn extend(req: &Request, extensions: &mut Extensions) { +pub fn extend(req: &mut Request, extensions: &mut Extensions) { let value = observation(req); let token = req.get_header_str("x-request-token").unwrap_or("missing"); initialized(&value, token); @@ -102,24 +109,59 @@ fn post_commit_error(obs: &Observation, token: &str, error: &dyn std::fmt::Displ ); } pub fn rebuilt() -> Result<(), Error> { - let mut logger_installed = false; - let logging = FastlyLogging::from(MeasuredApp::logging_for("fastly")); - finish(serving().run(move |req| -> Result { + finish(serve_custom(serving(), |req, setup: &mut Sandbox<()>| { + let stores = MeasuredApp::stores(); + let logging = FastlyLogging::from(MeasuredApp::logging_for("fastly")); + setup.setup_once(|| install_logger(&logging))?; + let app = App::build::(FASTLY_PLATFORM)?; + send_request_with_registries_and_hooks(&app, stores, req, extend, |_response| ()) + .map(|_state| ()) + })) +} + +pub fn logger_negative() -> Result<(), Error> { + finish(serve_custom(serving(), |req, _state: &mut Sandbox<()>| { + let obs = observation(&req); let stores = MeasuredApp::stores(); - if !logger_installed { - install_logger(&logging)?; - logger_installed = true; + let logging = FastlyLogging::from(MeasuredApp::logging_for("fastly")); + if let Err(error) = install_logger(&logging) { + Response::from_status(500) + .with_body(error.to_string()) + .send_to_client(); + return Err(error); } - let app = MeasuredApp::build_app(); - edgezero_adapter_fastly::request::dispatch_with_registries(&app, req, stores, extend) + let app = App::build::(FASTLY_PLATFORM)?; + initialized( + &obs, + req.get_header_str("x-request-token").unwrap_or("missing"), + ); + send_request_with_registries_and_hooks( + &app, + stores, + req, + |_, extensions| { + extensions.insert(obs); + }, + |_response| (), + ) + .map(|_state| ()) })) } -fn custom_dispatch( - mut req: Request, - sandbox: &mut edgezero_adapter_fastly::lifecycle::Sandbox, - reuse_app: bool, -) -> Result<(), Error> { +fn finalize_response(response: &mut CoreResponse, attempts: u64) { + if let Some(fixture_core::Finalize(value)) = + response.extensions_mut().remove::() + { + response + .headers_mut() + .append("x-fixture-finalized", value.parse().unwrap()); + } + response + .headers_mut() + .insert("x-fixture-attempts", attempts.to_string().parse().unwrap()); +} + +fn custom_dispatch(req: Request, sandbox: &mut Sandbox, reuse_app: bool) -> Result<(), Error> { let token = req .get_header_str("x-request-token") .unwrap_or("missing") @@ -153,15 +195,14 @@ fn custom_dispatch( .get_header_str("x-fixture-backend") .filter(|uri| uri.contains("/chunks")) .map(|uri| uri.replace("/chunks", "/post-send")); - req.set_header("x-fixture-mutated", "true"); // Per-request mode scopes application state to the current callback. - let mut per_request = edgezero_adapter_fastly::lifecycle::Sandbox::default(); + let mut per_request = Sandbox::default(); let state = if reuse_app { sandbox } else { &mut per_request }; let result = state.initialize(|| { if req.get_path() == "/initialization-error" { Err(Error::msg("injected initialization failure")) } else { - Ok(MeasuredApp::build_app()) + App::build::(FASTLY_PLATFORM).map_err(Error::from) } }); if result.is_err() { @@ -176,89 +217,47 @@ fn custom_dispatch( return Ok(()); } initialized(&obs, &token); - let core = edgezero_adapter_fastly::request::into_core_request_with_registries( - req, + let app = state.state().unwrap(); + let attempts = state.initialization_attempts(); + let delivered = send_request_with_registries_and_hooks( + app, stores, - |_, extensions| { + req, + |raw, extensions| { + raw.set_header("x-fixture-mutated", "true"); extensions.insert(obs.clone()); }, - )?; - let proxy = core - .extensions() - .get::() - .cloned(); - let mut response = block_on(state.state().unwrap().router().oneshot(core))?; - if let Some(fixture_core::Finalize(value)) = - response.extensions_mut().remove::() - { - response - .headers_mut() - .append("x-fixture-finalized", value.parse().unwrap()); - } - let (parts, body) = response.into_parts(); - let mut native = Response::from_status(parts.status.as_u16()); - native.set_header( - "x-fixture-attempts", - state.initialization_attempts().to_string(), + |response| finalize_response(response, attempts), ); - for (name, value) in &parts.headers { - native.append_header(name.as_str(), value.as_bytes()); - } - match body { - Body::Once(bytes) => { - native.set_body(bytes.to_vec()); - native.send_to_client(); - lifecycle("response_committed", &obs, &token); + match delivered { + Err(error) => { + post_commit_error(&obs, &token, &error); + lifecycle("guest_completed", &obs, &token); + return Ok(()); } - Body::Stream(mut stream) => { - let mut writer = native.stream_to_client(); - lifecycle("response_committed", &obs, &token); - while let Some(chunk) = block_on(stream.next()) { - match chunk { - Ok(bytes) => { - if let Err(error) = writer.write_all(&bytes).and_then(|()| writer.flush()) { - post_commit_error(&obs, &token, &error); - drop(writer); - lifecycle("guest_completed", &obs, &token); - return Ok(()); - } - } - Err(error) => { - post_commit_error(&obs, &token, &error); - drop(writer); - lifecycle("guest_completed", &obs, &token); - return Ok(()); - } - } - } - if let Err(error) = writer.finish() { - post_commit_error(&obs, &token, &error); - } + Ok(None) => { + lifecycle("guest_completed", &obs, &token); + return Ok(()); } + Ok(Some(())) => lifecycle("delivery_completed", &obs, &token), } - if let (Some(uri), Some(proxy)) = (post_send, proxy) { - let Ok(uri) = uri.parse::() else { - return Ok(()); - }; - if fixture_core::is_loopback_http(&uri) { - let outcome = block_on(proxy.forward(edgezero_core::proxy::ProxyRequest::new( - edgezero_core::http::Method::GET, - uri, - ))); - // All failures here are after commitment: record them without a second send. - match outcome { - Ok(response) => { - if let Body::Stream(mut stream) = response.into_body() { - while let Some(chunk) = block_on(stream.next()) { - if let Err(error) = chunk { - println!("post-send body failure: {error}"); - break; - } - } - } - } - Err(error) => println!("post-send failure: {error}"), + if let Some(uri) = post_send { + let outcome = block_on(async { + let uri: Uri = uri.parse().map_err(EdgeError::internal)?; + if !fixture_core::is_loopback_http(&uri) { + return Err(EdgeError::bad_request("backend must be loopback HTTP")); } + let client = + HttpClient::with_client(FastlyOutboundClient::with_clock(app.monotonic_clock())); + client + .send(OutboundRequest::get(uri.to_string())?.stream_response()) + .await? + .into_bytes_bounded(1024 * 1024) + .await?; + Ok::<(), EdgeError>(()) + }); + if let Err(error) = outcome { + post_commit_error(&obs, &token, &error); } } println!( @@ -276,6 +275,7 @@ pub fn custom(reuse_sandbox: bool, reuse_app: bool) -> Result<(), Error> { move |req, state| custom_dispatch(req, state, reuse_app), )) } else { + fastly::init(); edgezero_adapter_fastly::lifecycle::run_custom(Request::from_client(), |req, state| { custom_dispatch(req, state, false) }) @@ -301,13 +301,13 @@ impl Hooks for MeasuredApp { fn stores() -> edgezero_core::app::StoresMetadata { FixtureApp::stores() } - fn build_app() -> App { + fn configure(app: &mut App) -> Result<(), EdgeError> { if PANIC_IN_CONSTRUCTOR.swap(false, Ordering::SeqCst) { println!( "{}", - serde_json::json!({"event":"constructor_panic","instance":fixture_core::instance_id(),"source":"injected_inside_build_app"}) + serde_json::json!({"event":"constructor_panic","instance":fixture_core::instance_id(),"source":"injected_inside_configure"}) ); - panic!("injected failure inside MeasuredApp::build_app"); + panic!("injected failure inside MeasuredApp::configure"); } let cpu_before = fastly::compute_runtime::elapsed_vcpu_ms().ok(); let heap_before = fastly::compute_runtime::heap_memory_snapshot_mib().ok(); @@ -316,85 +316,165 @@ impl Hooks for MeasuredApp { .unwrap_or("0") .parse() .unwrap(); + let source = + serde_json::json!({"routes": ["one", "two"], "cache": {"capacity": 128}}).to_string(); for _ in 0..rounds { - let parsed: serde_json::Value = - serde_json::from_str(r#"{"routes":["one","two"],"cache":{"capacity":128}}"#) - .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&source).unwrap(); std::hint::black_box(parsed); } - let app = FixtureApp::build_app(); + FixtureApp::configure(app)?; + app.set_ingress_admission_policy(|head| { + let token = head + .headers() + .get("x-request-token") + .and_then(|value| value.to_str().ok()) + .unwrap_or("missing") + .to_owned(); + AdmissionDecision::Admit { + completion: ResponseEgressCompletion::new(move |report| { + println!( + "{}", + serde_json::json!({"event":"egress_terminal","instance":fixture_core::instance_id(), + "token":token,"outcome":format!("{:?}",report.outcome), + "body_kind":format!("{:?}",report.body_kind),"bytes_written":report.bytes_written, + "fallback":report.fallback_disposition.map(|value|format!("{value:?}"))}) + ); + }), + grant: IngressGrant::empty(), + read_deadline: head.read_deadline_after(DEFAULT_INBOUND_READ_BUDGET), + } + }); INITIALIZATIONS.lock().unwrap().push(serde_json::json!({"event":"initialization", "wall_ns":start.elapsed().as_nanos(), "cpu_before_ms":cpu_before,"cpu_after_ms":fastly::compute_runtime::elapsed_vcpu_ms().ok(), "heap_before_mib":heap_before,"heap_after_mib":fastly::compute_runtime::heap_memory_snapshot_mib().ok(), "construction_rounds":rounds})); - app + Ok(()) } } /// Controlled fixture inputs exercise real adapter boundaries, not provider outages. pub fn faults() -> Result<(), Error> { - use edgezero_core::http::response_builder; - let mut retained = None; - finish(serving().run(move |mut req: Request| -> Result { - let obs = observation(&req); - let token = req.get_header_str("x-request-token").unwrap_or("missing").to_owned(); - let path = req.get_path().to_owned(); - if path == "/constructor-panic" { - PANIC_IN_CONSTRUCTOR.store(true, Ordering::SeqCst); - retained = None; - } - let app = retained.get_or_insert_with(MeasuredApp::build_app); - initialized(&obs, &token); - let result = match path.as_str() { - "/collect-error" => { - let body = Body::from_stream(futures::stream::iter([ - Err(std::io::Error::other("injected core stream failure")) - ])); - edgezero_adapter_fastly::response::from_core_response(response_builder().body(body).unwrap()).map_err(Error::from) + use edgezero_core::app::{StoreMetadata, StoresMetadata}; + finish(serve_custom( + serving(), + |mut req: Request, retained: &mut Sandbox| { + let obs = observation(&req); + let token = req + .get_header_str("x-request-token") + .unwrap_or("missing") + .to_owned(); + let path = req.get_path().to_owned(); + if path == "/constructor-panic" { + PANIC_IN_CONSTRUCTOR.store(true, Ordering::SeqCst); } - "/inbound-error" => { - let mut upstream = Request::get("http://fault-origin/abort").send("fault-origin")?; + retained.initialize(|| App::build::(FASTLY_PLATFORM))?; + let app = retained.state().unwrap(); + initialized(&obs, &token); + if path == "/inbound-error" { + let mut upstream = + Request::get("http://fault-origin/abort").send("fault-origin")?; req.set_body(upstream.take_body()); - edgezero_adapter_fastly::request::into_core_request(req) - .map(|_| Response::from_status(200)).map_err(Error::from) } - _ => { - let stores = if path == "/recoverable-store-error" - || path == "/terminal-store-error" - { - edgezero_core::app::StoresMetadata { - kv: Some(StoreMetadata { - default: "missing_fixture_store", - ids: &["missing_fixture_store"], - }), - ..MeasuredApp::stores() + let stores = if path == "/recoverable-store-error" || path == "/terminal-store-error" { + StoresMetadata { + kv: Some(StoreMetadata { + default: "missing_fixture_store", + ids: &["missing_fixture_store"], + }), + ..MeasuredApp::stores() + } + } else { + MeasuredApp::stores() + }; + let result = send_request_with_registries_and_hooks( + app, + stores, + req, + |raw, extensions| { + if path == "/collect-error" || path == "/inbound-error" { + raw.set_url(format!("http://example.com/probe{}", path)); + } + extensions.insert(obs.clone()); + }, + |response| { + if path == "/collect-error" { + *response.status_mut() = StatusCode::OK; + *response.body_mut() = Body::from_stream(futures::stream::once(async { + Err(EdgeError::internal(std::io::Error::other( + "injected core stream failure", + ))) + })); + } + response.status() + }, + ); + match result { + Ok(status) => { + if path == "/inbound-error" { + assert_eq!(status, Some(StatusCode::INTERNAL_SERVER_ERROR)); + lifecycle("ingress_error_response", &obs, &token); + } + lifecycle("delivery_completed", &obs, &token); + Ok(()) + } + Err(error) => { + println!( + "{}", + serde_json::json!({"event":"adapter_error","instance":obs.instance,"ordinal":obs.ordinal,"token":token,"path":path,"error":error.to_string(),"source":"controlled_fixture_input"}) + ); + if path == "/recoverable-store-error" { + // Explicit custom policy permits another callback; standard helpers propagate. + Response::from_status(503) + .with_body("injected selector failed") + .send_to_client(); + Ok(()) + } else { + if path == "/terminal-store-error" { + Response::from_status(500) + .with_body("injected selector failed") + .send_to_client(); + } else if path == "/collect-error" { + post_commit_error(&obs, &token, &error); + } + lifecycle("terminal_error", &obs, &token); + Err(error) } - } else { - MeasuredApp::stores() - }; - edgezero_adapter_fastly::request::dispatch_with_registries( - app, - req, - stores, - |_, extensions| { - extensions.insert(obs.clone()); - }, - ) - } - }; - match result { - Ok(response) => { lifecycle("conversion_completed", &obs, &token); Ok(response) } - Err(error) => { - println!("{}", serde_json::json!({"event":"adapter_error","instance":obs.instance,"ordinal":obs.ordinal,"token":token,"path":path,"error":error.to_string(),"source":"controlled_fixture_input"})); - if path == "/recoverable-store-error" { - // Explicit custom policy permits another callback; standard helpers propagate. - Ok(Response::from_status(503).with_body("injected selector failed")) - } else { - lifecycle("terminal_error", &obs, &token); - Err(error) } } + }, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::http::Response as CoreResponse; + + #[test] + fn finalization_preserves_streaming_body_and_request_local_headers() { + for (attempts, token) in [(1, "first"), (2, "second")] { + let mut response = CoreResponse::new(Body::stream(futures::stream::once(async { + Body::from("chunk").into_bytes().unwrap() + }))); + response + .extensions_mut() + .insert(fixture_core::Finalize(token.to_owned())); + + finalize_response(&mut response, attempts); + + assert_eq!(response.headers()["x-fixture-finalized"], token); + assert_eq!( + response.headers()["x-fixture-attempts"], + attempts.to_string() + ); + assert!( + response + .extensions() + .get::() + .is_none() + ); + assert!(matches!(response.body(), Body::Stream(_))); } - })) + } } diff --git a/tests/fixtures/reusable-app/crates/fixture-harness/src/net.rs b/tests/fixtures/reusable-app/crates/fixture-harness/src/net.rs index 8c5653245..d798810ac 100644 --- a/tests/fixtures/reusable-app/crates/fixture-harness/src/net.rs +++ b/tests/fixtures/reusable-app/crates/fixture-harness/src/net.rs @@ -35,6 +35,45 @@ impl std::fmt::Display for InterruptedResponse { } } impl std::error::Error for InterruptedResponse {} + +#[derive(Debug)] +pub struct ResponseHeadAborted(std::io::ErrorKind); +impl std::fmt::Display for ResponseHeadAborted { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "response ended before its status: {:?}", self.0) + } +} +impl std::error::Error for ResponseHeadAborted {} + +fn read_status(reader: &mut impl BufRead) -> Result { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => { + return Err(Box::new(ResponseHeadAborted( + std::io::ErrorKind::UnexpectedEof, + ))); + } + Err(error) + if line.is_empty() + && matches!( + error.kind(), + std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::UnexpectedEof + ) => + { + return Err(Box::new(ResponseHeadAborted(error.kind()))); + } + Err(error) => return Err(error.into()), + Ok(_) => {} + } + Ok(line + .split_whitespace() + .nth(1) + .ok_or("missing HTTP response status")? + .parse()?) +} + pub fn release(signal: &Signal) { *signal.0.lock().unwrap() = true; signal.1.notify_all(); @@ -156,6 +195,13 @@ fn serve( stream.write_all( b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\npartial", )?; + if path.contains("gated=1") { + stream.flush()?; + let (_observed, _) = signal + .1 + .wait_timeout_while(signal.0.lock().unwrap(), Duration::from_secs(8), |v| !*v) + .map_err(|_| "release poisoned")?; + } } else { stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")?; } @@ -201,13 +247,8 @@ pub fn request( write!(stream, "\r\n{body}")?; stream.flush()?; let mut reader = BufReader::new(stream); + let status = read_status(&mut reader)?; let mut line = String::new(); - reader.read_line(&mut line)?; - let status: u16 = line - .split_whitespace() - .nth(1) - .ok_or("missing HTTP response status")? - .parse()?; let mut headers = Vec::new(); let mut length = None; let mut chunked = false; @@ -307,6 +348,35 @@ pub fn request( #[cfg(test)] mod tests { use super::*; + + #[test] + fn no_response_head_classification_excludes_timeouts_and_malformed_bytes() { + struct FailedRead(std::io::ErrorKind); + impl Read for FailedRead { + fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { + Err(std::io::Error::from(self.0)) + } + } + let eof = read_status(&mut std::io::Cursor::new(b"")).unwrap_err(); + assert!(eof.downcast_ref::().is_some()); + for kind in [ + std::io::ErrorKind::ConnectionReset, + std::io::ErrorKind::TimedOut, + ] { + let error = read_status(&mut BufReader::new(FailedRead(kind))).unwrap_err(); + assert_eq!( + error.downcast_ref::().is_some(), + kind == std::io::ErrorKind::ConnectionReset + ); + } + let invalid = read_status(&mut std::io::Cursor::new(b"invalid\r\n")).unwrap_err(); + assert!(invalid.downcast_ref::().is_none()); + assert_eq!( + read_status(&mut std::io::Cursor::new(b"HTTP/1.1 500 Error\r\n")).unwrap(), + 500 + ); + } + #[test] fn nonblocking_connection_waits_for_request_bytes() { let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); @@ -346,6 +416,37 @@ mod tests { assert_eq!(partial.body, b"partial"); } + #[test] + fn gated_abort_waits_for_client_observation_before_closing() { + let backend = Backend::start().unwrap(); + let mut stream = TcpStream::connect(("127.0.0.1", backend.port)).unwrap(); + stream + .set_read_timeout(Some(Duration::from_millis(200))) + .unwrap(); + stream + .write_all(b"GET /abort?gated=1 HTTP/1.1\r\nHost: localhost\r\n\r\n") + .unwrap(); + let mut reader = BufReader::new(stream); + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" { + break; + } + } + let mut partial = [0; 7]; + reader.read_exact(&mut partial).unwrap(); + assert_eq!(&partial, b"partial"); + let before_release = reader.read(&mut [0]); + release(&backend.release); + assert!(matches!( + before_release, + Err(error) if matches!(error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) + )); + assert_eq!(reader.read(&mut [0]).unwrap(), 0); + } + #[test] fn connection_failure_is_not_an_interrupted_response() { let unused = port().unwrap(); diff --git a/tests/fixtures/reusable-app/crates/fixture-harness/src/runners.rs b/tests/fixtures/reusable-app/crates/fixture-harness/src/runners.rs index 53d1792e2..68fc11f86 100644 --- a/tests/fixtures/reusable-app/crates/fixture-harness/src/runners.rs +++ b/tests/fixtures/reusable-app/crates/fixture-harness/src/runners.rs @@ -480,13 +480,15 @@ fn custom_stream_checks( large, json!({"variant":variant,"repetition":repetition,"assertion":"large_progressive_stream"}), ))?; + // Trigger truncation only after the downstream client has observed the partial body. + *backend.release.0.lock().unwrap() = false; let failure_token = crate::token(); let failure = net::request( port, "/stream-error", &failure_token, - Some(&backend.url(&format!("/abort?token={failure_token}"))), - None, + Some(&backend.url(&format!("/abort?gated=1&token={failure_token}"))), + Some(&backend.release), ); let failure = match failure { Ok(reply) => { @@ -756,7 +758,7 @@ fn provider_command( )?; command .current_dir(state) - .env("fixture_marker", "fixture-only-value"); + .env("EDGEZERO__SECRETS__FIXTURE_MARKER", "fixture-only-value"); } else if name == "cloudflare" { let state = run.join(mode).join("worker-state"); checked( @@ -1005,10 +1007,39 @@ fn faults(exe: &Path, run: &Path, records: &mut Records) -> Result<()> { )?; records.push(json!({"status":"pass","assertion":"malformed_rejected_before_dispatch","token":malformed_token,"status_line":rejected}))?; let failed_token = token(); - let reply = net::request(port, path, &failed_token, None, None)?; + let reply = if *path == "/collect-error" { + let failure = net::request(port, path, &failed_token, None, None) + .err() + .ok_or("owned egress must abandon the failed response stream")?; + if let Some(partial) = failure.downcast_ref::() { + require( + partial.status == 200 && partial.body.is_empty(), + "egress source failure sent a replacement response", + )?; + json!({"status":partial.status,"body":"","interrupted":true}) + } else { + // An immediate source error can reset delivery before the head is observed. + require( + failure.downcast_ref::().is_some(), + format!("unexpected response-head failure: {failure}"), + )?; + json!({"no_response_head":true,"interrupted":true,"error":failure.to_string()}) + } + } else { + net::request(port, path, &failed_token, None, None)? + }; let recoverable = *path == "/recoverable-store-error"; + let rendered_ingress = *path == "/inbound-error"; require( - reply["status"] == if recoverable { 503 } else { 500 }, + (*path == "/collect-error" && reply["no_response_head"] == true) + || reply["status"] + == if *path == "/collect-error" { + 200 + } else if recoverable { + 503 + } else { + 500 + }, format!("fault not observed: {path}: {reply}"), )?; let following = req(port, "/probe/recovered")?; @@ -1023,6 +1054,8 @@ fn faults(exe: &Path, run: &Path, records: &mut Records) -> Result<()> { .ok_or("missing fault attempt")?; let event = if *path == "/constructor-panic" { "constructor_panic" + } else if rendered_ingress { + "ingress_error_response" } else { "adapter_error" }; @@ -1033,7 +1066,23 @@ fn faults(exe: &Path, run: &Path, records: &mut Records) -> Result<()> { "missing actual failure boundary", ) })?; - if recoverable { + if *path == "/collect-error" { + let terminal = logs(&log) + .into_iter() + .filter(|event| { + event["event"] == "egress_terminal" && event["token"] == failed_token + }) + .collect::>(); + require( + terminal.len() == 1 + && terminal[0]["outcome"] == "SourceError" + && terminal[0]["body_kind"] == "Application" + && terminal[0]["bytes_written"] == 0 + && terminal[0]["fallback"].is_null(), + "immediate source failure must terminate once without fallback delivery", + )?; + } + if recoverable || rendered_ingress { let status = if value["instance"] == failed_guest { "pass" } else { @@ -1041,14 +1090,18 @@ fn faults(exe: &Path, run: &Path, records: &mut Records) -> Result<()> { }; require( value["builds"] == 1, - "retained app rebuilt after handled store failure", + "retained app rebuilt after handled failure", )?; require( guest(&req(port, "/bindings")?)? == json!({"config":true,"kv":true,"secrets":true,"unknown":false}), "bindings failed to recover", )?; - records.push(json!({"status":status,"assertion":"injected_selector_failure_recovery","instance":failed_guest,"source":"custom_callback_handles_real_registry_error"}))?; + if rendered_ingress { + records.push(json!({"status":status,"assertion":"admitted_body_failure_rendered","instance":failed_guest,"source":"canonical_admitted_request_dispatch"}))?; + } else { + records.push(json!({"status":status,"assertion":"injected_selector_failure_recovery","instance":failed_guest,"source":"custom_callback_handles_real_registry_error"}))?; + } } else { require( value["instance"] != failed_guest, diff --git a/tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml index cd7d9156f..7d3dbc8c7 100644 --- a/tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml +++ b/tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml @@ -4,12 +4,15 @@ version.workspace = true edition.workspace = true publish.workspace = true +[features] +qualification = ["fixture-core/qualification"] + [dependencies] serde_json.workspace = true fixture-core.workspace = true edgezero-core.workspace = true edgezero-adapter-spin.workspace = true -spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value"] } +spin-sdk = { workspace = true, features = ["http", "key-value"] } anyhow = "1" [lib] diff --git a/tests/fixtures/reusable-app/crates/fixture-spin/src/lib.rs b/tests/fixtures/reusable-app/crates/fixture-spin/src/lib.rs index b3f0949b1..a5188305c 100644 --- a/tests/fixtures/reusable-app/crates/fixture-spin/src/lib.rs +++ b/tests/fixtures/reusable-app/crates/fixture-spin/src/lib.rs @@ -1,5 +1,16 @@ +#![cfg(target_arch = "wasm32")] +#![allow( + unsafe_code, + reason = "spin's #[http_service] macro generates the unsafe wasm export" +)] + +use anyhow::Context as _; use edgezero_core::app::{App, Hooks}; -use fixture_core::{FixtureApp, MissingBindingApp, OtherApp}; +#[cfg(not(feature = "qualification"))] +use fixture_core::FixtureApp; +#[cfg(feature = "qualification")] +use fixture_core::qualification::QualificationApp as FixtureApp; +use fixture_core::{MissingBindingApp, OtherApp, retained_app}; use spin_sdk::{ http::{IntoResponse, Request}, http_service, @@ -8,30 +19,32 @@ use std::sync::OnceLock; static APP: OnceLock = OnceLock::new(); static OTHER_APP: OnceLock = OnceLock::new(); +fn retained_app_for_dispatch(cell: &OnceLock) -> anyhow::Result<&App> { + retained_app::(cell, edgezero_adapter_spin::SPIN_PLATFORM) + .context("application configuration failed") +} + #[http_service] -async fn handle(req: Request) -> anyhow::Result { +async fn handle(req: Request) -> anyhow::Result { if !FixtureApp::owns_logging() { drop(edgezero_adapter_spin::init_logger()); } let retained = std::env::var("FIXTURE_MODE").as_deref() == Ok("retained"); if retained && req.uri().path() == "/binding-failure" { - // Pairs MissingBindingApp with FixtureApp's router; valid only while - // dispatch_app reads nothing but A::stores() (see MissingBindingApp). - let result = edgezero_adapter_spin::dispatch_app::( - APP.get_or_init(FixtureApp::build_app), - req, - ) - .await; + let app = App::build::(edgezero_adapter_spin::SPIN_PLATFORM) + .context("application configuration failed")?; + let result = edgezero_adapter_spin::dispatch_app::(&app, req).await; return match result { Err(error) => { let body = serde_json::json!({"instance":fixture_core::instance_id(),"source":"injected_required_binding","error":format!("{error:#}")}).to_string(); - Ok(edgezero_adapter_spin::response::from_core_response( - edgezero_core::http::response_builder() - .status(503) - .body(edgezero_core::body::Body::from(body)) - .unwrap(), - ) - .await?) + edgezero_core::http::response_builder() + .status(503) + .header("content-type", "application/json") + .body(body)? + .into_response() + .map_err(|error| { + anyhow::anyhow!("fixture response conversion failed: {error:?}") + }) } Ok(response) => Ok(response), }; @@ -45,14 +58,14 @@ async fn handle(req: Request) -> anyhow::Result { return edgezero_adapter_spin::run_app::(req).await; } return edgezero_adapter_spin::dispatch_app::( - OTHER_APP.get_or_init(OtherApp::build_app), + retained_app_for_dispatch::(&OTHER_APP)?, req, ) .await; } if retained { edgezero_adapter_spin::dispatch_app::( - APP.get_or_init(FixtureApp::build_app), + retained_app_for_dispatch::(&APP)?, req, ) .await