diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e629d17a..e82aaeae 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,6 +65,10 @@ jobs: - name: Nested AppConfig checker tests run: cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config + - name: Lifecycle fixture host tests + run: | + cargo test --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-harness -p fixture-core + - name: Run workspace tests run: cargo test --workspace --all-targets @@ -247,5 +251,19 @@ jobs: ${{ matrix.runner_env }}: ${{ matrix.runner_value }} run: cargo test -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 --lib + - name: Fastly reusable HTTP smoke + if: matrix.adapter == 'fastly' + run: ./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smoke --require-runtime + + - name: Preserve Fastly lifecycle evidence + if: always() && matrix.adapter == 'fastly' + uses: actions/upload-artifact@v4 + with: + name: fastly-lifecycle-evidence + path: tests/fixtures/reusable-app/.runs/ + include-hidden-files: true + if-no-files-found: ignore + retention-days: 7 + - name: Check ${{ matrix.adapter }} wasm target run: cargo check -p edgezero-adapter-${{ matrix.adapter }} --features ${{ matrix.adapter }} --target ${{ matrix.target }} diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index edd9224f..17517649 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -21,7 +21,7 @@ pub mod response; pub mod secret_store; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -use edgezero_core::app::{Hooks, StoresMetadata}; +use edgezero_core::app::{App, Hooks, StoresMetadata}; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use edgezero_core::env_config::EnvConfig; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] @@ -123,3 +123,38 @@ pub async fn run_app( ) .await } + +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +/// Dispatch a caller-owned app with explicit store metadata. +/// +/// Resolves configuration and request resources for this invocation without +/// building or caching an app or installing logging. Pass metadata matching +/// the app (normally `MyApp::stores()`). Retain only application-owned values; +/// native handles and pending work belong to the request. Shared app state +/// must support overlapping invocations. +/// +/// # Errors +/// Returns conversion or dispatch errors from the existing adapter boundary. +#[inline] +pub async fn dispatch_app( + 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 +} diff --git a/crates/edgezero-adapter-cloudflare/src/response.rs b/crates/edgezero-adapter-cloudflare/src/response.rs index 7843b899..a0edea48 100644 --- a/crates/edgezero-adapter-cloudflare/src/response.rs +++ b/crates/edgezero-adapter-cloudflare/src/response.rs @@ -33,11 +33,20 @@ pub fn from_core_response(response: Response) -> Result { let mut cf_response = body_response.with_status(parts.status.as_u16()); let headers = cf_response.headers_mut(); - for (name, value) in &parts.headers { - if let Ok(value_str) = value.to_str() { - headers - .set(name.as_str(), value_str) + for name in parts.headers.keys() { + let mut first = true; + for value in parts.headers.get_all(name) { + if let Ok(value_str) = value.to_str() { + // Replace any body-generated default once, then retain every + // additional application value (especially Set-Cookie). + if first { + headers.set(name.as_str(), value_str) + } else { + headers.append(name.as_str(), value_str) + } .map_err(EdgeError::internal)?; + first = false; + } } } Ok(cf_response) diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 36161a35..5e4e4c7b 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -1,6 +1,14 @@ //! Utilities for bridging Fastly Compute@Edge requests into the //! `edgezero-core` service abstractions. +#![cfg_attr( + feature = "fastly", + expect( + clippy::pub_use, + reason = "re-export the SDK serving builder rather than duplicate its API" + ) +)] + // Only compiled where it is actually used (the CLI push/GC path and the Fastly // runtime resolver). Gating it keeps a `--no-default-features` build dead-code // clean instead of dragging in helpers no feature references. @@ -13,6 +21,7 @@ pub mod config_store; pub mod context; #[cfg(feature = "fastly")] pub mod key_value_store; +pub mod lifecycle; #[cfg(feature = "fastly")] pub mod logger; #[cfg(feature = "fastly")] @@ -24,6 +33,8 @@ 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; #[cfg(any(feature = "fastly", test))] @@ -36,6 +47,8 @@ use edgezero_core::http::Extensions; use edgezero_core::manifest::ResolvedLoggingConfig; #[cfg(feature = "fastly")] use fastly::compute_runtime::service_id; +#[cfg(feature = "fastly")] +pub use fastly::http::serve::{Serve, ServeSummary}; #[cfg(any(feature = "cli", feature = "fastly", test))] const RUNTIME_ENV_PREFIX: &str = "EDGEZERO__"; @@ -104,6 +117,35 @@ impl From<&EnvConfig> for FastlyLogging { } } +#[cfg(any(feature = "fastly", test))] +#[derive(Default)] +struct RetainedApp { + app: Option, +} + +#[cfg(any(feature = "fastly", test))] +impl RetainedApp { + fn get_or_init( + &mut self, + env: &EnvConfig, + 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() { + 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)) + } +} + /// Prefix a canonical `EDGEZERO__*` key with its owning Fastly service. /// /// The shared `edgezero_runtime_env` Config Store is account-wide. Service @@ -432,3 +474,161 @@ mod runtime_env_key_tests { ); } } + +#[cfg(test)] +mod retained_app_tests { + use super::*; + use edgezero_core::app::{App, Hooks}; + use edgezero_core::router::RouterService; + use std::cell::{Cell, RefCell}; + + struct CountedApp; + thread_local! { static CONFIGURES: Cell = const { Cell::new(0) }; } + #[expect( + clippy::missing_trait_methods, + reason = "exercise default app construction" + )] + impl Hooks for CountedApp { + fn configure(app: &mut App) { + CONFIGURES.with(|count| count.set(count.get().checked_add(1).unwrap())); + app.set_name("retained"); + } + fn routes() -> RouterService { + RouterService::builder().build() + } + } + + fn configured_env() -> EnvConfig { + EnvConfig::from_vars([ + ("EDGEZERO__LOGGING__ENDPOINT", "fixture-logs"), + ("EDGEZERO__LOGGING__LEVEL", "debug"), + ]) + } + + #[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_env(), + || 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_keeps_first_degraded_snapshot() { + let mut retained = RetainedApp::default(); + let builds = Cell::new(0_usize); + for env in [EnvConfig::default(), configured_env()] { + retained + .get_or_init( + &env, + || false, + |_, _, _| -> Result<(), &'static str> { panic!("must not install later") }, + || { + 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_env(), + || 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); + } + + #[test] + fn retained_app_logger_error_prevents_construction() { + let mut retained = RetainedApp::default(); + let result = retained.get_or_init( + &configured_env(), + || false, + |_, _, _| Err("logger failed"), + || panic!("must not build"), + ); + assert_eq!(result.err(), Some("logger failed")); + assert!(retained.app.is_none()); + } +} + +#[cfg(feature = "fastly")] +/// 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. +#[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| {}) +} + +#[cfg(feature = "fastly")] +/// Serve a retained app with fresh request extensions and store registries. +/// +/// Runtime configuration is read on every callback. Logging uses only the first +/// snapshot, before app construction, unless `Hooks::owns_logging` is true. +/// An unavailable optional configuration store freezes logging as disabled, +/// even if later reads recover store selectors. Initialization errors terminate +/// the SDK loop; construction panics remain sandbox failures. +/// +/// The mutable callback runs per request. For mutable native requests, manual +/// streaming, response finalization, or custom initialization, use [`Serve`] +/// directly with the existing raw conversion/dispatch APIs. +#[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 mut retained = RetainedApp::default(); + serve.run(move |req| -> Result { + let env = runtime_env_config(stores); + let app = retained.get_or_init(&env, A::owns_logging, init_logger, A::build_app)?; + request::dispatch_with_registries(app, req, stores, &env, &mut extend) + }) +} diff --git a/crates/edgezero-adapter-fastly/src/lifecycle.rs b/crates/edgezero-adapter-fastly/src/lifecycle.rs new file mode 100644 index 00000000..ad403a26 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/lifecycle.rs @@ -0,0 +1,217 @@ +//! Lazy, successful-only state retention for custom Fastly dispatch. +//! +//! Only application-owned state belongs here. Native request handles, bodies, +//! metadata, extensions, and pending work must remain local to each callback. + +#[cfg(feature = "fastly")] +use fastly::http::serve::{HandlerResult, Serve, ServeSummary}; + +/// State owned by one invocation of a custom serving entry point. +/// +/// A new sandbox can start at any request. Initialization must therefore be +/// repeatable; retaining state never guarantees its lifetime or persistence. +pub struct Sandbox { + initialization_attempts: u64, + requests: u64, + setup_complete: bool, + state: Option, +} + +impl Default for Sandbox { + #[inline] + fn default() -> Self { + Self { + state: None, + requests: 0, + initialization_attempts: 0, + setup_complete: false, + } + } +} + +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "group accessors before lifecycle operations" +)] +impl Sandbox { + /// Successfully initialized application state, if any. + #[inline] + pub fn state(&self) -> Option<&T> { + self.state.as_ref() + } + + /// Attempted callbacks, including the current callback and early returns. + #[inline] + pub fn requests(&self) -> u64 { + self.requests + } + + /// Builder invocations, including failed attempts. + #[inline] + pub fn initialization_attempts(&self) -> u64 { + self.initialization_attempts + } + + /// Build only when empty; retain only success and return errors unchanged. + /// + /// An error can carry a request-local fallback router. The callback decides + /// how to respond and whether to continue serving. A later call retries an + /// unsuccessful build. Panics propagate normally. + /// + /// # Errors + /// Returns the builder error unchanged without retaining it. + #[inline] + pub fn initialize(&mut self, build: F) -> Result<(), E> + where + F: FnOnce() -> Result, + { + if self.state.is_none() { + self.initialization_attempts = self.initialization_attempts.saturating_add(1); + self.state = Some(build()?); + } + Ok(()) + } + + /// Run setup until it succeeds, independently of application initialization. + /// + /// For example, install logging after its configuration becomes available. + /// This guard belongs to this `Sandbox`, not the process. Callers must make + /// failed setup safe to retry: partial side effects are not rolled back. + /// + /// # Errors + /// Returns the setup error, leaving setup eligible for retry. + #[inline] + pub fn setup_once(&mut self, setup: F) -> Result<(), E> + where + F: FnOnce() -> Result<(), E>, + { + if !self.setup_complete { + setup()?; + self.setup_complete = true; + } + Ok(()) + } + + #[cfg(any(feature = "fastly", test))] + fn handle(&mut self, request: Q, handler: F) -> R + where + F: FnOnce(Q, &mut Self) -> R, + { + self.requests = self.requests.saturating_add(1); + handler(request, self) + } +} + +/// 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. +/// +/// 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")] +#[inline] +pub fn serve_custom(serve: Serve, mut handler: F) -> ServeSummary +where + F: FnMut(fastly::Request, &mut Sandbox) -> R, + R: HandlerResult, +{ + let mut sandbox = Sandbox::default(); + serve.run_with_context( + |request, context: &mut Sandbox| 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`]. +/// +/// # Errors +/// Returns the error from completing the callback's SDK result. +#[cfg(feature = "fastly")] +#[inline] +pub fn run_custom(request: fastly::Request, handler: F) -> Result<(), R::Error> +where + R: HandlerResult, + F: FnOnce(fastly::Request, &mut Sandbox) -> R, +{ + Sandbox::default().handle(request, handler).send() +} + +#[cfg(test)] +mod tests { + use super::Sandbox; + + // Neither the retained value nor an error payload needs framework traits. + struct App(u64); + struct Fallback(&'static str); + + #[test] + fn early_return_counts_request_without_setup_or_build() { + let mut sandbox = Sandbox::::default(); + sandbox.handle("health", |request, state| { + assert_eq!(request, "health"); + assert_eq!(state.requests(), 1); + assert_eq!(state.initialization_attempts(), 0); + assert!(state.state().is_none()); + assert!(!state.setup_complete); + }); + } + + #[test] + fn failed_build_returns_payload_then_success_is_retained() { + let mut sandbox = Sandbox::::default(); + sandbox.handle("first", |_, state| { + assert!(state.setup_once(|| Ok::<_, ()>(())).is_ok()); + let result = state.initialize(|| Err(Fallback("current request only"))); + assert!(matches!(result, Err(Fallback("current request only")))); + assert!(state.state().is_none()); + assert_eq!(state.initialization_attempts(), 1); + }); + for ordinal in 2..=4 { + sandbox.handle(ordinal, |request, state| { + assert!( + state + .setup_once::<(), _>(|| panic!("setup repeated")) + .is_ok() + ); + assert!( + state + .initialize::<(), _>(|| { + assert_eq!(request, 2, "successful initialization repeated"); + Ok(App(request)) + }) + .is_ok() + ); + assert_eq!(state.state().unwrap().0, 2); + assert_eq!(state.requests(), request); + assert_eq!(state.initialization_attempts(), 2); + }); + } + let fresh = Sandbox::::default(); + assert!(fresh.state().is_none()); + assert_eq!(fresh.requests(), 0); + assert_eq!(fresh.initialization_attempts(), 0); + } + + #[test] + fn failed_setup_can_retry_without_initializing_the_app() { + let mut sandbox = Sandbox::::default(); + assert_eq!(sandbox.setup_once(|| Err("not ready")), Err("not ready")); + assert!(!sandbox.setup_complete); + assert!(sandbox.setup_once(|| Ok::<_, ()>(())).is_ok()); + assert!( + sandbox + .setup_once::<(), _>(|| panic!("setup repeated")) + .is_ok() + ); + assert_eq!(sandbox.initialization_attempts(), 0); + assert_eq!(sandbox.requests(), 0); + } +} diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 623223d2..9862d8b8 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -540,14 +540,13 @@ impl Adapter for SpinCliAdapter { if !is_valid_spin_key(&spin_var) { let reason = spin_key_rule_violation(&spin_var); return Err(format!( - "`#[secret]` field `{field}` value `{value}` translates to Spin variable `{spin_var}`, which is not a valid Spin variable name. {reason}. Pick a `#[secret]` value that conforms.", + "`#[secret]` field `{field}` does not reference a valid Spin variable name. {reason}. Pick a secret reference that conforms; its value is redacted.", field = entry.field_name, - value = entry.key_value, )); } if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name.as_str()) { return Err(format!( - "Spin variable `{spin_var}` would receive values from BOTH `#[secret]` field `{prev_field}` AND `#[secret]` field `{this_field}`; Spin's flat variable namespace cannot disambiguate them. Pick distinct `#[secret]` values whose lowercased forms differ.", + "`#[secret]` fields `{prev_field}` and `{this_field}` reference the same Spin variable after lowercasing; Spin's flat variable namespace cannot disambiguate them. Pick distinct secret references; their values are redacted.", this_field = entry.field_name, )); } @@ -1290,6 +1289,37 @@ mod tests { ); } + #[test] + fn secret_validation_errors_redact_original_and_normalized_values() { + for entries in [ + vec![TypedSecretEntry::new( + "default", + "credential", + "Private-Canary", + )], + vec![ + TypedSecretEntry::new("default", "first", "Private_Canary"), + TypedSecretEntry::new("default", "second", "private_canary"), + ], + ] { + let err = SpinCliAdapter.validate_typed_secrets(&entries).unwrap_err(); + for entry in &entries { + assert!( + !err.contains(entry.key_value), + "raw reference leaked: {err}" + ); + assert!( + !err.contains(&entry.key_value.to_ascii_lowercase()), + "normalized reference leaked: {err}" + ); + assert!( + err.contains(entry.field_name.as_str()), + "missing field: {err}" + ); + } + } + } + #[test] fn validate_typed_secrets_passes_with_no_collision() { SpinCliAdapter @@ -1311,13 +1341,10 @@ mod tests { .validate_typed_secrets(&[TypedSecretEntry::new("default", "api_token", "api-token")]) .expect_err("dashed secret value must error"); assert!( - // The error must name BOTH the field name (`api_token`, - // underscore) and the offending value (`api-token`, - // dash), plus mark it as a Spin variable issue. The prior - // assertion double-checked the value and silently missed - // the field-name half. - err.contains("api_token") && err.contains("api-token") && err.contains("Spin variable"), - "error names the field, the bad value, and the Spin-variable bucket: {err}" + err.contains("api_token") + && !err.contains("api-token") + && err.contains("Spin variable"), + "error names the field and rule without its value: {err}" ); } @@ -1333,15 +1360,15 @@ mod tests { ]) .expect_err("two values lowercasing to the same name must collide"); assert!( - err.contains("shared_name") && (err.contains("first") || err.contains("second")), - "error names the shared canonical name and at least one field: {err}" + !err.contains("shared_name") && err.contains("first") && err.contains("second"), + "error names both fields without the shared value: {err}" ); } // named-store secret adapter validation #[test] - fn collision_error_names_both_field_names_and_lowercased_variable() { + fn collision_error_names_both_fields_without_the_lowercased_value() { // case (b): KeyInDefault and KeyInNamedStore that // collide on the lowercased Spin variable. let entries = [ @@ -1351,7 +1378,7 @@ mod tests { let err = SpinCliAdapter.validate_typed_secrets(&entries).unwrap_err(); assert!(err.contains("`one`"), "{err}"); assert!(err.contains("`two`"), "{err}"); - assert!(err.contains("demo_token"), "{err}"); + assert!(!err.contains("demo_token"), "{err}"); } #[test] @@ -1361,9 +1388,9 @@ mod tests { let entries = [TypedSecretEntry::new("vault", "api_token", "demo-token")]; let err = SpinCliAdapter.validate_typed_secrets(&entries).unwrap_err(); assert!(err.contains("`api_token`"), "{err}"); - assert!(err.contains("demo-token"), "{err}"); + assert!(!err.contains("demo-token"), "{err}"); assert!( - err.to_lowercase().contains("hyphen") || err.contains("not a valid"), + err.contains("lowercase letters, digits, and underscores"), "{err}" ); } diff --git a/crates/edgezero-adapter-spin/src/lib.rs b/crates/edgezero-adapter-spin/src/lib.rs index db282c05..1458c466 100644 --- a/crates/edgezero-adapter-spin/src/lib.rs +++ b/crates/edgezero-adapter-spin/src/lib.rs @@ -29,7 +29,7 @@ use core::pin::Pin; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use bytes::Bytes; #[cfg(all(feature = "spin", target_arch = "wasm32"))] -use edgezero_core::app::{App, Hooks}; +use edgezero_core::app::{App, Hooks, StoresMetadata}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use edgezero_core::env_config::EnvConfig; #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -122,3 +122,25 @@ pub async fn run_app(req: SpinRequest) -> anyhow::Result anyhow::Result { + let env = EnvConfig::from_env(); + request::dispatch_with_registries(app, req, stores.config, stores.kv, stores.secrets, &env) + .await +} diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 97a16f7c..04fd3d6a 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -218,7 +218,7 @@ struct ResolvedTomlLeaf<'raw> { #[inline] pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String> { let ctx = load_validation_context(args)?; - run_shared_checks(&ctx)?; + run_shared_checks(&ctx, None)?; log::info!( "[edgezero] config validate (raw): {} OK{}", args.manifest.display(), @@ -237,7 +237,7 @@ where C: DeserializeOwned + Validate + AppConfigMeta, { let ctx = load_validation_context(args)?; - run_shared_checks(&ctx)?; + run_shared_checks(&ctx, None)?; // Typed deserialise + validate_excluding_secrets (push, // diff, AND typed validate all use deserialize-only + @@ -255,7 +255,7 @@ where .map_err(|err| format!("typed app-config failed validation: {err}"))?; typed_secret_checks(&typed, &ctx)?; - run_adapter_typed_checks::(&ctx)?; + run_adapter_typed_checks::(&ctx, None)?; log::info!( "[edgezero] config validate (typed): {} + {} OK{}", @@ -445,7 +445,7 @@ where { // Pre-flight: load + validate. let ctx = load_push_context(args)?; - run_shared_checks(&ctx.validation)?; + run_shared_checks(&ctx.validation, Some(&args.adapter))?; let mut opts = AppConfigLoadOptions::default(); opts.env_overlay = !args.no_env; let typed: C = app_config::deserialize_app_config_with_options::( @@ -457,7 +457,7 @@ where app_config::validate_excluding_secrets(&typed) .map_err(|err| format!("typed app-config failed validation: {err}"))?; typed_secret_checks(&typed, &ctx.validation)?; - run_adapter_typed_checks::(&ctx.validation)?; + run_adapter_typed_checks::(&ctx.validation, Some(&args.adapter))?; // Resolve adapter paths. let (manifest_root, adapter_manifest_path, component_selector, push_ctx) = @@ -611,7 +611,8 @@ where strict: false, }; let ctx = load_validation_context(&validate_args)?; - run_shared_checks(&ctx)?; + ensure_adapter_defined(&args.adapter, Some(&ctx.manifest_loader))?; + run_shared_checks(&ctx, Some(&args.adapter))?; let mut opts = AppConfigLoadOptions::default(); opts.env_overlay = !args.no_env; let typed: C = app_config::deserialize_app_config_with_options::( @@ -623,7 +624,7 @@ where app_config::validate_excluding_secrets(&typed) .map_err(|err| format!("local validation failed: {err}"))?; typed_secret_checks(&typed, &ctx)?; - run_adapter_typed_checks::(&ctx)?; + run_adapter_typed_checks::(&ctx, Some(&args.adapter))?; // Build the local envelope. let local_data: serde_json::Value = serde_json::to_value(&typed) @@ -1334,7 +1335,7 @@ fn load_push_context(args: &ConfigPushArgs) -> Result { // Push is strict — the synthesized validate args // unconditionally request `--strict` so `run_shared_checks` // runs the capability-completeness + handler-path checks - // alongside the schema and per-adapter shared checks. + // alongside the schema and selected-adapter shared checks. let validate_args = ConfigValidateArgs { app_config: args.app_config.clone(), manifest: args.manifest.clone(), @@ -1526,10 +1527,10 @@ fn resolve_app_config_path( ) } -fn run_shared_checks(ctx: &ValidationContext) -> Result<(), String> { - run_adapter_shared_checks(ctx)?; +fn run_shared_checks(ctx: &ValidationContext, selected: Option<&str>) -> Result<(), String> { + run_adapter_shared_checks(ctx, selected)?; if ctx.args_strict { - strict_capability_completeness(ctx.manifest())?; + strict_capability_completeness(ctx.manifest(), selected)?; strict_handler_paths(ctx.manifest())?; } Ok(()) @@ -1541,12 +1542,15 @@ fn run_shared_checks(ctx: &ValidationContext) -> Result<(), String> { // ------------------------------------------------------------------- /// Run the adapter-agnostic shared checks: for every adapter -/// declared in the manifest, look up its `Adapter` impl in the +/// declared in the manifest (or only the selected push/diff target), look up its `Adapter` impl in the /// registry and invoke `validate_app_config_keys` + /// `validate_adapter_manifest`. Adapters not in the registry (e.g. /// a feature-gated build that omitted some) are silently skipped — /// they can't validate what they don't link. -fn run_adapter_shared_checks(ctx: &ValidationContext) -> Result<(), String> { +fn run_adapter_shared_checks( + ctx: &ValidationContext, + selected: Option<&str>, +) -> Result<(), String> { let raw_table = ctx .raw_config .as_table() @@ -1557,6 +1561,9 @@ fn run_adapter_shared_checks(ctx: &ValidationContext) -> Result<(), String> { let env_config = EnvConfig::from_env(); for (name, adapter_cfg) in &ctx.manifest().adapters { + if selected.is_some_and(|target| target != name) { + continue; + } let Some(adapter) = adapter_registry::get_adapter(name) else { continue; }; @@ -1750,7 +1757,10 @@ fn collect_secret_leaves<'raw>( /// runtime store ids, not flat-namespace candidates) so adapters /// whose secret store has a flat-namespace constraint (Spin) can /// detect within-secrets collisions. -fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result<(), String> { +fn run_adapter_typed_checks( + ctx: &ValidationContext, + selected: Option<&str>, +) -> Result<(), String> { let default_store_id = ctx .manifest() .stores @@ -1781,6 +1791,9 @@ fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result } for name in ctx.manifest().adapters.keys() { + if selected.is_some_and(|target| target != name) { + continue; + } if let Some(adapter) = adapter_registry::get_adapter(name) { adapter.validate_typed_secrets(&entries)?; } @@ -1888,12 +1901,18 @@ fn flatten_keys_into(table: &Table, prefix: &str, out: &mut Vec) { // --strict checks // ------------------------------------------------------------------- -fn strict_capability_completeness(manifest: &Manifest) -> Result<(), String> { +fn strict_capability_completeness( + manifest: &Manifest, + selected: Option<&str>, +) -> Result<(), String> { // Capability matrix, driven by each adapter crate's // `Adapter::single_store_kinds()` impl. Adapters not in the // registry (e.g. a feature-gated build that omitted some) are // skipped — we can't speak for what isn't linked. for adapter_name in manifest.adapters.keys() { + if selected.is_some_and(|target| target != adapter_name) { + continue; + } enforce_single_store_capability(manifest, adapter_name)?; } Ok(()) @@ -4102,6 +4121,66 @@ timeout_ms = 50 /// the same belt-and-braces guard. We probe via Spin's /// `validate_adapter_manifest`, which fails when the /// referenced spin.toml has no `[component.*]` declarations. + #[test] + fn selected_adapter_strict_checks_preserve_portability_validation() { + let manifest_text = format!( + "{}\n[adapters.spin.adapter]\ncrate = \"unused\"\nmanifest = \"spin.toml\"\n", + PUSH_MANIFEST + .replace("adapters.axum", "adapters.fastly") + .replace( + "ids = [\"default\"]", + "ids = [\"default\", \"extra\"]\ndefault = \"default\"" + ) + ); + let (dir, manifest, _) = setup_project(&manifest_text, FIXTURE_APP_CONFIG); + fs::write(dir.path().join("spin.toml"), VALID_SPIN_TOML).unwrap(); + let mut args = args_for(&manifest); + args.strict = true; + let ctx = load_validation_context(&args).unwrap(); + run_shared_checks(&ctx, Some("fastly")) + .expect("selected multi-store adapter accepts multiple stores"); + for selected in [None, Some("spin")] { + let err = run_shared_checks(&ctx, selected).unwrap_err(); + assert!(err.contains("Single") && err.contains("secrets"), "{err}"); + } + } + + #[test] + fn selected_adapter_push_and_diff_ignore_unrelated_adapter_constraints() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let manifest_text = format!( + "{PUSH_MANIFEST}\n[adapters.spin.adapter]\ncrate = \"unused\"\nmanifest = \"spin.toml\"\n" + ); + for spin_manifest in ["spin_manifest_version = 2\n", VALID_SPIN_TOML] { + let app_config = FIXTURE_APP_CONFIG.replace("demo_api_token", "Private-Reference"); + let (dir, manifest, _) = setup_project(&manifest_text, &app_config); + fs::write(dir.path().join("spin.toml"), spin_manifest).unwrap(); + let mut push = push_args(&manifest, "axum"); + push.local = true; + push.dry_run = true; + run_config_push_typed::(&push) + .expect("an unrelated adapter must not block a selected-adapter push"); + let diff = ConfigDiffArgs { + adapter: "axum".into(), + app_config: None, + exit_code: false, + format: DiffFormat::Unified, + key: None, + local: true, + manifest: manifest.clone(), + no_env: true, + runtime_config: None, + store: None, + staging: false, + }; + run_config_diff_typed::(&diff) + .expect("an unrelated adapter must not block a selected-adapter diff"); + assert!(run_config_validate_typed::(&args_for(&manifest)).is_err()); + push.adapter = "spin".into(); + assert!(run_config_push_typed::(&push).is_err()); + } + } + #[test] fn typed_push_runs_spin_adapter_manifest_check_before_push() { let _lock = manifest_guard().lock().expect("manifest guard"); @@ -4187,20 +4266,12 @@ default = "one" "spin_manifest_version = 2\n[application]\nname = \"x\"\nversion = \"0\"\n[component.demo]\nsource = \"a.wasm\"\n", ) .expect("write spin.toml"); - // Adapter the push targets doesn't matter — the strict - // capability check fires per declared adapter set. We - // push to axum to keep the rest of the flow simple. + // The selected adapter is Single-capable too, so its capability + // check must still reject before any push. let err = run_config_push_typed::(&push_args(&manifest, "axum")) .expect_err("Single-capable adapter with multi-id store must fail preflight"); - // BTreeMap iteration order on the manifest's adapter set - // means the check reports whichever Single-capable - // adapter sorts first (axum or spin) — both are - // Single-capable for secrets in this fixture. The - // contract that matters is "the strict check ran before - // the per-adapter push", which the `Single` + - // `secrets` substrings prove. assert!( - err.contains("Single") && err.contains("secrets"), + err.contains("axum") && err.contains("Single") && err.contains("secrets"), "error must come from --strict capability check: {err}" ); } diff --git a/crates/edgezero-core/src/app.rs b/crates/edgezero-core/src/app.rs index 6d1ebc89..6e6aa4cf 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -218,6 +218,12 @@ mod tests { RouterService::builder().build() } + #[test] + fn app_can_be_retained_in_a_shared_owner() { + fn assert_send_sync() {} + assert_send_sync::(); + } + #[test] fn build_app_invokes_hooks_for_routes_and_configuration() { let app = TestHooks::build_app(); diff --git a/crates/edgezero-core/src/app_config.rs b/crates/edgezero-core/src/app_config.rs index 6cf96235..e96f3882 100644 --- a/crates/edgezero-core/src/app_config.rs +++ b/crates/edgezero-core/src/app_config.rs @@ -1585,3 +1585,30 @@ greeting = "hello" assert_eq!(array.dotted_path(), "partners[*].api_key"); } } + +#[cfg(test)] +mod retained_guard_tests { + use super::{SECRET_FIELDS_DEPTH, SecretFieldsRecursionGuard}; + use std::panic::catch_unwind; + + #[test] + fn recursion_guard_resets_after_scope_and_unwind() { + fn nested_scope() { + let _outer = SecretFieldsRecursionGuard::enter(); + let _inner = SecretFieldsRecursionGuard::enter(); + SECRET_FIELDS_DEPTH.with(|depth| assert_eq!(depth.get(), 2)); + } + nested_scope(); + SECRET_FIELDS_DEPTH.with(|depth| assert_eq!(depth.get(), 0)); + assert!( + catch_unwind(|| { + let _guard = SecretFieldsRecursionGuard::enter(); + panic!("injected initialization failure"); + }) + .is_err() + ); + SECRET_FIELDS_DEPTH.with(|depth| assert_eq!(depth.get(), 0)); + let _fresh = SecretFieldsRecursionGuard::enter(); + SECRET_FIELDS_DEPTH.with(|depth| assert_eq!(depth.get(), 1)); + } +} diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index d20f35a8..e2e58a5c 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -861,6 +861,145 @@ mod tests { assert_eq!(response.body().as_bytes().expect("buffered"), b"count=2"); } + #[test] + fn retained_router_separates_overlapping_requests() { + use futures::channel::oneshot; + use std::collections::VecDeque; + use std::future::Future as _; + use std::str::from_utf8; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Clone)] + struct Tag(&'static str); + #[derive(Clone)] + struct RequestToken(&'static str); + struct Barrier(Mutex>>); + #[async_trait::async_trait(?Send)] + impl Middleware for Barrier { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + let receiver = self.0.lock().unwrap().pop_front().unwrap(); + receiver.await.unwrap(); + next.run(ctx).await + } + } + #[crate::action] + async fn probe(ctx: RequestContext) -> Result { + let request = ctx.request(); + let counter = request.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 + )) + } + let (send1, recv1) = oneshot::channel(); + let (send2, recv2) = oneshot::channel(); + let counter = Arc::new(AtomicUsize::new(0)); + let router = RouterService::builder() + .with_state(Tag("app")) + .with_state(Arc::clone(&counter)) + .middleware(Barrier(Mutex::new([recv1, recv2].into()))) + .get("/probe/{id}", probe) + .build(); + let make_request = |token: &'static str| { + let mut req = request_builder() + .uri(format!("/probe/{token}")) + .header("x-request-token", token) + .body(Body::from(token)) + .unwrap(); + req.extensions_mut().insert(RequestToken(token)); + req.extensions_mut().insert(Tag("request")); + req + }; + let mut first = Box::pin(router.oneshot(make_request("first"))); + let mut second = Box::pin(router.oneshot(make_request("second"))); + let mut cx = Context::from_waker(noop_waker_ref()); + assert!(first.as_mut().poll(&mut cx).is_pending()); + assert!(second.as_mut().poll(&mut cx).is_pending()); + assert_eq!(counter.load(Ordering::SeqCst), 0); + send1.send(()).unwrap(); + send2.send(()).unwrap(); + let first_response = block_on(first).unwrap(); + let second_response = block_on(second).unwrap(); + assert_eq!( + first_response.body().as_bytes().unwrap(), + b"first:first:first:first:app" + ); + assert_eq!( + second_response.body().as_bytes().unwrap(), + b"second:second:second:second:app" + ); + assert_eq!(counter.load(Ordering::SeqCst), 2); + } + + #[test] + fn retained_router_releases_cancelled_request_state() { + use std::future::{Future as _, pending}; + + #[derive(Clone)] + struct RequestResource(Arc<()>); + + #[crate::action] + async fn wait(ctx: RequestContext) -> Result { + // Keep the request alive across suspension, as an awaiting handler does. + pending::<()>().await; + Ok(ctx.path_params().get("id").unwrap().to_owned()) + } + + #[crate::action] + async fn probe(ctx: RequestContext) -> Result { + assert!( + ctx.request() + .extensions() + .get::() + .is_none() + ); + Ok(ctx.path_params().get("id").unwrap().to_owned()) + } + + let shared = Arc::new(()); + let router = RouterService::builder() + .with_state(Arc::clone(&shared)) + .get("/wait/{id}", wait) + .get("/probe/{id}", probe) + .build(); + let resource = RequestResource(Arc::new(())); + let released = Arc::downgrade(&resource.0); + let mut request = request_builder() + .uri("/wait/cancelled") + .body(Body::from("private request body")) + .unwrap(); + request.extensions_mut().insert(resource); + let mut suspended = Box::pin(router.oneshot(request)); + let mut cx = Context::from_waker(noop_waker_ref()); + assert!(suspended.as_mut().poll(&mut cx).is_pending()); + assert!(released.upgrade().is_some()); + drop(suspended); + assert!(released.upgrade().is_none()); + + let response = block_on( + router.oneshot( + request_builder() + .uri("/probe/fresh") + .body(Body::empty()) + .unwrap(), + ), + ) + .unwrap(); + assert_eq!(response.body().as_bytes().unwrap(), b"fresh"); + assert_eq!(Arc::strong_count(&shared), 2); + drop(router); + assert_eq!(Arc::strong_count(&shared), 1); + } + #[test] fn with_state_no_cross_request_bleed() { use crate::extractor::{FromRequest as _, State}; diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index 62813d79..f4c5d769 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -239,3 +239,12 @@ While Axum provides a convenient development environment, always test on actual - Deploy to [Fastly Compute](/guide/adapters/fastly) for production - Deploy to [Cloudflare Workers](/guide/adapters/cloudflare) as an alternative - Explore [Configuration](/guide/configuration) for manifest options + +## Application lifetime + +`dev_server::run_app` already constructs one app per server startup and serves +clones of its router. No additional reuse option is needed. Retained application +state must support concurrent requests; each request retains its own metadata, +extensions, and body. Restarting the server creates a fresh app. Native Axum +measurements are a useful ownership reference but do not establish WASM runtime +performance or resource lifetimes. diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index c22e99e6..b90fae10 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -252,3 +252,36 @@ Configure the Cloudflare adapter in `edgezero.toml`. See [Configuration](/guide/ - Learn about [Fastly Compute](/guide/adapters/fastly) as an alternative - Explore the [Axum adapter](/guide/adapters/axum) for local development + +## Retaining an application + +For explicit retention, keep a cache owned by one concrete application and pass +its store metadata on every fetch: + +```rust +use edgezero_core::app::{App, Hooks}; +use std::sync::OnceLock; + +static APP: OnceLock = OnceLock::new(); + +#[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), MyApp::stores(), req, env, ctx, + ).await +} +``` + +`dispatch_app` does not initialize logging or construct an app. 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. + +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 +not establish this property. Restore the existing `run_app` entry point to remove +explicit retention. The generated default remains unchanged. diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index da0185e9..070696c5 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -296,3 +296,156 @@ Configure the Fastly adapter in `edgezero.toml`. See [Configuration](/guide/conf - Learn about [Cloudflare Workers](/guide/adapters/cloudflare) as an alternative deployment target - Explore [Configuration](/guide/configuration) for manifest details + +## Reusing a sandbox and retaining an app + +Opt in with an ordinary `main` in place of the single-request `#[fastly::main]`: + +```rust +use edgezero_adapter_fastly::{Serve, serve_app}; +use std::time::Duration; + +fn main() -> Result<(), fastly::Error> { + serve_app::( + Serve::new() + .with_max_requests(10) + .with_timeout(Duration::from_millis(500)), + ).into_result() +} +``` + +`serve_app_with_request_extensions` additionally accepts an `FnMut` callback +for fresh extensions on every request. The first callback initializes logging +and builds the app. Each callback reads runtime configuration and constructs new +store registries. App construction and `configure` execute once per retained +owner; explicit work elsewhere still executes whenever the application calls it. + +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 a custom SDK callback. + +SDK 0.12.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. +Limits do not guarantee reuse; any request may start a fresh sandbox. +Lifetime and memory checks occur between callbacks and cannot interrupt +blocked application work. Before using a local memory limit, verify that the +guest's memory-snapshot call succeeds: an unsupported snapshot conservatively +ends the SDK loop. CPU clock observations are not reliable cross-run benchmarks. + +`ServeSummary::requests()` counts attempted callbacks, including a failed one. +Record response commitment, guest completion, and client completion separately; +a crash can prevent a final summary. Ordinary handler errors are rendered by the +router. Errors escaping conversion, required store setup, stream collection, or +error rendering retain the SDK's terminal error behavior. 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 `runtime_env_config` and `request::dispatch_with_registries` +when standard translation suffices; otherwise retain the existing raw +`into_core_request` and router dispatch path. + +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. + +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 + +The custom path is a supported interface, not a requirement to use `serve_app`. +It combines `lifecycle::serve_custom`, public `request::into_core_request`, and +`App::router().oneshot`. EdgeZero delegates the receive loop and limits to the SDK, +owns the state slot, attempted-callback count, initialization-attempt count, and +successful setup guard. The application owns the limit configuration, which state +survives, initialization and setup closures, error responses, logging policy, +configuration refresh, response finalization, and explicit sending. Direct SDK +serving remains available when these helpers do not fit. + +`Sandbox::initialize` invokes its builder only while empty. It returns `Result<(), E>` +without constraining `E`: an error may carry a fallback router for this request. +Read the retained value afterward using `state()`. `setup_once` has an independent +success guard, so application retries do not repeat successful logger installation. +Failed setup must be safe to retry; partial side effects are not rolled back. +Neither method catches panics. `requests()` includes the current callback and +early-return probes; `initialization_attempts()` counts only actual builder calls. + +For example, inside a callback after its health checks: + +```rust,ignore +sandbox.setup_once(|| install_application_logger())?; +let fallback = sandbox.initialize(|| build_application()).err(); +// build_application returns Ok(retained_state) or Err(request_local_error_router). +// Select fallback.as_ref() or sandbox.state(), then dispatch and explicitly send. +``` + +Use `lifecycle::run_custom(Request::from_client(), callback)` for the single-request +branch. It creates fresh state and does not enter `Serve` or call `next_request`. +Both wrappers complete the SDK `HandlerResult` exactly once. Use an ordinary +`fn main`, not `#[fastly::main]`, which could attempt another error response when +an already-completed error propagates. Manually sending callbacks can return `()` +or `Result<(), E>`; after response commitment, handle failures locally and return +`() / Ok(())`. The wrappers never convert or collect the callback's response body. + +Retain only successfully initialized state. A handled initialization failure +may send an error response and return `()` (or `Ok(())`) to allow another +callback to retry. Do not put an error router into the retained slot. Check +health routes before initialization. Guard successful global logger installation +separately: retrying application initialization must not reinstall the logger. +Constructor panics are sandbox failures, not recoverable initialization results. +The standard helper's terminal error policy is unchanged. + +Every callback gets fresh request extensions, metadata, native handles, and +bodies. Mutable parsing buffers belong to a request or document, even when their +rewriter is referenced by a retained router. Response extensions produced by +handlers remain available after direct router dispatch; inspect them before +sending. Registry-aware `dispatch_with_registries` performs standard response +conversion and is not a streaming/finalization substitute. + +Pin the adapter, core, and SDK to compatible versions. Do not repin merely to +replace the SDK's `Serve` import with its EdgeZero re-export. When adopting a new +EdgeZero revision, run the existing compatibility suite against that checkout: + +```sh +./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smoke --require-runtime +``` + +The standalone fixture workspace depends on this checkout by path. Its custom +entry points use these lifecycle helpers and exercise lazy health bypass, failed initialization followed by +successful retry and reuse, request-specific response extensions, progressive +streaming, duplicate cookies, and post-commit error handling. The runner records +runtime versions and artifact identities. Recovery only passes when all required +callbacks are observed in the same guest. An unavailable runtime or missing +reuse is unverified, not a compatibility pass. Run the corresponding adapter +suites when using Cloudflare, Spin, or Axum; their host lifetimes differ. + +Application-specific refresh, key rotation, and workload validation remain the +application's responsibility. Passing local fixtures does not guarantee reuse +or establish deployed resource lifetimes. + +Warning caches persist too. Their bounded recent-name sets can evict entries, +so warnings can recur; suppressed warning counts are not failure counts. +Dynamic-backend capacity is service-wide, and registrations may wait for capacity. +A sandbox request limit alone does not bound origin diversity or request fan-out. + +The local fixtures are in `tests/fixtures/reusable-app`. Local reuse and streaming +results do not establish deployed eviction frequency, endpoint-handle validity, +resource accounting, or performance. Named endpoint delivery must be verified +separately from echoed stdout. Roll back by restoring the original single-request +entry point and removing retained state, not merely setting a request limit of one. diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index 08745634..7f2edc33 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -124,3 +124,53 @@ Adapters that fulfil these steps can be dropped into the EdgeZero CLI without re | [Cloudflare](/guide/adapters/cloudflare) | Cloudflare Workers | `wasm32-unknown-unknown` | Stable | | [Spin](/guide/adapters/spin) | Fermyon Spin | `wasm32-wasip2` | Stable | | [Axum](/guide/adapters/axum) | Native (Tokio) | Host | Stable | + +## Opt-in application retention + +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 a custom `Serve` callback | +| 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 | + +Retain owned settings, parsed objects, and bounded caches only when their +staleness policy is acceptable. Keep native request handles, bodies, store +registries, pending operations, and response effects request-local. `Send + Sync` +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. + +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 +function: that static would be shared between application types. Existing macros, +manifest settings, and generated entry points keep their current behavior. + +### Preparing an application for retention + +Audit the values captured by handlers, middleware, and registered state before +opting in. The framework creates fresh request resources but cannot inspect or +clear application-owned interiors. + +| Risk | Application mitigation | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Request data survives into another request | Store identity, authorization results, bodies, and correlation IDs in request-local values or extensions. Use distinct types for request data and registered state; registered state wins on a type collision. | +| Settings or parsed keys become stale | Keep them request-scoped until a refresh policy is defined. For retained values, build and validate a complete replacement snapshot, then publish it atomically. Decide whether refresh failure keeps the last valid snapshot or rejects requests. | +| Caches grow without bound | Limit entries and retained bytes, expire or evict entries, and bound origin diversity. A sandbox request limit cannot bound allocation within one request. | +| Concurrent requests mutate shared state | Synchronize mutations and acquire an immutable snapshot per request. Release locks before awaiting provider work. Rust's `Send + Sync` bounds do not establish application-level isolation. | +| Initialization or required bindings fail | Surface the error and monitor repeated fresh-instance failures. In custom dispatch, convert a recoverable failure into one response only when the application defines that recovery policy. Avoid unlimited retries. | +| Logging is installed twice | Choose one owner. With the Fastly helper, set `Hooks::owns_logging()` when application code installs the logger. First-snapshot logging is intentionally fixed; use custom dispatch for another policy. | +| A stream fails after commitment | Finish or drop the streaming writer and settle request-owned pending work. Log the partial-response failure; do not attempt a replacement response after sending headers. | + +Exercise cancellation as well as successful requests. The core regression tests +drop a suspended request, verify its extension resources are released, and serve +a fresh request through the same router. This does not cancel application-spawned +background tasks or prove cleanup after a terminating provider trap. + +Roll out retention only after the application's isolation, refresh, and bounded +memory workload checks pass. Provider eviction can force initialization on any +request, so correctness must not depend on reaching the configured reuse limit. diff --git a/docs/guide/adapters/spin.md b/docs/guide/adapters/spin.md index e9c0e57c..6b328456 100644 --- a/docs/guide/adapters/spin.md +++ b/docs/guide/adapters/spin.md @@ -257,3 +257,37 @@ Configure the Spin adapter in `edgezero.toml`. See pre-rewrite store schema - [Adapters overview](/guide/adapters/overview) — cross-adapter contracts - [Configuration](/guide/configuration) — full manifest reference + +## Retaining an application + +Use a concrete application-owned `OnceLock` and call +`edgezero_adapter_spin::dispatch_app(app, MyApp::stores(), req).await` on each +invocation. This resolves fresh request resources with explicit metadata and does +not install logging, construct an app, or cache native resources. Initialize any +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 std::sync::OnceLock; + +static APP: OnceLock = OnceLock::new(); + +#[http_service] +async fn handle(req: Request) -> anyhow::Result { + let app = APP.get_or_init(MyApp::build_app); + edgezero_adapter_spin::dispatch_app(app, MyApp::stores(), req).await +} +``` + +The pinned SDK 6 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; detect reuse/concurrency controls from the actual +host's help output instead of assuming newer flags are available. + +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` +and remove the cache to roll back; default generated entry points are unchanged. diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 779c38e7..44069537 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -263,6 +263,14 @@ non-zero with a one-line diagnostic on error. ### edgezero config validate +Standalone validation checks every declared, registered adapter for portability. +With `--strict`, capability limits are checked across that same adapter set. +By contrast, `config push --adapter NAME` and `config diff --adapter NAME` +apply provider-specific rules only to `NAME`; an unrelated adapter's manifest +or secret-reference naming rules cannot block the selected destination. Shared +schema and secret-presence checks still apply. Spin secret-name validation +diagnostics name the affected fields and rules without printing their values. + Validate `edgezero.toml` together with the typed `.toml` app config (see [Application config](/guide/configuration#application-config)). diff --git a/docs/superpowers/plans/2026-09-15-reusable-app-lifecycle.md b/docs/superpowers/plans/2026-09-15-reusable-app-lifecycle.md new file mode 100644 index 00000000..0cddf90f --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-reusable-app-lifecycle.md @@ -0,0 +1,486 @@ +# Reusable Application Lifecycle 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 checkboxes for tracking; exceptions and evidence limits are recorded in the execution checkpoint. + +**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. + +**Tech Stack:** Rust, Fastly SDK 0.12.1/Viceroy, worker 0.8/worker-build/Wrangler, Spin SDK 6/P3, Axum, shell, VitePress/Prettier. + +--- + +## Authority and constraints + +- 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. +- 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. + +## Delivery structure + +| Stage | Tasks | Exit evidence | +| ------------------------------- | ----- | ----------------------------------------------------- | +| Core contracts and adapter APIs | 1–4 | Host lifecycle tests and provider type checks | +| Provider fixtures and harness | 5–9 | Real HTTP assertions and explicit unsupported results | +| Runtime evidence | 10 | A/B/C accounting, performance, and isolation outcomes | +| Guides and final verification | 11–12 | Checked examples, repository gates, review | + +Tasks 3 and 4 can run independently after Task 2. Provider fixture work can run independently once Task 5 fixes the common contract. Assign each file to one worker; do not concurrently edit Fastly `lib.rs`. + +## File map + +Production and existing tests: + +- `crates/edgezero-core/src/app.rs`: retained-object bounds test. +- `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-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. +- `scripts/smoke_test_reusable_app.sh`: explicit build/tool/run orchestration. + +New standalone fixture workspace: + +```text +tests/fixtures/reusable-app/ + Cargo.toml + Cargo.lock + .gitignore + README.md + crates/ + fixture-harness/{Cargo.toml,src/{main,evidence,net,runners}.rs} + fixture-core/{Cargo.toml,src/lib.rs} + 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 + fixture-cloudflare/{Cargo.toml,wrangler.toml,src/lib.rs} + fixture-spin/{Cargo.toml,spin.toml,runtime-config.toml,src/lib.rs} + fixture-axum/{Cargo.toml,src/main.rs} +``` + +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. + +## Task 1: Protect retained ownership and request separation + +**Files:** core `src/app.rs`, `src/router.rs`, and, where needed, `src/app_config.rs` test modules. Audit Fastly `src/request.rs` warning caches; record findings in the fixture README from Task 11. + +- [x] **1.1 Read the existing tests** near `router.rs:865` and injection at `:251`. The current two-future test does not force both handlers to remain pending; extend coverage rather than copying it. +- [x] **1.2 Add the bounds assertion** to the app test module: + +```rust +#[test] +fn app_can_be_retained_in_a_shared_owner() { + fn assert_send_sync() {} + assert_send_sync::(); +} +``` + +- [x] **1.3 Run `cargo test -p edgezero-core app_can_be_retained_in_a_shared_owner`.** Expected: pass against existing code. This protects an existing property; no production edit is justified. +- [x] **1.4 Add `retained_router_separates_overlapping_requests`.** Use test middleware with two `futures::channel::oneshot` receivers. Take a receiver under a mutex, release the guard, then await it. Poll both router futures and assert both are pending before releasing either sender. +- [x] **1.5 Give each request distinct** `/probe/{id}` path values, body, `x-request-token`, and a request-only extension. Also inject a conflicting value of the same type as registered app state; assert existing app-state precedence. Keep a separate `Arc` counter as intentionally shared state. +- [x] **1.6 Add a `#[action]` probe** that reads its request-local values after the barrier and returns them plus shared state. Assert two distinct correct outputs and shared count two. Never put request tokens in app state. +- [x] **1.6a Audit persistent process/thread state.** Search the core and Fastly runtime modules for `static`, `OnceLock`, and `thread_local!`. Classify each value as intentionally retained, bounded suppression/cache state, or request-scoped state needing cleanup. Inspect `SecretFieldsRecursionGuard` in `crates/edgezero-core/src/app_config.rs:96–145` and the warning caches in `crates/edgezero-adapter-fastly/src/request.rs:563–586`. Do not infer leakage merely from a static or guard. +- [x] **1.6b Verify supported cleanup paths.** Reuse existing tests where sufficient; otherwise add colocated guard tests for normal scope exit and host panic unwinding, followed by a fresh successful invocation proving depth reset. Do not claim host unwinding proves cleanup after a terminating WASM trap; that case requires the fresh-instance fixture in Task 7.8. Confirm warning caches remain bounded and document that eviction permits repeated warnings, while suppression means warning counts are not failure counts. Run scoped tests after any test-code increment. +- [x] **1.7 Run `cargo test -p edgezero-core`.** Expected: pass without router production changes. Checkpoint: `test(core): cover retained app ownership and request isolation`. + +## Task 2: Host-testable Fastly production initialization + +**File:** `crates/edgezero-adapter-fastly/src/lib.rs`. + +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: + +```rust +#[cfg(any(feature = "fastly", test))] +#[derive(Default)] +struct RetainedApp { + app: Option, +} + +#[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)) + } +} +``` + +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. + +- [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.4 Complete these cases:** + +| Case | Assertion | +| ------------------ | ---------------------------------------------------------------------------------------------- | +| 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 | +| Fresh owner | Two state values initialize independently, no shared static | +| Retained app | Two dispatches use the same configured router/state | + +- [x] **2.5 Run `cargo test -p edgezero-adapter-fastly --lib` after each increment.** Expected: default-feature tests pass without SDK linkage. Checkpoint: `feat(fastly): separate retained initialization from host calls`. + +## Task 3: Fastly opt-in serving APIs + +**File:** `crates/edgezero-adapter-fastly/src/lib.rs`. + +- [x] **3.1 Add these public APIs:** + +```rust +#[cfg(feature = "fastly")] +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| {}) +} + +#[cfg(feature = "fastly")] +#[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 mut retained = RetainedApp::default(); + serve.run(move |req| -> Result { + let env = runtime_env_config(stores); + let app = retained.get_or_init(&env, A::owns_logging, init_logger, A::build_app)?; + request::dispatch_with_registries(app, req, stores, &env, &mut extend) + }) +} +``` + +- [x] **3.2 Add API docs** for ordinary `main`, first-callback initialization, degraded logging snapshot, 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.3 Verify:** + +```sh +cargo test -p edgezero-adapter-fastly --lib +cargo check -p edgezero-adapter-fastly --features fastly --target wasm32-wasip1 +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`. + +## Task 4: Cloudflare and Spin prebuilt dispatch + +**Files:** `crates/edgezero-adapter-cloudflare/src/lib.rs`, `crates/edgezero-adapter-spin/src/lib.rs`. + +- [x] **4.1 Add the Cloudflare wrapper:** + +```rust +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +#[inline] +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 +} +``` + +- [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. +- [x] **4.3 Add the Spin wrapper:** + +```rust +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[inline] +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 +} +``` + +- [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`. + +## 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.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.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`. + +## Task 6: Build the local HTTP evidence harness + +**Files:** `tests/fixtures/reusable-app/crates/fixture-harness/src/main.rs` and `scripts/smoke_test_reusable_app.sh`. + +- [x] **6.1 Define the runner interface.** Accept `--adapter fastly|cloudflare|spin|axum|all`, `--suite smoke|benchmark`, `--output `, and `--require-runtime`. Resolve `VICEROY_BIN`, `WORKER_BUILD_BIN`, `WRANGLER_BIN`, and `SPIN_BIN` explicitly to absolute executables and record versions. Never install or deploy automatically. +- [x] **6.2 Write failing Rust tests for evidence accounting.** Cover a failed attempted request, a missing final summary, two duplicate headers, unavailable CPU measurements, and invalid overlap evidence. Run `cargo test --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-harness`; expect failures before implementing the parser/accounting. +- [x] **6.3 Implement JSONL accounting.** Record run/tool/build identity, request start, response commitment, guest completion, client completion, errors, SDK summary, metrics, and capability/skip events. Attempted requests and successful completions are different fields. Missing crash summaries are unknown, not zero. Every metric has source, units, and observed/injected/unsupported/unverified status. +- [x] **6.4 Implement a Rust loopback backend using `TcpListener` and scoped worker threads.** Provide a bounded two-arrival barrier, delayed chunked response with an explicit release signal, and abrupt connection closure. Flush the first chunk before waiting. Bind only loopback addresses and use allocated ports. +- [x] **6.5 Implement a bounded Rust HTTP client for the loopback fixtures.** Preserve response headers as a list; do not collapse duplicate cookies into a dictionary. Capture header/first-byte/full-response timing with `Instant`, partial-body failures, and concurrent requests using Rust threads. +- [x] **6.6 Test the streaming and overlap oracles.** Equal final bodies alone must fail the progressive-stream assertion. Two different instance markers or a maximum in-flight count of one must fail same-instance overlap. First-byte delivery must precede the controlled backend's final-chunk release. +- [x] **6.7 Implement process orchestration.** Copy configs into unique ignored `.runs/` directories, resolve copied paths, enforce deadlines, and clean up only child processes started by this invocation. Exit 0 means all requested assertions passed; 1 means build/assertion failure; 2 means required runtime/evidence is unavailable. Optional skips remain an explicitly incomplete result and must never hide assertion failures. +- [x] **6.8 Run the Rust harness tests and `bash -n scripts/smoke_test_reusable_app.sh`.** Expected: passing tests and valid shell syntax. Checkpoint: `test(lifecycle): add local HTTP evidence harness`. + +## Task 7: Exercise Fastly default, rebuilt, and retained applications + +**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`.** Read runtime configuration each callback, make the logging decision once from the first snapshot, build the app each callback, and call `dispatch_with_registries`. Use the same logging policy as C, including a degraded first snapshot. 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.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.** Derive runtime configuration's service-scoped keys from the guest's actual service ID, 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.** 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. Add the degraded-first-read/recovered-selector case where runtime controls permit; otherwise retain host injection evidence and mark the provider scenario unverified. +- [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 --require-runtime`. +- [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.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.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.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. +- [x] **8.8 Verify recoverable binding failures where controllable.** A relaunch with changed bindings proves fresh initialization, not same-instance refresh. Record injected versus observed transitions and unavailable host failure modes separately. +- [x] **8.9 Run scoped host tests, actual-target checks, and each HTTP smoke command.** Use `cargo test -p edgezero-adapter-cloudflare`, `cargo test -p edgezero-adapter-spin`, and the Task 12 target checks. Run the script once with `--adapter cloudflare` and once with `--adapter spin`, both `--suite smoke --require-runtime`. Checkpoint: `test(adapters): verify retained app isolation on Workers and Spin`. + +## Task 9: Verify Axum and integrate runnable CI evidence + +**Files:** `tests/fixtures/reusable-app/crates/fixture-axum/{Cargo.toml,src/main.rs}`, harness Axum orchestration, `.github/workflows/test.yml`. + +- [x] **9.1 Add the native reference fixture.** Use existing `dev_server::run_app::()` with an allocated host/port and isolated local-store configuration. Add no new Axum lifecycle API. Run the same probe and overlap workload, expecting one build per server start and another after restart. +- [x] **9.2 Run the native fixture.** Build `fixture-axum` with the fixture manifest and `--locked`; run `cargo test -p edgezero-adapter-axum` and `./scripts/smoke_test_reusable_app.sh --adapter axum --suite smoke --require-runtime`. Axum timings do not stand in for WASM results. +- [x] **9.3 Add Rust harness unit tests and portable fixture tests to CI.** The root workspace excludes this fixture workspace, so explicitly invoke its locked `fixture-core` tests. Preserve all existing root tests. +- [x] **9.4 Add the Fastly HTTP smoke step to the existing Fastly WASM matrix job.** Reuse its Viceroy installation and version from `.tool-versions`; do not add a competing download. Require the runtime and actual multi-request evidence. Keep existing WASM contract and library tests: they cover different behavior from a live multi-request fixture. +- [x] **9.5 Check CI command parity locally.** Cloudflare/Spin HTTP runtime provisioning is not currently supplied by the existing contract matrix; retain explicit manual commands and report unavailable evidence. Do not label their WASM contract success as overlapping live-request validation. Checkpoint: `ci: run reusable Fastly HTTP smoke fixtures`. + +## Task 10: Produce controlled lifecycle measurements + +**Files:** benchmark/accounting sections of `tests/fixtures/reusable-app/crates/fixture-harness/src/main.rs` and fixture instrumentation; reports go under ignored `.runs/`. + +- [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.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. +- [x] **10.7 Summarize raw evidence.** Report sample counts and documented p50/p95/p99 calculations for cold/reused first-byte and completion latency; attempts/completions per guest; build/configure counts; supported CPU observations; memory peak, slope, and plateau over actual ordinals. Distinguish guest linear-memory high-water marks, host-inclusive snapshots, and process RSS. Rounded MiB observations cannot rule out small leaks. +- [x] **10.8 Classify each result.** Use pass/fail/unsupported/unverified. Require a reproducible workload benefit and bounded retained growth before recommending adoption; set no invented speedup target. SDK vCPU readings alone do not justify cross-run performance claims. Deployed reuse frequency, eviction, logging endpoint validity, resource accounting, and performance remain unverified until separately authorized measurements. +- [x] **10.9 Rerun Rust harness and affected scoped tests.** Keep raw generated reports ignored unless explicitly approved for publication and scrubbed. Checkpoint: `test(lifecycle): measure initialization and reuse separately`. + +## Task 11: Document supported ownership and adoption + +**Files:** `docs/guide/adapters/{overview,fastly,cloudflare,spin,axum}.md`, `tests/fixtures/reusable-app/README.md`, and the relevant comment in `examples/app-demo/crates/app-demo-core/src/lib.rs`. + +- [x] **11.1 Update the adapter overview.** Explain portable ownership versus provider scheduling; safe retained app/configuration values versus request-owned handles/extensions/bodies/pending work; intentional shared state; and caller-owned refresh, rotation, isolation checks, and benchmarks. +- [x] **11.2 Update Fastly's guide.** Show the compiled standard helper and raw callback examples. Cover all four SDK limits, first-snapshot logging, `FnMut` reborrowing, explicit sends/streams, post-send completion, attempted summaries, terminal errors including failed error rendering, memory preflight, and possible fresh initialization at any request. State that retained apps do not automatically remove explicitly repeated construction or parsing elsewhere. Explicitly distinguish sandbox `FASTLY_TRACE_ID` from per-request `Request::get_client_request_id()`. Document an application-generated per-request correlation fallback when native IDs are unavailable, identify its source, and keep it out of retained logger/app state. Explain persistent warning suppression, bounded-cache eviction, and why warning frequency does not measure failure frequency. +- [x] **11.3 Update Cloudflare and Spin guides.** Show concrete app-owned caches with explicit metadata and request-local dispatch. Explain caller logging before initialization, concurrent access, P3 interface verification, and runtime-specific capability detection. Avoid generic-static examples. +- [x] **11.4 Update Axum's guide and qualify the demo comment.** Document its existing retained router; describe the Fastly comment as applying to the default entry point. Keep default generated templates and macro settings unchanged. +- [x] **11.5 Write fixture instructions from commands actually exercised.** Include tool selection, build targets, local store setup, smoke and benchmark commands, evidence statuses, and default-entry-point rollback. Restore A to test rollback; limit one alone does not recreate all original initialization behavior. Mark unmeasured results explicitly. +- [x] **11.6 Compile every example through the matching fixture target.** Keep guide snippets synchronized with the compiled fixtures rather than adding a second untested example family. Run scoped Cargo tests if the demo source comment changes, per repository convention. +- [x] **11.7 Run docs checks.** From `docs`, run `npm run format`, `npm run lint`, and `npm run build`. Check all new documentation for prohibited external consumer identifiers and local machine paths. Checkpoint: `docs: explain opt-in application retention across adapters`. + +## Task 12: Final verification and review + +**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 remain limited to the three adapter library files and the focused Cloudflare response-header fix, plus tests/docs. +- [x] **12.2 Run all repository gates.** Expected: each command exits successfully; record unavailable targets/tools separately rather than claiming success. + +```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" +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] **12.3 Verify the standalone workspace explicitly.** Run its format check, locked core tests, each provider's target build, native Axum build, Rust harness tests, and all available HTTP smoke suites. Do not run native all-feature tests across its Fastly members. Root Cargo success does not cover this nested workspace. +- [x] **12.4 Request independent review using `superpowers:requesting-code-review`.** Supply this plan, the governing spec, the diff, and actual test/evidence results. Review architecture/default compatibility and runtime/build evidence as separate scopes. Use `superpowers:verification-before-completion` before claiming implementation completion. +- [x] **12.5 Resolve findings and rerun affected checks.** Broaden repeat testing only when changes or unresolved concerns justify it. Do not silently weaken an evidence assertion because a local runtime cannot satisfy it. +- [x] **12.6 Report delivery status.** List APIs shipped, default compatibility, passed gates, observed provider behavior, unsupported/unverified acceptance items, and required application-owned adoption work. No deployment, PR/issue comment, or performance claim beyond the evidence is authorized by this plan. + +## Coverage and decision record + +| Requirement | Implementation / evidence | +| -------------------------------------------------------------- | ----------------------------------------------- | +| Portable ownership, fresh request state | Tasks 1, 4, 5, 8, 9 | +| Fastly opt-in lifecycle, all supported limits | Tasks 2, 3, 7, 10, 11 | +| Host-testable production initialization | Task 2; provider assertions remain in Tasks 7–8 | +| Custom mutable dispatch, streaming, finalization, pending work | Task 7 | +| Metadata and logging snapshots/recovery | Tasks 2–4, 7–8 | +| Attempted versus completed requests and terminal failures | Tasks 6, 7, 10 | +| Same-instance overlapping-request isolation | Tasks 1, 6, 8, 9 | +| A/B/C cost separation and bounded memory | Task 10 | +| Generated/default compatibility and rollback | Tasks 3–4, 7, 11–12 | +| Persistent-state audit and supported cleanup | Tasks 1.6a–1.6b, 7.8, 11.2 | +| Sandbox identity versus request correlation | Tasks 7.5a, 11.2 | +| Local versus deployed evidence | Tasks 6–12 | + +The planning review compared two focused proposals: a private Fastly state helper with thin wrappers, and a standalone provider fixture workspace that reuses existing CI runtime setup. The selected approach keeps SDK calls out of host unit tests while testing the same production initialization state. It preserves existing dispatcher ordering and avoids a new public lifecycle abstraction. Independent complete-plan review approved this plan after two corrections: unique candidate instance tokens on every request, and explicit unsupported/unverified status when no SDK heap-hostcall failure mechanism is available. Implementation outcomes are checked below; these marks do not assert that a suggested commit was made or that unavailable provider evidence passed. + +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 + +Implementation is on `feat/reusable-app-lifecycle` in the original checkout. +Changes remain uncommitted. Existing default entry points, generated templates, +macro grammar, and lifecycle error policy are unchanged. One targeted compatibility +fix is included: Cloudflare now preserves repeated application response headers, +including `Set-Cookie`, while still replacing generated defaults with the first +application value. Live tests reproduced the original loss and pass with the fix. + +Tasks 1–9 and 11–12 have implementation and local verification, subject to the +explicit full-matrix exception in 8.7. Task 10 has the measurement implementation +and controlled runs recorded in the fixture README. Checkmarks denote completed +implementation/verification work, including an explicit capability disposition; +they do not turn unsupported behavior into a passing provider assertion. + +The final Rust driver has 17 unit tests; the portable fixture has two. Root +workspace tests, strict all-feature Clippy, formatting, feature checks, and +Fastly WASM library/contract tests pass. Provider target builds and live HTTP +checks exercise all four adapters. Fastly's expanded fault cases, Cloudflare's +both-mode overlap, and Cloudflare/Spin retained binding recovery pass. Spin's +retained overlap passes; the latest all-provider invocation exits 2 because +its per-request control did not observe same-guest overlap after three attempts. +No assertion failure is hidden by that status. + +Remaining evidence is environmental or outside this implementation: + +- No local control was found for transient failure of the fixed optional runtime + store or an unavailable heap hostcall. Host snapshot injection and real required + 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. +- 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. + Rounded heap samples were stable at 2 MiB; longer per-instance growth remains + unverified because increasing the configured limit did not extend host reuse. + Cached Viceroy 0.17.0 source confirms a hardcoded five additional requests + (`NEXT_REQ_ACCEPT_MAX`); the installed CLI has no override. + +The bounded lifetime test crosses a 50-ms deadline during a 100-ms initialization; +it proves the next callback is not admitted after expiration, not interruption of +an active handler. The memory-limit test uses a threshold below the observed +fixture baseline after successful preflight. Neither requires an unbounded load. + +The HTTP driver is Rust. Source files use descriptive names; A/B/C are experiment +labels only. Raw evidence stays under ignored `.runs/` directories. Independent +measurement and diff reviews led to explicit unknown metrics, consistent request +correlation, and phase-specific CPU/memory reporting; the final review found no +further actionable issue. No deployment, commit, or external comment was made. + +The follow-up risk review added a core cancellation regression: dropping a +suspended request releases its extension resources while retaining the router, +and a subsequent request has no stale request extension. The adapter overview +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. diff --git a/docs/superpowers/plans/2026-09-17-custom-serving-lifecycle.md b/docs/superpowers/plans/2026-09-17-custom-serving-lifecycle.md new file mode 100644 index 00000000..d17b9ae2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-custom-serving-lifecycle.md @@ -0,0 +1,55 @@ +# Custom serving lifecycle + +## Objective + +Provide reusable lifecycle mechanics for custom Fastly dispatch without taking +over application-specific initialization, response finalization, or streaming. +Keep the default entry points unchanged. + +## Implementation + +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. +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. +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 + Fastly smoke suite. Independently review the implementation and consumer fit. + +## Other adapters + +Cloudflare and Spin already expose `dispatch_app` with explicit store metadata +and freshly resolved request resources. Their callers own retention compatible +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. + +## Acceptance + +Health callbacks count but skip initialization. A failed build is returned and +not retained; the next attempt can succeed and subsequent requests reuse it. +Successful setup survives application retries. The custom runtime fixture must +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 + +Implemented and independently reviewed against the SDK sending contract and a +custom consumer's ownership requirements. Workspace tests: 1,435 passed, one +existing ignored test. Fixture unit tests: 19 passed. Workspace and fixture +Clippy, Fastly WASM compilation, adapter feature checks, Spin WASM check, Rust +formatting, and documentation format/lint passed. + +The Fastly smoke suite passed under Viceroy 0.17.0. Its recovery sequence observed +five callbacks in one instance: statuses 200/503/200/200/200 and initialization +attempts 0/1/1/2/2. Only the fourth callback built the retained app successfully; +the fifth reused it. Separate client records and the initialization runtime log +establish that sequence; it is not included in the aggregate guest phase summary. +Progressive streaming, duplicate headers, response finalization, and post-commit +error fixtures also passed. These are local compatibility observations, not +deployed lifecycle or comparative performance evidence. diff --git a/docs/superpowers/plans/2026-09-17-lifecycle-contract-completion.md b/docs/superpowers/plans/2026-09-17-lifecycle-contract-completion.md new file mode 100644 index 00000000..62bc3b37 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-lifecycle-contract-completion.md @@ -0,0 +1,46 @@ +# Lifecycle contract completion + +**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. + +## Lifecycle acceptance + +- [x] Extend the custom Fastly fixture and smoke runner with health bypass, + failed initialization, successful retry, and subsequent retained reuse. +- [x] Produce request-specific finalization metadata inside the router and verify + its value after dispatch, including across reused callbacks. +- [x] Document the provider/application boundary, retry policy, version pinning, + and the existing repeatable compatibility command in the Fastly guide. +- [x] Run fixture unit tests and the actual Fastly smoke suite. Missing observed + reuse must remain unverified, never a passing recovery assertion. + +## Configuration correctness + +- [x] Add regression tests for redaction of invalid and colliding Spin secret + references; verify failure before changing diagnostics. +- [x] Add selected-adapter push/diff regressions with unrelated invalid Spin + configuration, preserving standalone validation and selected-Spin failures. +- [x] Scope shared, typed, and strict capability validation to the selected + 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 + +- [x] Run scoped tests after code changes, then workspace tests, format, Clippy, + all-adapter feature checks, and Spin WASM compilation. +- [x] Run documentation format/lint and independent diff review. +- [x] Report local runtime evidence separately from deployed behavior. Do not + publish, tag, deploy, or change release pins as part of this work. + +Verified locally: 1,432 workspace tests passed (one existing generated-project +test ignored); 19 fixture tests passed; workspace and fixture Clippy passed, +including the Fastly WASM fixture; all-adapter checks and Spin WASM check passed; +documentation format/lint passed. Viceroy 0.17.0 smoke passed with all five +initialization callbacks in one guest. Independent lifecycle and CLI reviews +found no blocking defects. Deployed behavior remains outside this evidence. 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 new file mode 100644 index 00000000..d0ce8747 --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-reusable-app-lifecycle-design.md @@ -0,0 +1,451 @@ +# Opt-in reusable application lifecycle + +- **Status:** Proposed; implementation requires approval. +- **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. +- **Evidence:** Source inspection and provider documentation. No lifecycle experiment or performance result is claimed by this spec. + +## 1. Objective + +Allow applications to amortize app/router construction across requests when the host supports reuse. Separate retained initialization from request setup without changing existing entry-point defaults or moving application policy into the framework. + +There are two independent operations: + +1. A host permits an instance to receive another request. +2. Application code retains initialized objects rather than rebuilding them when that request arrives. + +Enabling the first does not automatically implement the second. Retaining the router also does not eliminate configuration reads, request conversion, registry construction, signing, parsing, or other work that application handlers still perform explicitly. + +Correctness must tolerate fresh initialization on any request. Retention is an optimization within one process, sandbox, isolate, or component instance, not durable storage or a routing-affinity guarantee. + +## 2. Verified baseline + +Paths and line numbers below refer to the source baseline, not future implementation locations. + +| 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 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 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` | +| 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` | + +### 2.1 Host lifecycle distinctions + +- **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. +- **Axum:** the existing long-running service already retains its router and permits concurrent requests. + +Provider references: + +- [Fastly sandbox lifecycle](https://www.fastly.com/documentation/guides/compute/developer-guides/sandbox-lifecycle/) +- [Fastly 0.12.1 serving API](https://docs.rs/fastly/0.12.1/fastly/http/serve/index.html) +- [Cloudflare runtime model](https://developers.cloudflare.com/workers/reference/how-workers-works/) +- [Spin instance reuse](https://spinframework.dev/v4/http-trigger#controlling-instance-reuse) + +These distinctions prohibit a universal sequential serving loop in core. + +## 3. Scope and ownership + +### Framework responsibilities + +- Support dispatch against a prebuilt application with complete store-metadata wiring. +- 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. +- Document lifecycle, concurrency, failure, refresh, and resource boundaries. +- Provide framework contract tests and local lifecycle fixtures. + +### Application responsibilities + +- Decide which settings, parsed objects, registries, clients, middleware caches, and other objects to retain. +- Define refresh, invalidation, key rotation, bounded cache growth, and initialization-failure policies. +- Keep request-derived data out of shared initialization unless explicitly designed for safe sharing. +- Own raw-request mutation, application response finalization, and post-send application work. +- Prove application-specific isolation and measure representative workloads. + +### Non-goals + +- No automatic caching inside existing `run_app` functions. +- 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`. +- No changes to body buffering, stream caps, error redaction, or response translation semantics. +- 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. + +## 4. API design + +### 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`. + +`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. + +### 4.2 Fastly standard lifecycle + +Add these exports in `edgezero-adapter-fastly`, under its existing `fastly` gate: + +```rust,ignore +pub use fastly::http::serve::{Serve, ServeSummary}; + +pub fn serve_app(serve: Serve) + -> ServeSummary; + +pub fn serve_app_with_request_extensions( + serve: Serve, + extend: F, +) -> ServeSummary +where + A: Hooks, + F: FnMut(&fastly::Request, &mut Extensions); +``` + +`serve_app` delegates with an empty extensions callback. Both helpers: + +1. Capture static `A::stores()` metadata. +2. Enter the SDK callback before performing host-dependent initialization. +3. Read runtime configuration for the current request. +4. On the first callback only, resolve logging policy, honor `A::owns_logging()`, initialize adapter logging if enabled, and call `A::build_app()`. +5. Retain the successfully constructed app for subsequent callbacks. +6. Use the first runtime configuration read for the first dispatch; do not read it twice. +7. On every request, use existing `request::dispatch_with_registries` with fresh registries and the current runtime configuration. +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 are a first-initialization snapshot, even if runtime selectors later change. Store selector reads remain per request. Preserve the existing `FastlyLogging::from(&EnvConfig)` mapping: it derives enablement from endpoint presence and currently fixes `echo_stdout` to true rather than applying the corresponding runtime override. This feature does not change that mapping. `owns_logging` retains its existing meaning: skip adapter logger installation. An application adopting retained construction must arrange its own logger installation before construction if construction needs logging. + +**Degraded-first-read policy:** preserve the existing optional-store fallback and freeze the resulting logging decision for this sandbox. `runtime_env_config` does not distinguish an absent optional store from another store-open failure; both produce empty configuration. That disables named-endpoint logging for the sandbox even if a later runtime read succeeds. The `echo_stdout` value does not provide a fallback when no logger is installed. Later reads can restore store selectors but must not silently reconfigure the global logger. Test this explicitly. Applications requiring stricter logging availability must use the custom lifecycle with their own configuration-read and initialization policy. Adding a distinguishable read status, deferred logging initialization, or retry policy would require a separately reviewed API/ordering change; it is not implied by these helpers. + +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. + +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. + +Example entry point after implementation: + +```rust,ignore +use edgezero_adapter_fastly::{Serve, serve_app}; + +fn main() -> Result<(), fastly::Error> { + serve_app::(Serve::new().with_max_requests(10)).into_result() +} +``` + +The example deliberately uses ordinary `main`, not `#[fastly::main]`. Existing generated entry points remain unchanged. + +### 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. + +A callback receives an owned native request and may mutate it, capture metadata, convert it with `request::into_core_request`, 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. + +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. + +Existing public `runtime_env_config` and `request::dispatch_with_registries` remain the complete prebuilt-app path when standard response translation is appropriate. The immutable extensions callback is not a substitute for mutable raw-request custom dispatch. + +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. + +### 4.4 Cloudflare and Spin prebuilt dispatch + +Add public root-level dispatch helpers that take metadata explicitly: + +```rust,ignore +// edgezero-adapter-cloudflare +pub async fn dispatch_app( + app: &App, + stores: StoresMetadata, + req: worker::Request, + env: worker::Env, + ctx: worker::Context, +) -> Result; + +// edgezero-adapter-spin +pub async fn dispatch_app( + app: &App, + stores: StoresMetadata, + req: spin_sdk::http::Request, +) -> 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 the supplied metadata, and call the existing internal registry-aware dispatcher. Metadata must describe the supplied app's intended store bindings; normal callers pass `MyApp::stores()`. + +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. + +Explicit metadata avoids silently losing store wiring and avoids pretending `&App` contains a `Hooks` implementation. Logging is excluded intentionally: a dispatcher must be safe to call repeatedly, and the caller controls initialization before constructing the app. + +Existing `run_app` may delegate its dispatch portion to the new function if doing so preserves logger-before-build ordering, environment-resolution behavior, error behavior, and exactly one build per call. Do not refactor unrelated code for symmetry. + +Do not route these helpers through the manual Cloudflare builder or Spin `AppExt::dispatch`: those paths do not preserve all metadata-aware behavior. + +### 4.5 Concrete retention on Cloudflare and Spin + +Document a static owned by the concrete application entry-point module: + +```rust,ignore +use edgezero_core::app::{App, Hooks}; +use std::sync::OnceLock; + +static APP: OnceLock = OnceLock::new(); + +fn retained_app() -> &'static App { + APP.get_or_init(MyApp::build_app) +} +``` + +The fetch/HTTP callback obtains this reference synchronously and passes it, `MyApp::stores()`, and the current request arguments to `dispatch_app`. + +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. +- 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. +- 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. + +## 5. Fastly limits and termination + +Expose SDK limit configuration without translating or duplicating it: + +| SDK setting | Semantics to document | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `with_max_requests(usize)` | Positive values bound requests; zero means no SDK request-count limit. The example uses an explicit finite value. | +| `with_max_lifetime(Duration)` | Checked between requests, measured from `Serve` construction. Not an interrupt deadline for a running handler. | +| `with_max_memory(u32)` | MiB snapshot threshold checked between requests; zero disables this SDK threshold. An unavailable measurement stops further reuse conservatively when configured. | +| `with_timeout(Duration)` | Bounds waiting for another request, subject to platform limits. Does not set application/backend request timeout. | + +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. + +`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. + +## 6. Response and resource contract + +### 6.1 Preserve response behavior + +Implementation review identified one focused compatibility correction: Cloudflare's +converter previously overwrote repeated application headers. Preserve each value, +including duplicate `Set-Cookie`, by replacing the generated default with the first +application value and appending the rest. This correction applies to both existing +and retained entry points; stream translation and response ownership stay unchanged. + +| 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 streaming response conversion. | + +Fastly and Spin inbound conversions buffer bodies. This feature must not be described as providing end-to-end streaming or removing 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. + +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. + +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. + +### 6.3 Retained versus request-owned state + +| Retain only by deliberate policy | Keep fresh and request-owned | +| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| App/router/routes, middleware instances, static store metadata | Raw/core requests and responses, headers, URI, route parameters | +| Immutable settings snapshots, parsed ordinary-memory objects | Client metadata, authentication/session data, request IDs, request extension bags | +| Bounded caches with explicit keys, eviction, and invalidation | Native store wrappers/registries, body streams, pending backend operations, response effects | + +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. + +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. + +**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). + +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. +- 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. + +### Platform verification gates + +1. **Fastly logging endpoints:** the current logger retains native endpoint handles. Viceroy retains its endpoint table, but this is not a blanket deployed lifetime guarantee. Verify named-endpoint logging through multiple reused requests and obtain authoritative SDK/provider evidence for deployed validity before declaring full support. Assert actual receipt of distinct request-correlated records at the named endpoint after the first request; absence of returned errors or stdout output is insufficient. `log-fastly` ignores endpoint write errors, so invalid handles may manifest as missing logs. If invalid, a separately reviewed logger adjustment must retain configuration while reacquiring request-valid endpoints; do not simply ignore logger errors. +2. **Native handles:** the spec deliberately avoids caching store handles. No guarantee that all native resources survive request boundaries is assumed. +3. **Spin host/interface compatibility:** verify the emitted P3 component and installed runtime together, not only the Rust target triple or CLI version. +4. **Overlap:** verify Cloudflare and Spin callbacks actually overlap in the test, then prove isolation. A serialized test cannot establish concurrent safety. + +These are validation gates, not permission to silently broaden implementation scope. Unsupported environments must be reported as unsupported or unverified rather than counted as passing. + +## 8. Files and documentation surfaces + +Expected production changes: + +- `crates/edgezero-adapter-fastly/src/lib.rs`: SDK exports, two opt-in standard lifecycle helpers, and a small private feature-independent lifecycle state/helper with colocated host tests. This may remain in `lib.rs`; split a private module only if needed for clarity. +- `crates/edgezero-adapter-cloudflare/src/lib.rs`: explicit-metadata prebuilt dispatcher. +- `crates/edgezero-adapter-cloudflare/src/response.rs`: the duplicate-header correction described in §6.1. +- `crates/edgezero-adapter-spin/src/lib.rs`: explicit-metadata prebuilt dispatcher. +- Existing request modules: reuse registry logic; change only if necessary to share it without duplication. +- `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. + +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/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. + +Keep generated templates and default demo entry points unchanged. Add opt-in examples as dedicated fixtures under `tests/fixtures/reusable-app/` with a standalone Cargo workspace, per-provider packages/configuration, and no production credentials. Add `scripts/smoke_test_reusable_app.sh` to build/run selected local providers, collect results, and report explicit skips. Record exact fixture paths in the implementation plan after confirming provider build-tool requirements. + +The current spec is the only file authorized for this design step; the listed implementation files are proposed work. + +## 9. Validation design + +### 9.1 Framework tests + +#### Host logic versus provider execution + +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. + +Factor the initialization/retention state used by `serve_app` into a small private helper compiled under `cfg(any(feature = "fastly", test))`, with no dependency on Fastly SDK types or hostcalls. 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 this extraction private and local to the Fastly adapter; no public lifecycle abstraction is needed. + +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. +- **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. + +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. + +- Count builds/configure invocations: existing default paths build every invocation; retained paths build once per owner/instance. +- 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 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. + +### 9.2 Local runtime matrix + +At investigation time PATH Viceroy is 0.17.0; Fastly CLI 15.1.0 reports Viceroy 0.21.0; Spin is 4.0.0. Record the exact executable used, SDK lockfile, build target, emitted HTTP interface, and configuration for every result. + +The fixture runner must select and report its Viceroy executable explicitly. `edgezero serve --adapter fastly` invokes `fastly compute serve`, which may select a different Viceroy. Record the CLI-managed runtime separately; do not attribute standalone fixture results to the CLI path without running it. Different versions are valid separate test environments, not interchangeable evidence. + +Before enabling `with_max_memory` in a local comparison, verify that the actual guest can obtain a successful memory snapshot. Both investigated Viceroy versions implement `get_heap_mib`, but source support does not replace this runtime preflight. If unavailable, report the metric and memory-limit test as unsupported and omit that optional limit from the reuse-performance comparison. Otherwise the SDK may stop after request one while `into_result()` remains successful. Keep a separate unsupported-measurement test to verify the SDK's conservative termination behavior. + +Viceroy's versioned upstream tests demonstrate a multi-request implementation: [0.17.0](https://github.com/fastly/Viceroy/blob/v0.17.0/cli/tests/integration/reusable_sessions.rs#L7) and [0.21.0](https://github.com/fastly/Viceroy/blob/v0.21.0/cli/tests/integration/reusable_sandboxes.rs#L7). This makes local experiments plausible; it is not evidence that these EdgeZero changes have run successfully. + +- **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. +- **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. + +### 9.3 Fastly A/B/C experiment + +| Variant | Serving lifecycle | Initialization | +| ------- | ----------------------------------- | ----------------------------------------------------------------- | +| A | Existing single-request entry point | Current per-request app construction and logging policy | +| 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. + +Implement B through the custom path in §4.3: `Serve::run`, a once-only logging initialization decision, per-callback `runtime_env_config` and `A::build_app()`, then `request::dispatch_with_registries`. Do not use retaining `serve_app` for B. Apply the same first-read logging policy in B and C so degraded 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. +- 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. + +Use fixed release builds, deterministic payloads, controlled local backends, identical instrumentation, repeated runs, and randomized variant order where practical. Include small requests, expensive construction, delayed/large streams, failures, long sequences, idle gaps, and concurrency. Do not set a performance percentage target without baseline data. Adoption requires a reproducible benefit for the target workload with no correctness regression and bounded retained-state growth. + +Include a repeated-origin control and a many-distinct-authorized-origin proxy workload, recording latency, registration failures or waits, attempted requests per sandbox, completion outcomes, and memory. Local runtime behavior does not establish deployed service-wide capacity behavior. Include malformed-request attempts and injected inbound/body-conversion failures; distinguish requests rejected by the host before dispatch from errors actually reaching the adapter. Verify the specified termination/recovery behavior without labelling a hypothetical malformed request as a demonstrated exploit. + +### 9.4 Deployed evidence + +Local results establish only the tested local runtime behavior. Platform reuse frequency, eviction, endpoint-handle validity, resource accounting, and performance require separately authorized deployed validation. Record these as unverified until measured. No configured request limit is a guaranteed reuse count. + +### 9.5 Repository checks + +After implementation, run scoped `cargo test` after code changes and all required gates: + +```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" +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 +``` + +Also build and run the dedicated provider fixtures. Run documentation formatting/lint and the VitePress build when public guide changes are made. This design-document-only change does not claim any Rust test execution. + +## 10. Acceptance and delivery + +The implementation is acceptable when: + +- Existing generated/default entry points retain their behavior. +- 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. +- 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. +- Native handle and logger support claims are limited to authoritative evidence and tested environments. +- A/B/C measurements distinguish lifecycle reuse from retained application initialization and do not claim automatic removal of every per-request cost. +- All required build/test/documentation checks pass, or unavailable runtime evidence is explicitly reported without claiming that acceptance criterion passed. + +Suggested implementation sequence: public prebuilt dispatch and core contracts; Fastly lifecycle; provider fixtures and experiments; guides and examples. Each stage preserves defaults. No cross-provider cache abstraction is required to deliver this design. + +Rollback is an entry-point choice: return to the existing `run_app` path and remove the concrete retained-app cache. On Fastly, also restore the single-request entry point. Test the rollback fixture rather than assuming a lower request limit recreates every aspect of the old initialization path. No persisted data migration is part of this feature. + +## 11. Review decisions + +Independent reviews checked the adapter/core design and a custom-dispatch integration. The selected approach incorporates these corrections: + +- App ownership is portable, but scheduling and concurrency remain provider-specific. +- Prebuilt dispatch needs explicit store metadata because `App` does not contain it. +- Concrete application-owned caches avoid generic-static cross-application contamination. +- Mutable native-request handling and response-extension finalization require the raw callback path. +- Progressive client streaming differs from collecting a stream into a response body. +- The current Spin SDK's exported HTTP interface determines reuse semantics, not the Rust target name. + +A subsequent source review clarified degraded logging snapshots, terminal adapter errors versus rendered application errors, service-wide backend pressure, callback reborrowing, and runtime measurement preflights. These clarifications retain the original opt-in and error-policy boundaries; no blanket error swallowing or automatic logging retry was added. + +Rejected alternatives: documentation-only Fastly lifecycle repeats fragile logger ordering in every standard app; a universal server builder duplicates provider controls; adding metadata/cache management to core expands scope; silently caching inside existing helpers breaks lifetime compatibility. 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 9735fbaa..ac8f18aa 100644 --- a/examples/app-demo/crates/app-demo-core/src/lib.rs +++ b/examples/app-demo/crates/app-demo-core/src/lib.rs @@ -23,7 +23,7 @@ pub struct DemoState { /// IMPORTANT: `app!(state = )` emits this call inside the macro-generated /// `build_router()`, which every adapter's `run_app` invokes via `A::build_app()` /// — 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 +/// the default Fastly entry point (each request is a fresh Wasm instance). So `app_state()` must /// be **cheap**: build the heavy state once and hand out clones. Here a /// `OnceLock>` builds it lazily and every call just bumps the /// `Arc` refcount — do NOT `Arc::new(..)` a heavy object on each call. diff --git a/scripts/smoke_test_reusable_app.sh b/scripts/smoke_test_reusable_app.sh new file mode 100755 index 00000000..417dda61 --- /dev/null +++ b/scripts/smoke_test_reusable_app.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec cargo run --quiet --locked --manifest-path "$REPO_DIR/tests/fixtures/reusable-app/Cargo.toml" -p fixture-harness -- "$@" diff --git a/tests/fixtures/reusable-app/.gitignore b/tests/fixtures/reusable-app/.gitignore new file mode 100644 index 00000000..6008c8bc --- /dev/null +++ b/tests/fixtures/reusable-app/.gitignore @@ -0,0 +1,5 @@ +/target/ +/.runs/ +**/build/ +**/.wrangler/ +**/.spin/ diff --git a/tests/fixtures/reusable-app/Cargo.lock b/tests/fixtures/reusable-app/Cargo.lock new file mode 100644 index 00000000..64ae81da --- /dev/null +++ b/tests/fixtures/reusable-app/Cargo.lock @@ -0,0 +1,3342 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "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", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "edgezero-adapter-axum" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "bytes", + "edgezero-core", + "futures", + "futures-util", + "http", + "log", + "redb", + "reqwest", + "serde_json", + "simple_logger", + "thiserror 2.0.20", + "tokio", + "tower", + "tracing", +] + +[[package]] +name = "edgezero-adapter-cloudflare" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "brotli", + "bytes", + "edgezero-core", + "flate2", + "futures", + "futures-util", + "log", + "serde_json", + "worker", +] + +[[package]] +name = "edgezero-adapter-fastly" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "brotli", + "bytes", + "chrono", + "edgezero-core", + "fastly", + "fern", + "flate2", + "futures", + "futures-util", + "log", + "log-fastly", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", +] + +[[package]] +name = "edgezero-adapter-spin" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "brotli", + "bytes", + "edgezero-core", + "flate2", + "futures", + "futures-util", + "log", + "serde", + "serde_json", + "spin-sdk", + "subtle", + "thiserror 2.0.20", +] + +[[package]] +name = "edgezero-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-compression", + "async-stream", + "async-trait", + "bytes", + "edgezero-macros", + "futures", + "futures-util", + "http", + "http-body", + "log", + "matchit 0.9.2", + "ryu", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha2 0.10.9", + "thiserror 2.0.20", + "toml", + "tower-service", + "tracing", + "validator", + "web-time", +] + +[[package]] +name = "edgezero-macros" +version = "0.1.0" +dependencies = [ + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 3.0.3", + "toml", + "validator", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elsa" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9abf33c656a7256451ebb7d0082c5a471820c31269e49d807c538c252352186e" +dependencies = [ + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastly" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e4c3df48350d9f4fc95b4deaf87fd29820336b7926bb84bf460457c2a126b" +dependencies = [ + "anyhow", + "bytes", + "downcast-rs", + "elsa", + "fastly-macros", + "fastly-shared", + "fastly-sys", + "http", + "itertools", + "lazy_static", + "mime", + "serde", + "serde_json", + "serde_repr", + "serde_urlencoded", + "sha2 0.9.9", + "smallvec", + "thiserror 1.0.69", + "time", + "url", +] + +[[package]] +name = "fastly-macros" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2aef5f9690b04c8890f9a54ddb591b12b9779ec25ee0e572d207106e52e3d8" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "fastly-shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "080ad138403159fd366d3e0b14bb49cb0c01dc18c25095bbbd1c85e3338f5413" +dependencies = [ + "bitflags 1.3.2", + "http", +] + +[[package]] +name = "fastly-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de75ef193f6c29c43d667458bede648970715aedd5db2d42c2eba3ffa3ad738b" +dependencies = [ + "bitflags 1.3.2", + "fastly-shared", + "http", + "wasip2", + "wit-bindgen 0.51.0", +] + +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixture-axum" +version = "0.1.0" +dependencies = [ + "anyhow", + "edgezero-adapter-axum", + "edgezero-core", + "fixture-core", +] + +[[package]] +name = "fixture-cloudflare" +version = "0.1.0" +dependencies = [ + "edgezero-adapter-cloudflare", + "edgezero-core", + "fixture-core", + "serde_json", + "worker", +] + +[[package]] +name = "fixture-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "edgezero-core", + "futures", + "serde_json", +] + +[[package]] +name = "fixture-fastly" +version = "0.1.0" +dependencies = [ + "edgezero-adapter-fastly", + "edgezero-core", + "fastly", + "fixture-core", + "futures", + "log", + "serde_json", +] + +[[package]] +name = "fixture-harness" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "fixture-spin" +version = "0.1.0" +dependencies = [ + "anyhow", + "edgezero-adapter-spin", + "edgezero-core", + "fixture-core", + "serde_json", + "spin-sdk", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasip2", + "wasip3 0.4.0+wasi-0.3.0-rc-2026-01-06", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "log-fastly" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51dae5def13a2d557fdb63862d642f8d4641ec3773c036bb14092697b6764013" +dependencies = [ + "fastly", + "log", + "regex", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redb" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e925444704b5f17d32bf42f5b6e2df050bceebc3dcd6e71cc73dafe8092e839" +dependencies = [ + "libc", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_logger" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7038d0e96661bf9ce647e1a6f6ef6d6f3663f66d9bf741abf14ba4876071c17" +dependencies = [ + "colored", + "log", + "time", + "windows-sys 0.61.2", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin-macro" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11e483b94d5bcfac493caf0427fa875063e3e8604d0466a4ab491ec200a42857" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "spin-sdk" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fd2abac3eb2ee249c2241ab87f7b1287f36172c8cc1ea815c19c85e41ede44d" +dependencies = [ + "anyhow", + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "spin-macro", + "thiserror 2.0.20", + "wasip3 0.6.0+wasi-0.3.0-rc-2026-03-15", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasip3" +version = "0.6.0+wasi-0.3.0-rc-2026-03-15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed83456dd6a0b8581998c0365e4651fa2997e5093b49243b7f35391afaa7a3d9" +dependencies = [ + "bytes", + "http", + "http-body", + "thiserror 2.0.20", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" +dependencies = [ + "leb128fmt", + "wasmparser 0.247.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.247.0", + "wasmparser 0.247.0", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wasmparser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.17.1", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +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_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.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.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.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.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.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.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.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "bitflags 2.11.1", + "wit-bindgen-rust-macro 0.51.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags 2.11.1", + "futures", + "wit-bindgen-rust-macro 0.57.1", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.247.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata 0.244.0", + "wit-bindgen-core 0.51.0", + "wit-component 0.244.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata 0.247.0", + "wit-bindgen-core 0.57.1", + "wit-component 0.247.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core 0.51.0", + "wit-bindgen-rust 0.51.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core 0.57.1", + "wit-bindgen-rust 0.57.1", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.244.0", + "wasm-metadata 0.244.0", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-component" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.247.0", + "wasm-metadata 0.247.0", + "wasmparser 0.247.0", + "wit-parser 0.247.0", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.247.0", +] + +[[package]] +name = "worker" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3c60a70414db58e1890f3675d02692adace736657cb66994f220ae3780c90d" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http", + "http-body", + "js-sys", + "matchit 0.7.3", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "strum", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60bcb459a67977fcb79698a3123ae58a928b1b24cc3035eaec033dbdfc139438" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "strum", + "syn 2.0.119", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0e59a8504685d87649b8fda877d95fcc48f8c8177dbd77a4dc8e67f8fc80240" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tests/fixtures/reusable-app/Cargo.toml b/tests/fixtures/reusable-app/Cargo.toml new file mode 100644 index 00000000..de24d7cd --- /dev/null +++ b/tests/fixtures/reusable-app/Cargo.toml @@ -0,0 +1,21 @@ +[workspace] +resolver = "2" +members = ["crates/*"] +default-members = ["crates/fixture-core"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +publish = false + +[workspace.dependencies] +edgezero-core = { path = "../../../crates/edgezero-core", default-features = false } +edgezero-adapter-fastly = { path = "../../../crates/edgezero-adapter-fastly", features = ["fastly"] } +edgezero-adapter-cloudflare = { path = "../../../crates/edgezero-adapter-cloudflare", features = ["cloudflare"] } +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.1" +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 new file mode 100644 index 00000000..92051ec6 --- /dev/null +++ b/tests/fixtures/reusable-app/README.md @@ -0,0 +1,267 @@ +# Reusable application lifecycle fixtures + +This standalone Cargo workspace tests provider HTTP behavior without changing +normal generated entry points. Run from the repository root: + +```sh +cargo test --locked --manifest-path tests/fixtures/reusable-app/Cargo.toml -p fixture-harness -p fixture-core +./scripts/smoke_test_reusable_app.sh --adapter fastly --suite smoke --require-runtime +./scripts/smoke_test_reusable_app.sh --adapter cloudflare --suite smoke --require-runtime +./scripts/smoke_test_reusable_app.sh --adapter spin --suite smoke --require-runtime +./scripts/smoke_test_reusable_app.sh --adapter axum --suite smoke --require-runtime +``` + +The driver is the native Rust `fixture-harness` crate. Its HTTP clients, controlled +loopback backend, process management, and evidence checks run outside the WASM +applications. The shell script only launches it through Cargo. + +## Understand the change in five minutes + +Think of the app as the route table plus any state you attach to it. A request +still brings its own headers, body, extensions, and provider resources. + +```text +Existing Fastly entry point: + request 1 → initialize → build app → dispatch → sandbox ends + request 2 → initialize → build app → dispatch → sandbox ends + +Opt-in retained Fastly app, when the host reuses the sandbox: + request 1 → initialize → build app → fresh request setup → dispatch + request 2 → fresh request setup → dispatch + request 3 → fresh request setup → dispatch +``` + +Responses should behave the same. The visible difference is fewer construction +calls, which can reduce latency when initialization is expensive. Reuse is a host +decision: the next request may always need to initialize again. + +Start with these entry points in `crates/fixture-fastly/src/bin/`: + +| Experiment | Source | What survives between callbacks? | +| ---------- | ------------------------ | ---------------------------------- | +| A | `single_request.rs` | Nothing from the previous sandbox | +| 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. + +With the pinned Viceroy installed, run a small synthetic comparison: + +```sh +./scripts/smoke_test_reusable_app.sh --adapter fastly --suite benchmark \ + --requests 20 --repetitions 1 --construction-rounds 10000 --require-runtime +``` + +The reported evidence directory contains `events.jsonl` and `summary.json`. +For matched probe responses, inspect `guest.instance`, `guest.ordinal`, +`guest.builds`, and `guest.configures`: + +- A: a new instance, ordinal 1, and one build on every request. +- B: the same instance can have ordinal 2 and two builds. +- C: the same instance can have ordinal 2 while builds stays at one. + +Compare B and C's reused-request latency to see the construction cost being +removed. The extra JSON parsing deliberately makes that cost visible; it is not +a prediction of an application's production performance. Token, path, and body +checks confirm that each request still sees its own data. + +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. +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 +current behavior until an application explicitly adopts retention. + +Select executables with `VICEROY_BIN`, `WORKER_BUILD_BIN`, `WRANGLER_BIN`, and +`SPIN_BIN`. Install tools explicitly; the runner never deploys or installs them. +Worker's build command also uses `worker-build` through PATH; select the same +binary there. The inspected worker-build 0.8.5 passes a flag unsupported by the +locked wasm-bindgen 0.2.122 CLI. Worker-build 0.8.3 successfully builds this fixture. +The checked-in Worker compatibility date is supported by the inspected local host. + +The runner binds loopback servers, uses synthetic values, seeds only local stores, +and creates an ignored `.runs/` directory. An explicit `--output` must +be empty to prevent evidence loss. Generated provider artifacts are ignored. Do +not expose these diagnostic applications publicly. Their synthetic request tokens +are fixture identities, not a production request-ID generation scheme. + +Exit 0 means the requested implemented assertions passed; 1 means a build or +assertion failed; 2 means required runtime evidence is unsupported/unverified. +Raw JSONL, runtime logs, and a summary are retained. The summary includes only +matched probe samples, separated into cold/reused cohorts. SDK attempts, guest +completion, and client completion are distinct. Missing final summaries after a +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). +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 +retained reuse. The sequence must share one guest to establish recovery; early +guest retirement is reported as unverified. Router-produced finalization metadata +carries each request's token, so finalization also checks response-extension +preservation and isolation rather than merely setting a constant header. + +- A: original single-request helper. +- B: `Serve`, once-only logging, app construction on each callback. +- 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. Arm B 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. + +```sh +./scripts/smoke_test_reusable_app.sh --adapter fastly --suite benchmark --require-runtime +./scripts/smoke_test_reusable_app.sh --adapter fastly --suite benchmark --construction-rounds 10000 --require-runtime +``` + +Defaults are three repetitions of 100 probes per variant, with randomized order +and a recorded seed. `--construction-rounds` adds deterministic JSON parsing +inside app construction; it models expensive initialization without embedding +application business logic. This synthetic workload cannot predict adoption gains. +Initialization events record wall time and supported SDK CPU/heap observations. +SDK CPU phase readings are not valid cross-run benchmarks. Heap snapshots include +host resources and are rounded MiB values; flat readings do not rule out small +leaks. Latency quantiles use nearest rank and retain sample counts. + +The smoke checks stable guest identity, increasing ordinals, build/configure counts, +request-local values, named/default binding reads, duplicate cookies, ordinary +rendered errors, idle reinitialization, supported request/lifetime/memory limits, +progressive chunks, interrupted streams, finite distinct loopback origins, and +exclusion of the terminated guest after an injected panic. Logging tests derive service-scoped +keys from an observed guest service 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. + +## Local evidence and remaining limitations + +Observed with Viceroy 0.17.0: A/B/C guest reuse and initialization distinctions, +custom streaming, duplicate cookies, binding reads, and later named-endpoint log +receipt pass. Configured limit 10 does not guarantee ten callbacks; observed +guests can terminate earlier. The repeated expensive-construction run completed +300 matched probes per variant. Raw run artifacts are intentionally not committed. + +Axum's retained app and overlapping-request isolation pass. Its store files are +isolated under the run directory. + +With Wrangler 4.83.0 and worker-build 0.8.3, sequential retention, binding reads, +same-instance overlap, and duplicate cookies pass in both modes. Verification +found an existing header-conversion bug: repeated values overwrote each other. +The converter now replaces each generated header default once, then appends +additional application values. This focused fix applies to default and retained +entry points; it does not enable retention by default. + +Spin 4.0.0 demonstrates sequential reuse and overlapping requests in the same +retained instance. A run may still select separate instances; the runner reports +that run as unverified. The selected runtime's help is saved with its evidence; +the inspected version exposes no explicit request-reuse or callback-concurrency +control, so the fixture uses its defaults. + +The final all-provider smoke run also passed injected required-binding recovery +in the same retained guest on Cloudflare and Spin. It exited 2 because Spin's +per-request overlap control selected different guests on all three attempts; +retained-mode overlap passed. This is incomplete observation, not an assertion +failure or evidence that the host cannot overlap requests. + +### Matched local measurements + +Both runs below used three repetitions of 100 probes per variant, seed 856, +release builds, and a configured request limit of 10. Standard and custom paths +are separate comparisons. Values are client completion p50 in milliseconds for +reused guests; each cell contains 249 samples. Each variant also has 51 cold +samples (A has 300 cold samples and no reused samples). + +| Construction | Standard B | Standard C | Custom B | Custom C | +| --------------------------------------------------- | ---------: | ---------: | -------: | -------: | +| Cheap (`--construction-rounds 0`) | 0.232 | 0.197 | 0.225 | 0.194 | +| Synthetic expensive (`--construction-rounds 10000`) | 7.698 | 0.208 | 7.667 | 0.204 | + +Evidence directories: `18d5774746f44488-d6d5-0` and +`18d57754ea36d748-e01c-0`. B rebuilt on each callback; C initialized once per +observed guest. The observed guest span was at most six requests despite the +configured limit of ten. Rounded heap snapshots peaked at 2 MiB and showed zero +change over these spans. The synthetic result demonstrates removal of fixture +construction work; it does not predict another application's gain. Small cheap +workload differences and SDK CPU samples are not production performance claims. + +The longer-limit run (`18d57768669e26e8-ebd5-0`) completed 300 probes per variant +with `--max-requests 100` and exited 0. The host still admitted at most six +requests per observed guest. Retained standard and custom samples again peaked +at 2 MiB with zero observed change. Thus the larger configured limit did not +provide a longer per-instance memory observation; long-lived growth remains +unverified in this environment. + +The ceiling is explained by Viceroy 0.17.0's `session.rs`: its +`NEXT_REQ_ACCEPT_MAX` constant permits five subsequent requests after the first. +The installed CLI and local-server configuration expose no override. A larger +SDK limit cannot extend this host ceiling. A patched host would be a separate, +instrumented experiment and would not establish deployed reuse behavior. + +## Failure and measurement evidence + +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. +- 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. +- Invalid HTTP framing rejected by the host before dispatch, distinguished from + the observed adapter conversion failure. +- A response with headers and partial body followed by stream interruption and a + correlated guest error. Connection failure alone cannot satisfy this check. +- Progressive 256-KiB streams, correlated post-send work, repeated and distinct + loopback origins, and an elapsed lifetime limit during initialization. + +`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 +initialization, while the custom callback sample occurs before it. + +Each run saves its lockfile, configuration, compiler/runtime identity, and binary +fingerprints. Fingerprints identify artifacts; they are not security checksums. +A larger, still bounded observation run can use: + +```sh +./scripts/smoke_test_reusable_app.sh --adapter fastly --suite benchmark \ + --requests 300 --repetitions 1 --max-requests 100 --require-runtime +``` + +`--max-requests` defaults to 10 and accepts 1–1000. Host eviction can shorten any +observed guest lifetime. Rounded MiB snapshots and a short plateau cannot prove +absence of small leaks or bounded growth for an arbitrary application. + +## Evidence requiring a different environment or API + +These are explicitly unverified, not assertions counted as passed: + +| Case | Available evidence / limitation | +| -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Optional runtime-store open fails, then recovers in the same guest | Production host tests inject degraded then successful configuration snapshots; actual required-registry recovery is exercised above. The fixed optional store has no transient-open-failure control in the inspected local runtime. | +| `EdgeError` itself fails to render | The current renderer builds fixed valid statuses/headers from JSON values. Its fallible signature remains preserved, but there is no public input or injection hook that triggers this failure. OOM/traps would not demonstrate a returned rendering error. | +| Unavailable heap hostcall | The installed runtime supplies it. Preflight and fallback are implemented; no documented fault-injection control was found to demonstrate unavailable-hostcall behavior. | +| 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. | + +No deployment is performed by these fixtures. + +## Persistent-state audit + +Core's production recursion depth is thread-local and guarded by +`SecretFieldsRecursionGuard`. Added 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; their eviction tests +already cover repeated names. Suppression persists with reuse, so warning counts +must not be interpreted as failure counts. No request data belongs in these caches. + +Restore the existing entry point and remove the concrete retained owner to roll +back. The default A fixture exercises the original path; limit one alone is not a +replacement for that rollback test. diff --git a/tests/fixtures/reusable-app/crates/fixture-axum/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-axum/Cargo.toml new file mode 100644 index 00000000..cc7c39d6 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-axum/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "fixture-axum" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +fixture-core.workspace = true +edgezero-core.workspace = true +edgezero-adapter-axum = { workspace = true, features = ["axum"] } +anyhow = "1" diff --git a/tests/fixtures/reusable-app/crates/fixture-axum/src/main.rs b/tests/fixtures/reusable-app/crates/fixture-axum/src/main.rs new file mode 100644 index 00000000..a1da0b30 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-axum/src/main.rs @@ -0,0 +1,3 @@ +fn main() -> anyhow::Result<()> { + edgezero_adapter_axum::dev_server::run_app::() +} diff --git a/tests/fixtures/reusable-app/crates/fixture-cloudflare/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-cloudflare/Cargo.toml new file mode 100644 index 00000000..73092584 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-cloudflare/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "fixture-cloudflare" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +serde_json.workspace = true +fixture-core.workspace = true +edgezero-core.workspace = true +edgezero-adapter-cloudflare.workspace = true +worker = "0.8" + +[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 new file mode 100644 index 00000000..e1579fa3 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-cloudflare/src/lib.rs @@ -0,0 +1,54 @@ +use edgezero_core::app::{App, Hooks}; +use fixture_core::{FixtureApp, OtherApp}; +use std::sync::OnceLock; +use worker::{Context, Env, Request, Response, event}; +static APP: OnceLock = OnceLock::new(); +static OTHER_APP: OnceLock = OnceLock::new(); +#[event(fetch)] +async fn fetch(req: Request, env: Env, ctx: Context) -> worker::Result { + let retained = env + .var("FIXTURE_MODE") + .map(|v| v.to_string() == "retained") + .unwrap_or(false); + if retained && req.path() == "/binding-failure" { + let mut stores = FixtureApp::stores(); + stores.kv = Some(edgezero_core::app::StoreMetadata { + default: "missing_fixture_kv", + ids: &["missing_fixture_kv"], + }); + let result = edgezero_adapter_cloudflare::dispatch_app( + APP.get_or_init(FixtureApp::build_app), + stores, + req, + env, + ctx, + ) + .await; + return match result { + Err(_) => Response::from_json(&serde_json::json!({"instance":fixture_core::instance_id(),"source":"injected_required_binding"})).map(|r|r.with_status(503)), + Ok(response) => Ok(response), + }; + } + if req.path() == "/other" { + return edgezero_adapter_cloudflare::dispatch_app( + OTHER_APP.get_or_init(OtherApp::build_app), + OtherApp::stores(), + req, + env, + ctx, + ) + .await; + } + if retained { + edgezero_adapter_cloudflare::dispatch_app( + APP.get_or_init(FixtureApp::build_app), + FixtureApp::stores(), + req, + env, + ctx, + ) + .await + } else { + edgezero_adapter_cloudflare::run_app::(req, env, ctx).await + } +} diff --git a/tests/fixtures/reusable-app/crates/fixture-cloudflare/wrangler.toml b/tests/fixtures/reusable-app/crates/fixture-cloudflare/wrangler.toml new file mode 100644 index 00000000..12957af1 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-cloudflare/wrangler.toml @@ -0,0 +1,15 @@ +name = "reusable-app-fixture" +main = "build/worker/shim.mjs" +compatibility_date = "2026-04-22" +[build] +command = "worker-build --release . -- --locked" +[vars] +FIXTURE_MODE = "retained" +fixture_marker = "fixture-only-value" + +[[kv_namespaces]] +binding = "fixture_config" +id = "00000000000000000000000000000001" +[[kv_namespaces]] +binding = "fixture_kv" +id = "00000000000000000000000000000002" diff --git a/tests/fixtures/reusable-app/crates/fixture-core/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-core/Cargo.toml new file mode 100644 index 00000000..200b2a1e --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "fixture-core" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +edgezero-core.workspace = true +futures.workspace = true +serde_json.workspace = true +async-trait = "0.1" diff --git a/tests/fixtures/reusable-app/crates/fixture-core/src/lib.rs b/tests/fixtures/reusable-app/crates/fixture-core/src/lib.rs new file mode 100644 index 00000000..db6f316e --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-core/src/lib.rs @@ -0,0 +1,347 @@ +#[cfg(test)] +mod tests { + use super::*; + use edgezero_core::app::Hooks; + use edgezero_core::body::Body; + use edgezero_core::http::request_builder; + use futures::executor::block_on; + + #[test] + fn retained_probe_echoes_each_request_and_preserves_cookies() { + let app = FixtureApp::build_app(); + for (index, token) in ["one", "two"].into_iter().enumerate() { + let req = request_builder() + .uri(format!("/probe/{token}")) + .header("x-request-token", token) + .body(Body::from(token)) + .unwrap(); + let response = block_on(app.router().oneshot(req)).unwrap(); + let value: serde_json::Value = + serde_json::from_slice(response.body().as_bytes().unwrap()).unwrap(); + assert_eq!(value["token"], token); + assert_eq!(value["path"], token); + assert_eq!(value["body"], token); + assert_eq!(value["shared"], index + 1); + } + let req = request_builder() + .uri("/cookies") + .body(Body::empty()) + .unwrap(); + let response = block_on(app.router().oneshot(req)).unwrap(); + assert_eq!(response.headers().get_all("set-cookie").iter().count(), 2); + } + + #[test] + fn rejects_remote_origins_and_keeps_concrete_apps_separate() { + let app = FixtureApp::build_app(); + let other = OtherApp::build_app(); + let req = request_builder() + .uri("/origin/x") + .header("x-fixture-backend", "https://example.com/") + .body(Body::empty()) + .unwrap(); + let response = block_on(app.router().oneshot(req)).unwrap(); + assert_eq!(response.status().as_u16(), 400); + let req = request_builder().uri("/other").body(Body::empty()).unwrap(); + let response = block_on(other.router().oneshot(req)).unwrap(); + assert_eq!(response.body().as_bytes().unwrap(), b"other-app"); + } +} + +use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; +use edgezero_core::http::{Method, Response, response_builder}; +use edgezero_core::proxy::ProxyRequest; +use edgezero_core::router::RouterService; +use edgezero_core::{action, body::Body, context::RequestContext, error::EdgeError}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, OnceLock}; + +static INSTANCE: OnceLock = OnceLock::new(); +static BUILDS: AtomicUsize = AtomicUsize::new(0); +static CONFIGURES: AtomicUsize = AtomicUsize::new(0); +static ORDINAL: AtomicUsize = AtomicUsize::new(0); +static INFLIGHT: AtomicUsize = AtomicUsize::new(0); +static MAX_INFLIGHT: AtomicUsize = AtomicUsize::new(0); + +pub fn instance_id() -> Option<&'static str> { + INSTANCE.get().map(String::as_str) +} + +/// Carry a fixture-owned lifetime observer through request and response conversion. +#[derive(Clone)] +pub struct ResponseLifetime( + pub Arc, + pub Arc, +); + +/// Request-specific finalization data produced inside the router. +#[derive(Clone)] +pub struct Finalize(pub String); + +#[derive(Clone)] +pub struct Observation { + pub instance: String, + pub ordinal: usize, + pub correlation: String, + pub native_id: bool, +} + +pub fn observe(candidate: &str, correlation: Option<&str>) -> Observation { + Observation { + instance: INSTANCE.get_or_init(|| candidate.to_owned()).clone(), + ordinal: ORDINAL.fetch_add(1, Ordering::SeqCst) + 1, + correlation: correlation.unwrap_or(candidate).to_owned(), + native_id: correlation.is_some(), + } +} + +pub struct FixtureApp; +pub struct OtherApp; + +impl Hooks for FixtureApp { + fn stores() -> StoresMetadata { + StoresMetadata { + config: Some(StoreMetadata { + default: "fixture_config", + ids: &["fixture_config"], + }), + kv: Some(StoreMetadata { + default: "fixture_kv", + ids: &["fixture_kv"], + }), + secrets: Some(StoreMetadata { + default: "fixture_secrets", + ids: &["fixture_secrets"], + }), + } + } + fn build_app() -> App { + BUILDS.fetch_add(1, Ordering::SeqCst); + let mut app = App::new(Self::routes()); + Self::configure(&mut app); + app + } + fn configure(app: &mut App) { + CONFIGURES.fetch_add(1, Ordering::SeqCst); + app.set_name("fixture"); + } + fn routes() -> RouterService { + RouterService::builder() + .middleware(ObserveRequest) + .with_state(Arc::new(AtomicUsize::new(0))) + .get("/probe/{id}", probe) + .post("/probe/{id}", probe) + .get("/bindings", bindings) + .get("/cookies", cookies) + .get("/rendered-error", rendered_error) + .get("/stream", backend) + .get("/stream-error", backend) + .get("/overlap/{id}", overlap) + .get("/origin/{id}", backend) + .build() + } +} +impl Hooks for OtherApp { + fn routes() -> RouterService { + RouterService::builder().get("/other", other).build() + } +} + +#[action] +async fn other(_ctx: RequestContext) -> Result { + Ok("other-app".into()) +} + +fn record(ctx: &RequestContext) -> serde_json::Value { + let req = ctx.request(); + let token = req + .headers() + .get("x-request-token") + .and_then(|v| v.to_str().ok()) + .unwrap_or("missing"); + let observation = req + .extensions() + .get::() + .cloned() + .unwrap_or_else(|| observe(token, None)); + let shared = req + .extensions() + .get::>() + .unwrap() + .fetch_add(1, Ordering::SeqCst) + + 1; + serde_json::json!({ + "instance": observation.instance, "ordinal": observation.ordinal, + "correlation": observation.correlation, "native_id": observation.native_id, + "builds": BUILDS.load(Ordering::SeqCst), "configures": CONFIGURES.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()), + "shared": shared, "max_inflight": MAX_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()) +} + +#[action] +async fn cookies(_ctx: RequestContext) -> Result { + let mut response = response_builder().body(Body::from("cookies")).unwrap(); + response + .headers_mut() + .append("set-cookie", "first=1; Path=/".parse().unwrap()); + response + .headers_mut() + .append("set-cookie", "second=2; Path=/".parse().unwrap()); + Ok(response) +} + +#[action] +async fn rendered_error(_ctx: RequestContext) -> Result { + Err(EdgeError::bad_request("fixture error")) +} + +async fn fetch_backend(ctx: &RequestContext) -> Result { + let uri = ctx + .request() + .headers() + .get("x-fixture-backend") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| EdgeError::bad_request("missing fixture backend"))?; + let uri: edgezero_core::http::Uri = uri + .parse() + .map_err(|_| EdgeError::bad_request("invalid backend URI"))?; + // Fixtures accept only harness-controlled local origins, never arbitrary Internet targets. + if uri.scheme_str() != Some("http") || !matches!(uri.host(), Some("127.0.0.1" | "localhost")) { + return Err(EdgeError::bad_request("backend must be loopback HTTP")); + } + ctx.proxy_handle() + .ok_or_else(|| EdgeError::bad_request("missing proxy"))? + .forward(ProxyRequest::new(Method::GET, uri)) + .await +} + +#[action] +async fn backend(ctx: RequestContext) -> Result { + fetch_backend(&ctx).await +} + +struct InFlight; +impl Drop for InFlight { + fn drop(&mut self) { + INFLIGHT.fetch_sub(1, Ordering::SeqCst); + } +} +#[action] +async fn overlap(ctx: RequestContext) -> Result { + let count = INFLIGHT.fetch_add(1, Ordering::SeqCst) + 1; + let _guard = InFlight; + MAX_INFLIGHT.fetch_max(count, Ordering::SeqCst); + let before = record(&ctx); + let _response = fetch_backend(&ctx).await?; + let mut after = record(&ctx); + after["before"] = before; + Ok(after.to_string()) +} + +#[action] +async fn bindings(ctx: RequestContext) -> Result { + let config = ctx + .config_store("fixture_config") + .ok_or_else(|| EdgeError::bad_request("config handle absent"))?; + let marker = config.get("marker").await.map_err(EdgeError::internal)?; + let default_marker = ctx + .config_store_default() + .unwrap() + .get("marker") + .await + .map_err(EdgeError::internal)?; + let kv = ctx + .kv_store("fixture_kv") + .ok_or_else(|| EdgeError::bad_request("KV handle absent"))?; + kv.put("fixture-marker", &"local-fixture") + .await + .map_err(EdgeError::internal)?; + let kv_marker: Option = ctx + .kv_store_default() + .unwrap() + .get("fixture-marker") + .await + .map_err(EdgeError::internal)?; + let secret = ctx + .secret_store("fixture_secrets") + .ok_or_else(|| EdgeError::bad_request("secret handle absent"))?; + let named_secret = secret + .get_bytes("fixture_marker") + .await + .map_err(EdgeError::internal)?; + let default_secret = ctx + .secret_store_default() + .unwrap() + .get_bytes("fixture_marker") + .await + .map_err(EdgeError::internal)?; + Ok(serde_json::json!({ + "config": marker.as_deref() == Some("local-fixture") && marker == default_marker, + "kv": kv_marker.as_deref() == Some("local-fixture"), + "secrets": named_secret.is_some() && named_secret == default_secret, + "unknown": ctx.kv_store("unknown").is_some(), + }) + .to_string()) +} + +struct ObserveRequest; +#[async_trait::async_trait(?Send)] +impl edgezero_core::middleware::Middleware for ObserveRequest { + async fn handle( + &self, + mut ctx: RequestContext, + next: edgezero_core::middleware::Next<'_>, + ) -> Result { + if ctx.request().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); + } + let lifetime = ctx + .request() + .extensions() + .get::() + .cloned(); + let finalization = Finalize( + ctx.request() + .headers() + .get("x-request-token") + .and_then(|value| value.to_str().ok()) + .unwrap_or("missing") + .to_owned(), + ); + let mut response = next.run(ctx).await?; + response.extensions_mut().insert(finalization); + if let Some(lifetime) = lifetime { + lifetime.1.store(true, Ordering::SeqCst); + response.extensions_mut().insert(lifetime); + } + Ok(response) + } +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-fastly/Cargo.toml new file mode 100644 index 00000000..ae10fcaa --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "fixture-fastly" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +edgezero-core.workspace = true +edgezero-adapter-fastly.workspace = true +fixture-core.workspace = true +fastly.workspace = true +futures.workspace = true +serde_json.workspace = true +log.workspace = true + +[[bin]] +name = "fixture-fastly-single-request" +path = "src/bin/single_request.rs" + +[[bin]] +name = "fixture-fastly-rebuild-per-request" +path = "src/bin/rebuild_per_request.rs" + +[[bin]] +name = "fixture-fastly-retained-app" +path = "src/bin/retained_app.rs" + +[[bin]] +name = "fixture-fastly-custom-single-request" +path = "src/bin/custom_single_request.rs" + +[[bin]] +name = "fixture-fastly-custom-rebuild-per-request" +path = "src/bin/custom_rebuild_per_request.rs" + +[[bin]] +name = "fixture-fastly-custom-retained-app" +path = "src/bin/custom_retained_app.rs" + +[[bin]] +name = "fixture-fastly-logger-negative" +path = "src/bin/logger_negative.rs" + +[[bin]] +name = "fixture-fastly-limit-requests" +path = "src/bin/limit_requests.rs" + +[[bin]] +name = "fixture-fastly-limit-lifetime" +path = "src/bin/limit_lifetime.rs" + +[[bin]] +name = "fixture-fastly-limit-memory" +path = "src/bin/limit_memory.rs" + +[[bin]] +name = "fixture-fastly-faults" +path = "src/bin/faults.rs" diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/fastly.toml b/tests/fixtures/reusable-app/crates/fixture-fastly/fastly.toml new file mode 100644 index 00000000..7165a154 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/fastly.toml @@ -0,0 +1,18 @@ +manifest_version = 3 +name = "reusable-app-fixture" +description = "Local lifecycle verification" +language = "rust" +[local_server] +[local_server.backends] + +[local_server.config_stores.fixture_config] +format = "inline-toml" +[local_server.config_stores.fixture_config.contents] +marker = "local-fixture" +[[local_server.kv_stores.fixture_kv]] +key = "__init__" +data = "" + +[[local_server.secret_stores.fixture_secrets]] +key = "fixture_marker" +data = "fixture-only-value" diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_rebuild_per_request.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_rebuild_per_request.rs new file mode 100644 index 00000000..068345d7 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_rebuild_per_request.rs @@ -0,0 +1,3 @@ +fn main() -> Result<(), fastly::Error> { + fixture_fastly::custom(true, false) +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_retained_app.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_retained_app.rs new file mode 100644 index 00000000..79745c9e --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_retained_app.rs @@ -0,0 +1,3 @@ +fn main() -> Result<(), fastly::Error> { + fixture_fastly::custom(true, true) +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_single_request.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_single_request.rs new file mode 100644 index 00000000..6fad8ff8 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/custom_single_request.rs @@ -0,0 +1,3 @@ +fn main() -> Result<(), fastly::Error> { + fixture_fastly::custom(false, false) +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/faults.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/faults.rs new file mode 100644 index 00000000..d39c4030 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/faults.rs @@ -0,0 +1,3 @@ +fn main() -> Result<(), fastly::Error> { + fixture_fastly::faults() +} 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 new file mode 100644 index 00000000..75b76dd0 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_lifetime.rs @@ -0,0 +1,26 @@ +use edgezero_core::app::{App, Hooks, StoresMetadata}; +use edgezero_core::router::RouterService; +use std::time::Duration; + +struct SlowInitialization; +impl Hooks for SlowInitialization { + fn routes() -> RouterService { + fixture_fastly::MeasuredApp::routes() + } + fn stores() -> StoresMetadata { + fixture_fastly::MeasuredApp::stores() + } + fn build_app() -> App { + // A real elapsed deadline, not only the SDK's zero-limit boundary. + std::thread::sleep(Duration::from_millis(100)); + fixture_fastly::MeasuredApp::build_app() + } +} +fn main() -> Result<(), fastly::Error> { + let summary = edgezero_adapter_fastly::serve_app_with_request_extensions::( + fixture_fastly::serving().with_max_lifetime(Duration::from_millis(50)), + fixture_fastly::extend, + ); + assert!(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 new file mode 100644 index 00000000..00d4cdec --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_memory.rs @@ -0,0 +1,29 @@ +fn main() -> Result<(), fastly::Error> { + let serve = { + let heap = fastly::compute_runtime::heap_memory_snapshot_mib(); + println!( + "{}", + serde_json::json!({"event":"heap_preflight", "heap_mib":heap.ok()}) + ); + if heap.is_err() { + return fixture_fastly::finish( + edgezero_adapter_fastly::serve_app_with_request_extensions::< + fixture_fastly::MeasuredApp, + _, + >( + fixture_fastly::serving().with_max_requests(1), + fixture_fastly::extend, + ), + ); + } + // A one-MiB threshold is below this fixture's observed baseline. This + // exercises the supported snapshot/limit branch without unbounded allocation. + fixture_fastly::serving().with_max_memory(1) + }; + fixture_fastly::finish( + edgezero_adapter_fastly::serve_app_with_request_extensions::( + serve, + fixture_fastly::extend, + ), + ) +} 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 new file mode 100644 index 00000000..8ce7b5b8 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/limit_requests.rs @@ -0,0 +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, + ), + ) +} 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 new file mode 100644 index 00000000..a0cee0de --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/logger_negative.rs @@ -0,0 +1,11 @@ +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); + }, + ) + })) +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/rebuild_per_request.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/rebuild_per_request.rs new file mode 100644 index 00000000..f52a3f10 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/rebuild_per_request.rs @@ -0,0 +1,3 @@ +fn main() -> Result<(), fastly::Error> { + fixture_fastly::rebuilt() +} 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 new file mode 100644 index 00000000..16381ab5 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/retained_app.rs @@ -0,0 +1,8 @@ +fn main() -> Result<(), fastly::Error> { + fixture_fastly::finish( + edgezero_adapter_fastly::serve_app_with_request_extensions::( + fixture_fastly::serving(), + fixture_fastly::extend, + ), + ) +} 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 new file mode 100644 index 00000000..1136a122 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/bin/single_request.rs @@ -0,0 +1,7 @@ +#[fastly::main] +fn main(req: fastly::Request) -> Result { + edgezero_adapter_fastly::run_app_with_request_extensions::( + req, + fixture_fastly::extend, + ) +} diff --git a/tests/fixtures/reusable-app/crates/fixture-fastly/src/lib.rs b/tests/fixtures/reusable-app/crates/fixture-fastly/src/lib.rs new file mode 100644 index 00000000..c39a6dd8 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-fastly/src/lib.rs @@ -0,0 +1,368 @@ +use edgezero_adapter_fastly::{ + FastlyLogging, Serve, ServeSummary, init_logger, runtime_env_config, +}; +use edgezero_core::app::{App, Hooks}; +use edgezero_core::body::Body; +use edgezero_core::http::Extensions; +use fastly::{Error, Request, Response}; +use fixture_core::{FixtureApp, Observation, observe}; +use futures::{StreamExt, executor::block_on}; +use std::io::Write; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +static INITIALIZATIONS: Mutex> = Mutex::new(Vec::new()); +static PANIC_IN_CONSTRUCTOR: AtomicBool = AtomicBool::new(false); + +fn lifecycle(event: &str, observation: &Observation, token: &str) { + println!( + "{}", + serde_json::json!({"event":event,"instance":observation.instance,"ordinal":observation.ordinal,"token":token,"cpu_ms":fastly::compute_runtime::elapsed_vcpu_ms().ok(),"heap_mib":fastly::compute_runtime::heap_memory_snapshot_mib().ok()}) + ); +} +fn initialized(observation: &Observation, token: &str) { + for mut event in INITIALIZATIONS.lock().unwrap().drain(..) { + event["instance"] = observation.instance.clone().into(); + event["ordinal"] = observation.ordinal.into(); + event["token"] = token.into(); + println!("{event}"); + } +} +struct ConversionCompletion { + armed: Arc, + observation: Observation, + token: String, +} +impl Drop for ConversionCompletion { + fn drop(&mut self) { + if self.armed.load(Ordering::SeqCst) { + lifecycle("conversion_completed", &self.observation, &self.token); + } + } +} + +pub fn serving() -> Serve { + Serve::new() + .with_max_requests( + option_env!("FIXTURE_MAX_REQUESTS") + .unwrap_or("10") + .parse() + .unwrap(), + ) + .with_timeout(Duration::from_millis(500)) +} +pub fn observation(req: &Request) -> Observation { + let token = req.get_header_str("x-request-token").unwrap_or("missing"); + let value = observe(token, req.get_client_request_id()); + println!( + "{}", + serde_json::json!({"event":"request_start", "instance":value.instance,"token":token, + "service_id":fastly::compute_runtime::service_id(),"ordinal":value.ordinal,"correlation":value.correlation,"native_id":value.native_id, + "cpu_ms":fastly::compute_runtime::elapsed_vcpu_ms().ok(), + "heap_mib":fastly::compute_runtime::heap_memory_snapshot_mib().ok()}) + ); + value +} +pub fn extend(req: &Request, extensions: &mut Extensions) { + let value = observation(req); + let token = req.get_header_str("x-request-token").unwrap_or("missing"); + initialized(&value, token); + let armed = Arc::new(AtomicBool::new(false)); + extensions.insert(fixture_core::ResponseLifetime( + Arc::new(ConversionCompletion { + armed: armed.clone(), + observation: value.clone(), + token: token.to_owned(), + }), + armed, + )); + log::info!("fixture request {}", value.correlation); + extensions.insert(value); +} +pub fn finish(summary: ServeSummary) -> Result<(), Error> { + println!( + "{}", + serde_json::json!({"event":"sdk_summary", "instance":fixture_core::instance_id(), "attempted":summary.requests(), + "handler_wall_ns":summary.time_handler().as_nanos(), "wait_wall_ns":summary.time_waited().as_nanos()}) + ); + summary.into_result() +} +pub fn rebuilt() -> Result<(), Error> { + let mut initialized = false; + finish(serving().run(move |req| -> Result { + let stores = MeasuredApp::stores(); + let env = runtime_env_config(stores); + if !initialized { + let logging = FastlyLogging::from(&env); + if logging.use_fastly_logger { + init_logger( + logging.endpoint.as_deref().unwrap(), + logging.level, + logging.echo_stdout, + )?; + } + initialized = true; + } + let app = MeasuredApp::build_app(); + edgezero_adapter_fastly::request::dispatch_with_registries(&app, req, stores, &env, extend) + })) +} + +fn custom_dispatch( + mut req: Request, + sandbox: &mut edgezero_adapter_fastly::lifecycle::Sandbox, + reuse_app: bool, +) -> Result<(), Error> { + let token = req + .get_header_str("x-request-token") + .unwrap_or("missing") + .to_owned(); + let observation = observation(&req); + assert_eq!(sandbox.requests(), observation.ordinal as u64); + if req.get_path() == "/panic" { + panic!("injected callback panic"); + } + if req.get_path() == "/health" { + Response::from_status(200) + .with_header( + "x-fixture-attempts", + sandbox.initialization_attempts().to_string(), + ) + .with_body_json(&serde_json::json!({"instance":observation.instance, + "ordinal":observation.ordinal,"retained":sandbox.state().is_some()}))? + .send_to_client(); + return Ok(()); + } + let post_send = req + .get_header_str("x-fixture-backend") + .map(|uri| uri.replace("/chunks", "/post-send")); + req.set_header("x-fixture-mutated", "true"); + if req.get_path() == "/constructor-panic" { + PANIC_IN_CONSTRUCTOR.store(true, Ordering::SeqCst); + } + // Arm B deliberately scopes application state to the current callback. + // The panic fixture also needs a fresh constructor even after initialization. + let mut per_request = edgezero_adapter_fastly::lifecycle::Sandbox::default(); + let state = if reuse_app && req.get_path() != "/constructor-panic" { + 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()) + } + }); + if result.is_err() { + Response::from_status(503) + .with_header( + "x-fixture-attempts", + state.initialization_attempts().to_string(), + ) + .with_body_json(&serde_json::json!({"instance":observation.instance, + "ordinal":observation.ordinal,"retained":state.state().is_some()}))? + .send_to_client(); + return Ok(()); + } + initialized(&observation, &token); + let mut core = edgezero_adapter_fastly::request::into_core_request(req)?; + core.extensions_mut().insert(observation.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(), + ); + 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", &observation, &token); + } + Body::Stream(mut stream) => { + let mut writer = native.stream_to_client(); + lifecycle("response_committed", &observation, &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()) { + println!( + "{}", + serde_json::json!({"event":"post_commit_error","instance":observation.instance,"ordinal":observation.ordinal,"token":token,"error":error.to_string()}) + ); + drop(writer); + lifecycle("guest_completed", &observation, &token); + return Ok(()); + } + } + Err(error) => { + println!( + "{}", + serde_json::json!({"event":"post_commit_error","instance":observation.instance,"ordinal":observation.ordinal,"token":token,"error":error.to_string()}) + ); + drop(writer); + lifecycle("guest_completed", &observation, &token); + return Ok(()); + } + } + } + if let Err(error) = writer.finish() { + println!( + "{}", + serde_json::json!({"event":"post_commit_error","instance":observation.instance,"ordinal":observation.ordinal,"token":token,"error":error.to_string()}) + ); + } + } + } + if let (Some(uri), Some(proxy)) = (post_send, proxy) { + let Ok(uri) = uri.parse::() else { + return Ok(()); + }; + if uri.host() == Some("127.0.0.1") && uri.scheme_str() == Some("http") { + 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}"), + } + } + } + println!( + "{}", + serde_json::json!({"event":"guest_completed", "instance":observation.instance,"token":token, + "ordinal":observation.ordinal,"cpu_ms":fastly::compute_runtime::elapsed_vcpu_ms().ok(), + "heap_mib":fastly::compute_runtime::heap_memory_snapshot_mib().ok()}) + ); + Ok(()) +} +pub fn custom(reuse_sandbox: bool, reuse_app: bool) -> Result<(), Error> { + if reuse_sandbox { + finish(edgezero_adapter_fastly::lifecycle::serve_custom( + serving(), + move |req, state| custom_dispatch(req, state, reuse_app), + )) + } else { + edgezero_adapter_fastly::lifecycle::run_custom(Request::from_client(), |req, state| { + custom_dispatch(req, state, false) + }) + } +} + +pub struct MeasuredApp; +impl Hooks for MeasuredApp { + fn routes() -> edgezero_core::router::RouterService { + FixtureApp::routes() + } + fn stores() -> edgezero_core::app::StoresMetadata { + FixtureApp::stores() + } + fn build_app() -> App { + 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"}) + ); + panic!("injected failure inside MeasuredApp::build_app"); + } + let cpu_before = fastly::compute_runtime::elapsed_vcpu_ms().ok(); + let heap_before = fastly::compute_runtime::heap_memory_snapshot_mib().ok(); + let start = std::time::Instant::now(); + let rounds: usize = option_env!("FIXTURE_BUILD_ROUNDS") + .unwrap_or("0") + .parse() + .unwrap(); + for _ in 0..rounds { + let parsed: serde_json::Value = + serde_json::from_str(r#"{"routes":["one","two"],"cache":{"capacity":128}}"#) + .unwrap(); + std::hint::black_box(parsed); + } + let app = FixtureApp::build_app(); + 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 + } +} + +/// Controlled fixture inputs exercise real adapter boundaries, not provider outages. +pub fn faults() -> Result<(), Error> { + use edgezero_core::env_config::EnvConfig; + use edgezero_core::http::response_builder; + let mut retained = None; + finish(serving().run(move |mut req: Request| -> Result { + let observation = 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(&observation, &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) + } + "/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 env = if path == "/recoverable-store-error" || path == "/terminal-store-error" { + EnvConfig::from_vars([("EDGEZERO__STORES__KV__FIXTURE_KV__NAME", "missing_fixture_store")]) + } else { runtime_env_config(MeasuredApp::stores()) }; + edgezero_adapter_fastly::request::dispatch_with_registries(app, req, MeasuredApp::stores(), &env, |_, extensions| { extensions.insert(observation.clone()); }) + } + }; + match result { + Ok(response) => { lifecycle("conversion_completed", &observation, &token); Ok(response) } + Err(error) => { + println!("{}", serde_json::json!({"event":"adapter_error","instance":observation.instance,"ordinal":observation.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", &observation, &token); + Err(error) + } + } + } + })) +} diff --git a/tests/fixtures/reusable-app/crates/fixture-harness/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-harness/Cargo.toml new file mode 100644 index 00000000..c3556a2f --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-harness/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "fixture-harness" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +serde_json.workspace = true diff --git a/tests/fixtures/reusable-app/crates/fixture-harness/src/evidence.rs b/tests/fixtures/reusable-app/crates/fixture-harness/src/evidence.rs new file mode 100644 index 00000000..f751b00b --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-harness/src/evidence.rs @@ -0,0 +1,585 @@ +use serde_json::{Value, json}; +use std::collections::{BTreeMap, HashMap, HashSet}; + +pub type Result = std::result::Result>; +pub fn require(condition: bool, message: impl Into) -> Result<()> { + if condition { + Ok(()) + } else { + Err(message.into().into()) + } +} +pub fn validate_logging(guests: &[Value], delivered: &[String]) -> Result<&'static str> { + let mut seen = HashMap::new(); + let mut reused = false; + for guest in guests { + if let Some(previous) = seen.insert(guest["instance"].to_string(), guest) { + require( + guest["ordinal"].as_u64() > previous["ordinal"].as_u64(), + "logging ordinal did not increase", + )?; + require( + guest["correlation"] != previous["correlation"], + "logging correlation repeated", + )?; + reused = true; + } + } + if !reused { + return Ok("unverified"); + } + require( + guests.iter().all(|g| { + delivered + .iter() + .any(|d| g["correlation"].as_str() == Some(d)) + }), + "missing correlated endpoint receipt", + )?; + Ok("pass") +} +pub fn validate_negative_logging(replies: &[Value], starts: &[Value]) -> Result<&'static str> { + let guests: HashMap<_, _> = starts + .iter() + .map(|v| (v["token"].to_string(), v["instance"].to_string())) + .collect(); + let mut seen = HashSet::new(); + let mut reused = false; + for reply in replies { + let guest = guests + .get(&reply["token"].to_string()) + .ok_or("missing negative logger request_start")?; + let repeated = !seen.insert(guest); + require( + reply["status"] == if repeated { 500 } else { 200 }, + "unexpected negative logger status", + )?; + reused |= repeated; + } + Ok(if reused { "pass" } else { "unverified" }) +} +pub fn validate_post_send(token: &str, receipts: &[String], events: &[Value]) -> Result<()> { + require( + receipts.contains(&format!("/post-send?token={token}")), + "missing post-send backend receipt", + )?; + require( + events + .iter() + .any(|e| e["event"] == "guest_completed" && e["token"] == token), + "missing guest completion", + ) +} +pub fn validate_limit_summaries(events: &[Value]) -> Result<()> { + let mut attempts = HashMap::new(); + for event in events.iter().filter(|e| e["event"] == "request_start") { + *attempts + .entry(event["instance"].to_string()) + .or_insert(0u64) += 1; + } + let summaries: Vec<_> = events + .iter() + .filter(|e| e["event"] == "sdk_summary") + .collect(); + require(!attempts.is_empty(), "missing attempts")?; + require( + summaries.len() == attempts.len(), + "missing or duplicate limit summaries", + )?; + let mut seen = HashSet::new(); + for summary in summaries { + let instance = summary["instance"].to_string(); + require(seen.insert(instance.clone()), "duplicate summary instance")?; + require( + attempts.get(&instance) == Some(&1) && summary["attempted"] == 1, + "limit summary attempts do not match", + )?; + } + Ok(()) +} +pub fn validate_overlap(left: &Value, right: &Value) -> Result<()> { + require( + left["instance"] == right["instance"], + "different guests do not prove overlap", + )?; + require( + left["max_inflight"] + .as_u64() + .unwrap_or(0) + .min(right["max_inflight"].as_u64().unwrap_or(0)) + >= 2, + "callbacks did not overlap", + ) +} +#[cfg(test)] +pub fn metric_delta(before: Option, after: Option) -> Result> { + match (before, after) { + (Some(a), Some(b)) => Ok(Some(b.checked_sub(a).ok_or("negative metric delta")?)), + _ => Ok(None), + } +} +pub fn cookie_values(headers: &Value) -> Vec<&str> { + headers + .as_array() + .into_iter() + .flatten() + .filter(|h| { + h[0].as_str() + .is_some_and(|s| s.eq_ignore_ascii_case("set-cookie")) + }) + .filter_map(|h| h[1].as_str()) + .collect() +} +pub fn attempt_counts(events: &[Value]) -> (usize, usize) { + ( + events + .iter() + .filter(|e| e["event"] == "request_start") + .count(), + events + .iter() + .filter(|e| e["event"] == "client_completed") + .count(), + ) +} +fn quantiles(mut values: Vec) -> Value { + values.sort_unstable(); + let mut result = json!({}); + for p in [50usize, 95, 99] { + result[format!("p{p}")] = json!(values[(p * values.len()).div_ceil(100).saturating_sub(1)]); + } + result +} +fn phase_delta(before: Option, after: Option) -> Value { + match (before, after) { + (Some(a), Some(b)) if a >= 0.0 && b >= a => { + json!({"status":"observed", "value":b-a}) + } + (Some(_), Some(_)) => json!({"status":"invalid", "value":null}), + _ => json!({"status":"unknown", "value":null}), + } +} +fn variant(record: &Value) -> String { + record["variant"] + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| { + format!( + "{}:{}", + record["adapter"].as_str().unwrap_or("unknown"), + record["mode"].as_str().unwrap_or("unknown") + ) + }) +} +fn memory_analysis(events: &[&Value], phase: &str) -> Value { + let snapshots = events + .iter() + .flat_map(|e| { + ["heap_mib", "heap_before_mib", "heap_after_mib"] + .into_iter() + .filter_map(|key| e[key].as_f64().filter(|v| *v >= 0.0)) + }) + .collect::>(); + let mut cleanup = BTreeMap::new(); + let mut invalid_cleanup = false; + for event in events.iter().filter(|e| e["event"] == phase) { + match (event["ordinal"].as_u64(), event["heap_mib"].as_f64()) { + (Some(ordinal), Some(heap)) if heap >= 0.0 => { + invalid_cleanup |= cleanup.insert(ordinal, heap).is_some(); + } + _ => invalid_cleanup = true, + } + } + let points = cleanup.into_iter().collect::>(); + let slope = if points.len() >= 2 && !invalid_cleanup { + let n = points.len() as f64; + let x = points.iter().map(|(x, _)| *x as f64).sum::() / n; + let y = points.iter().map(|(_, y)| y).sum::() / n; + Some( + points + .iter() + .map(|(a, b)| (*a as f64 - x) * (b - y)) + .sum::() + / points + .iter() + .map(|(a, _)| (*a as f64 - x).powi(2)) + .sum::(), + ) + } else { + None + }; + let change = if points.len() >= 2 && !invalid_cleanup { + Some(points.last().unwrap().1 - points[0].1) + } else { + None + }; + let mut plateau = json!({"status":"unknown", "window_samples":3}); + if points.len() >= 3 && !invalid_cleanup { + let tail = &points[points.len() - 3..]; + plateau = json!({"status":if tail.iter().all(|(_, heap)| *heap == tail[0].1) { + "observed" } else { "not_observed" }, "window_samples":3, + "first_ordinal":tail[0].0, "last_ordinal":tail[2].0}); + } + json!({"sample_phase":phase,"source":"SDK host-inclusive rounded MiB snapshots", + "scope":"observed request span only; rounded samples cannot establish absence of leaks; not guest linear-memory high-water or host RSS", + "snapshot_samples":snapshots.len(), + "peak_observed_mib":snapshots.into_iter().reduce(f64::max), + "samples":points.len(), "samples_invalid_or_missing":invalid_cleanup, + "samples_by_ordinal":points.iter().map(|(ordinal,heap)| json!({"ordinal":ordinal,"heap_mib":heap})).collect::>(), + "slope_mib_per_ordinal":slope, "slope_method":"least squares over observed phase ordinals", + "change_mib":change, "plateau":plateau}) +} +fn guest_analysis(records: &[Value]) -> Vec { + let mut guests: BTreeMap<(String, String, String), Vec<&Value>> = BTreeMap::new(); + for event in records { + if !event["instance"].is_null() { + guests + .entry(( + variant(event), + event["repetition"].to_string(), + event["instance"].to_string(), + )) + .or_default() + .push(event); + } + } + guests.into_iter().map(|((variant, _, _), events)| { + let count = |name: &str| events.iter().filter(|e| e["event"] == name).count(); + let summaries = events.iter().filter(|e| e["event"] == "sdk_summary").collect::>(); + let attempted = if summaries.len() == 1 { summaries[0]["attempted"].as_u64() } else { None }; + let initializations = events.iter().filter(|e| e["event"] == "initialization") + .map(|e| json!({"ordinal":e["ordinal"],"token":e["token"],"wall_ns":e["wall_ns"], + "cpu_ms":phase_delta(e["cpu_before_ms"].as_f64(),e["cpu_after_ms"].as_f64()), + "heap_before_mib":e["heap_before_mib"],"heap_after_mib":e["heap_after_mib"]})) + .collect::>(); + let mut requests: BTreeMap<(u64,String),Vec<&Value>> = BTreeMap::new(); + for event in &events { + if let (Some(ordinal), Some(token)) = (event["ordinal"].as_u64(),event["token"].as_str()) { + requests.entry((ordinal,token.to_owned())).or_default().push(event); + } + } + let requests = requests.into_iter().map(|((ordinal,token),rows)| { + let phase = |name: &str| { + let matches = rows.iter().filter(|e| e["event"] == name).collect::>(); + if matches.len() == 1 { matches[0]["cpu_ms"].as_f64() } else { None } + }; + json!({"ordinal":ordinal,"token":token, + "cpu_to_conversion_ms":phase_delta(phase("request_start"),phase("conversion_completed")), + "cpu_to_commit_ms":phase_delta(phase("request_start"),phase("response_committed")), + "cpu_after_commit_ms":phase_delta(phase("response_committed"),phase("guest_completed")), + "cpu_request_ms":phase_delta(phase("request_start"),phase("guest_completed")), + "response_committed":if rows.iter().any(|e| e["event"] == "response_committed") {json!(true)} else {Value::Null}, + "guest_completed":if rows.iter().any(|e| e["event"] == "guest_completed") {json!(true)} else {Value::Null}, + "terminal_error":rows.iter().any(|e| e["event"] == "terminal_error")}) + }).collect::>(); + let client_completions = records.iter().filter(|r| r["event"] == "client_completed" + && crate::evidence::variant(r) == variant && r["repetition"] == events[0]["repetition"] + && (r["instance"] == events[0]["instance"] || r["guest"]["instance"] == events[0]["instance"] + || requests.iter().any(|request| r["token"] == request["token"]))).count(); + json!({"variant":variant,"repetition":events[0]["repetition"],"instance":events[0]["instance"], + "request_starts":count("request_start"),"response_commitments":count("response_committed"), + "guest_completions":count("guest_completed"),"terminal_errors":count("terminal_error"), + "client_completions":client_completions,"sdk_summary_records":summaries.len(),"sdk_attempted":attempted, + "attempt_reconciliation":match attempted {Some(n) if n == count("request_start") as u64 => "match", Some(_) => "mismatch", None => "unknown"}, + "initialization_count":initializations.len(),"initialization":initializations,"requests":requests, + "cpu_scope":"SDK samples between named observation points: standard request_start is after initialization; custom request_start is before initialization. Concurrent work may contribute. Not total request CPU or cross-run performance evidence.", + "memory":memory_analysis(&events,"guest_completed"), + "conversion_memory":memory_analysis(&events,"conversion_completed")}) + }).collect() +} +pub fn summarize(records: &[Value]) -> Value { + let mut builds = BTreeMap::new(); + let mut mixed_builds = false; + for record in records.iter().filter(|r| r["event"] == "build") { + let key = (variant(record), record["repetition"].to_string()); + if let Some(previous) = builds.insert(key, record["artifact"]["fnv1a64"].clone()) { + mixed_builds |= previous != record["artifact"]["fnv1a64"]; + } + } + let mut groups: BTreeMap> = BTreeMap::new(); + let mut memory: BTreeMap> = BTreeMap::new(); + for r in records { + if r["event"] == "client_completed" && r.get("guest").is_some() { + let cohort = if r["guest"]["ordinal"] == 1 { + "cold" + } else { + "reused" + }; + let variant = r["variant"].as_str().map(str::to_owned).unwrap_or_else(|| { + format!( + "{}:{}", + r["adapter"].as_str().unwrap_or("unknown"), + r["mode"].as_str().unwrap_or("unknown") + ) + }); + groups + .entry(format!("{variant}:probe:{cohort}")) + .or_default() + .push(r); + } + if let Some(heap) = r["heap_mib"].as_u64() { + memory + .entry(r["variant"].as_str().unwrap_or("unknown").to_owned()) + .or_default() + .push(heap); + } + } + let mut result = json!({"quantile_method":"nearest rank", "variants":{}, "memory_snapshots":{}, "cpu_comparison":"SDK phase readings only; cross-run performance unverified"}); + for (key, rows) in groups { + let values = rows + .iter() + .filter_map(|r| r["complete_ns"].as_u64()) + .collect::>(); + if values.is_empty() { + continue; + } + result["variants"][&key] = + json!({"samples":values.len(), "completion_ns":quantiles(values)}); + let first = rows + .iter() + .filter_map(|r| r["first_byte_ns"].as_u64()) + .collect::>(); + if !first.is_empty() { + result["variants"][&key]["first_byte_ns"] = quantiles(first); + } + } + for (key, values) in memory { + result["memory_snapshots"][key] = json!({"samples":values.len(),"min_mib":values.iter().min(),"max_mib":values.iter().max(),"source":"SDK host-inclusive rounded snapshot"}); + } + result["build_identity"] = json!({"status":if mixed_builds {"invalid"} else if builds.is_empty() {"unknown"} else {"consistent"}}); + let (attempts, completions) = attempt_counts(records); + result["guests"] = json!(guest_analysis(records)); + result["recorded_request_starts"] = json!(attempts); + result["recorded_client_completions"] = json!(completions); + result +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn missing_observation_is_unknown_and_mixed_builds_are_rejected() { + let summary = summarize(&[ + json!({"event":"request_start","variant":"c","instance":"one","ordinal":1,"token":"a"}), + json!({"event":"build","variant":"c","repetition":0,"artifact":{"fnv1a64":"a"}}), + json!({"event":"build","variant":"c","repetition":0,"artifact":{"fnv1a64":"b"}}), + ]); + assert!(summary["guests"][0]["requests"][0]["response_committed"].is_null()); + assert_eq!(summary["build_identity"]["status"], "invalid"); + } + fn measured(event: &str, ordinal: u64, cpu: u64, heap: u64) -> Value { + json!({"event":event,"variant":"C","repetition":1,"instance":"same", + "ordinal":ordinal,"token":format!("t{ordinal}"),"cpu_ms":cpu,"heap_mib":heap}) + } + #[test] + fn per_guest_phases_attempts_and_memory_are_bounded_observations() { + let mut events = vec![ + json!({"event":"initialization","variant":"C","repetition":1, + "instance":"same","ordinal":1,"token":"t1","cpu_before_ms":1,"cpu_after_ms":4, + "heap_before_mib":5,"heap_after_mib":8}), + ]; + for ordinal in 1..=4 { + events.push(measured("request_start", ordinal, ordinal * 10, 8)); + events.push(measured( + "response_committed", + ordinal, + ordinal * 10 + 2, + 10, + )); + events.push(measured("guest_completed", ordinal, ordinal * 10 + 5, 9)); + } + events.push(json!({"event":"sdk_summary","variant":"C","repetition":1,"instance":"same","attempted":5})); + let summary = summarize(&events); + let guest = &summary["guests"][0]; + assert_eq!(guest["request_starts"], 4); + assert_eq!(guest["guest_completions"], 4); + assert_eq!(guest["sdk_attempted"], 5); + assert_eq!(guest["attempt_reconciliation"], "mismatch"); + assert_eq!(guest["initialization"][0]["cpu_ms"]["value"], 3.0); + assert_eq!(guest["requests"][0]["cpu_to_commit_ms"]["value"], 2.0); + assert_eq!(guest["requests"][0]["cpu_after_commit_ms"]["value"], 3.0); + assert_eq!(guest["memory"]["slope_mib_per_ordinal"], 0.0); + assert_eq!(guest["memory"]["peak_observed_mib"], 10.0); + assert_eq!(guest["memory"]["change_mib"], 0.0); + assert_eq!(guest["memory"]["plateau"]["status"], "observed"); + assert_eq!(guest["memory"]["plateau"]["first_ordinal"], 2); + assert_eq!(guest["memory"]["plateau"]["last_ordinal"], 4); + } + #[test] + fn missing_and_decreasing_cpu_are_not_zero_or_success() { + let mut events = vec![ + measured("request_start", 1, 10, 8), + measured("response_committed", 1, 9, 8), + ]; + events.push(measured("terminal_error", 1, 11, 8)); + let summary = summarize(&events); + let guest = &summary["guests"][0]; + assert!(guest["sdk_attempted"].is_null()); + assert_eq!(guest["attempt_reconciliation"], "unknown"); + assert_eq!(guest["terminal_errors"], 1); + assert_eq!(guest["guest_completions"], 0); + assert_eq!( + guest["requests"][0]["cpu_to_commit_ms"]["status"], + "invalid" + ); + assert_eq!( + guest["requests"][0]["cpu_after_commit_ms"]["status"], + "unknown" + ); + assert!(guest["requests"][0]["cpu_to_commit_ms"]["value"].is_null()); + assert_eq!(guest["memory"]["plateau"]["status"], "unknown"); + } + #[test] + fn guest_identity_includes_variant_and_repetition_and_slope_uses_ordinals() { + let mut events = vec![ + measured("guest_completed", 1, 1, 10), + measured("guest_completed", 3, 2, 14), + ]; + let mut other = events[0].clone(); + other["repetition"] = json!(2); + events.push(other.clone()); + other["variant"] = json!("B"); + events.push(other); + let summary = summarize(&events); + let guests = summary["guests"].as_array().unwrap(); + assert_eq!(guests.len(), 3); + let guest = guests + .iter() + .find(|g| g["variant"] == "C" && g["repetition"] == 1) + .unwrap(); + assert_eq!(guest["memory"]["slope_mib_per_ordinal"], 2.0); + assert_eq!(guest["memory"]["change_mib"], 4.0); + assert_eq!(guest["memory"]["plateau"]["status"], "unknown"); + } + #[test] + fn conversion_samples_do_not_claim_guest_cleanup_or_commitment() { + let events = vec![ + measured("conversion_completed", 1, 5, 8), + measured("conversion_completed", 2, 6, 10), + ]; + let summary = summarize(&events); + let guest = &summary["guests"][0]; + assert_eq!( + guest["conversion_memory"]["sample_phase"], + "conversion_completed" + ); + assert_eq!(guest["conversion_memory"]["slope_mib_per_ordinal"], 2.0); + assert_eq!(guest["memory"]["samples"], 0); + assert_eq!(guest["response_commitments"], 0); + assert_eq!(guest["guest_completions"], 0); + assert_eq!(guest["requests"][0]["cpu_request_ms"]["status"], "unknown"); + } + #[test] + fn failed_attempt_is_not_completion() { + assert_eq!( + attempt_counts(&[json!({"event":"request_start"}), json!({"event":"error"})]), + (1, 0) + ); + } + #[test] + fn unavailable_metrics() { + assert_eq!(metric_delta(None, Some(4)).unwrap(), None); + assert!(metric_delta(Some(5), Some(4)).is_err()); + } + #[test] + fn duplicate_cookies() { + assert_eq!( + cookie_values(&json!([["Set-Cookie", "a=1"], ["Set-Cookie", "b=2"]])), + vec!["a=1", "b=2"] + ); + } + #[test] + fn overlap_requires_same_guest_and_concurrency() { + let left = json!({"instance":"a","max_inflight":2}); + for right in [ + json!({"instance":"b","max_inflight":2}), + json!({"instance":"a","max_inflight":1}), + ] { + assert!(validate_overlap(&left, &right).is_err()); + } + validate_overlap(&left, &left).unwrap(); + } + #[test] + fn unmatched_workloads_excluded() { + let s = summarize(&[ + json!({"event":"client_completed","variant":"c","guest":{"ordinal":1},"complete_ns":10}), + json!({"event":"client_completed","variant":"c","guest":{"ordinal":2},"complete_ns":20}), + json!({"event":"client_completed","variant":"c","assertion":"idle","complete_ns":999}), + ]); + assert_eq!(s["variants"]["c:probe:cold"]["completion_ns"]["p50"], 10); + assert_eq!(s["variants"]["c:probe:reused"]["completion_ns"]["p50"], 20); + } + #[test] + fn provider_modes_separate() { + let records=["retained","per-request"].map(|mode|json!({"event":"client_completed","adapter":"spin","mode":mode,"guest":{"ordinal":2},"complete_ns":10})); + assert_eq!( + summarize(&records)["variants"].as_object().unwrap().len(), + 2 + ); + } + #[test] + fn logging_requires_reuse_and_correlated_delivery() { + let cold = (0..3) + .map(|i| json!({"instance":i.to_string(),"ordinal":1,"correlation":i.to_string()})) + .collect::>(); + let delivered = (0..3).map(|i| i.to_string()).collect::>(); + assert_eq!(validate_logging(&cold, &delivered).unwrap(), "unverified"); + let warm = (0..3) + .map(|i| json!({"instance":"same","ordinal":i+1,"correlation":i.to_string()})) + .collect::>(); + assert!(validate_logging(&warm, &["0".into(), "0".into(), "0".into()]).is_err()); + assert_eq!(validate_logging(&warm, &delivered).unwrap(), "pass"); + } + #[test] + fn negative_logger_eviction() { + let mut starts = vec![ + json!({"instance":"one","token":"a"}), + json!({"instance":"two","token":"b"}), + ]; + let mut replies = vec![ + json!({"token":"a","status":200}), + json!({"token":"b","status":200}), + ]; + assert_eq!( + validate_negative_logging(&replies, &starts).unwrap(), + "unverified" + ); + starts[1]["instance"] = json!("one"); + assert!(validate_negative_logging(&replies, &starts).is_err()); + replies[1]["status"] = json!(500); + assert_eq!( + validate_negative_logging(&replies, &starts).unwrap(), + "pass" + ); + } + #[test] + fn post_send_requires_both_receipts() { + assert!(validate_post_send("x", &[], &[]).is_err()); + let receipts = vec!["/post-send?token=x".into()]; + assert!(validate_post_send("x", &receipts, &[]).is_err()); + validate_post_send( + "x", + &receipts, + &[json!({"event":"guest_completed","token":"x"})], + ) + .unwrap(); + } + #[test] + fn limit_summaries_match_attempts() { + let mut events = vec![json!({"event":"request_start","instance":"one"})]; + assert!(validate_limit_summaries(&events).is_err()); + for count in [0, 2] { + events.truncate(1); + events.push(json!({"event":"sdk_summary","instance":"one","attempted":count})); + assert!(validate_limit_summaries(&events).is_err()); + } + events[1]["attempted"] = json!(1); + validate_limit_summaries(&events).unwrap(); + events[1]["instance"] = json!("other"); + assert!(validate_limit_summaries(&events).is_err()); + events[1]["instance"] = json!("one"); + events.push(events[1].clone()); + assert!(validate_limit_summaries(&events).is_err()); + events.push(json!({"event":"request_start","instance":"two"})); + assert!(validate_limit_summaries(&events).is_err()); + } +} diff --git a/tests/fixtures/reusable-app/crates/fixture-harness/src/main.rs b/tests/fixtures/reusable-app/crates/fixture-harness/src/main.rs new file mode 100644 index 00000000..433ffaaf --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-harness/src/main.rs @@ -0,0 +1,314 @@ +mod evidence; +mod net; +mod runners; +use evidence::{Result, require}; +use serde_json::{Value, json}; +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + sync::atomic::{AtomicU64, Ordering}, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +pub struct Args { + adapter: String, + suite: String, + output: Option, + requests: usize, + repetitions: usize, + seed: u64, + construction_rounds: u64, + max_requests: usize, +} +impl Args { + fn parse() -> Result { + let mut a = Self { + adapter: "fastly".into(), + suite: "smoke".into(), + output: None, + requests: 100, + repetitions: 3, + seed: 856, + construction_rounds: 0, + max_requests: 10, + }; + let mut args = std::env::args().skip(1); + while let Some(key) = args.next() { + if key == "--require-runtime" { + continue; + } + if key == "--help" || key == "-h" { + println!( + "fixture-harness [--adapter fastly|cloudflare|spin|axum|all] [--suite smoke|benchmark] [--output EMPTY_DIR] [--requests 100] [--repetitions 3] [--seed 856] [--construction-rounds 0] [--max-requests 10] [--require-runtime]" + ); + std::process::exit(0); + } + let value = args.next().ok_or(format!("missing value for {key}"))?; + match key.as_str() { + "--adapter" => a.adapter = value, + "--suite" => a.suite = value, + "--output" => a.output = Some(value.into()), + "--requests" => a.requests = value.parse()?, + "--repetitions" => a.repetitions = value.parse()?, + "--seed" => a.seed = value.parse()?, + "--construction-rounds" => a.construction_rounds = value.parse()?, + "--max-requests" => a.max_requests = value.parse()?, + _ => return Err(format!("unknown option {key}").into()), + } + } + require( + ["fastly", "cloudflare", "spin", "axum", "all"].contains(&a.adapter.as_str()), + "unknown adapter", + )?; + require( + ["smoke", "benchmark"].contains(&a.suite.as_str()), + "unknown suite", + )?; + require( + a.requests > 0 && a.repetitions > 0, + "requests and repetitions must be positive", + )?; + require( + (1..=1000).contains(&a.max_requests), + "max-requests must be 1..1000", + )?; + Ok(a) + } +} +pub fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("fixture workspace root") +} +pub fn token() -> String { + static NEXT: AtomicU64 = AtomicU64::new(0); + format!( + "{:x}-{:x}-{:x}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + ) +} +pub struct Records { + path: PathBuf, + values: Vec, +} +impl Records { + fn collect_clients(&mut self, fields: Value) -> Result<()> { + for event in net::take_client_events() { + if !self.values.iter().any(|existing| { + existing["event"] == "client_completed" && existing["token"] == event["token"] + }) { + self.push(annotate(event, fields.clone()))?; + } + } + Ok(()) + } + fn push(&mut self, v: Value) -> Result<()> { + if matches!( + v["status"].as_str(), + Some("fail" | "unverified" | "unsupported") + ) { + eprintln!("fixture-harness: {v}"); + } + writeln!( + OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?, + "{v}" + )?; + self.values.push(v); + Ok(()) + } +} +pub fn logs(path: &Path) -> Vec { + fs::read_to_string(path) + .unwrap_or_default() + .lines() + .filter(|l| l.starts_with('{')) + .filter_map(|l| serde_json::from_str(l).ok()) + .collect() +} +pub fn await_evidence(mut check: impl FnMut() -> Result<()>) -> Result<()> { + let start = Instant::now(); + loop { + match check() { + Ok(()) => return Ok(()), + Err(e) if start.elapsed() >= Duration::from_secs(5) => return Err(e), + Err(_) => thread::sleep(Duration::from_millis(50)), + } + } +} +pub fn executable(variable: &str, default: &str) -> Option { + let value = std::env::var(variable).unwrap_or_else(|_| default.into()); + let path = PathBuf::from(&value); + if path.components().count() > 1 { + return path.canonicalize().ok(); + } + std::env::split_paths(&std::env::var_os("PATH")?) + .map(|p| p.join(&value)) + .find(|p| p.is_file()) + .and_then(|p| p.canonicalize().ok()) +} +pub fn checked(command: &mut Command) -> Result<()> { + let status = command.status()?; + require( + status.success(), + format!("command {command:?} failed: {status}"), + ) +} +pub struct Process(Child); +impl Process { + pub fn start(command: &mut Command, log: &Path, port: u16) -> Result { + let file = fs::File::create(log)?; + let mut p = Self( + command + .stdout(Stdio::from(file.try_clone()?)) + .stderr(Stdio::from(file)) + .spawn()?, + ); + let start = Instant::now(); + loop { + if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() { + return Ok(p); + } + require( + p.0.try_wait()?.is_none(), + format!("runtime exited; see {}", log.display()), + )?; + require( + start.elapsed() < Duration::from_secs(45), + "runtime did not listen", + )?; + thread::sleep(Duration::from_millis(50)); + } + } +} +impl Drop for Process { + fn drop(&mut self) { + let _ = Command::new("kill") + .args(["-TERM", &self.0.id().to_string()]) + .status(); + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + if matches!(self.0.try_wait(), Ok(Some(_))) { + return; + } + thread::sleep(Duration::from_millis(50)); + } + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} +pub fn req(port: u16, path: &str) -> Result { + net::request(port, path, &token(), None, None) +} +pub fn guest(reply: &Value) -> Result { + require( + reply["status"] == 200, + format!("unexpected response: {reply}"), + )?; + Ok(serde_json::from_str( + reply["body"].as_str().ok_or("missing body")?, + )?) +} +pub fn annotate(mut value: Value, fields: Value) -> Value { + value + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + value +} +pub fn artifact(path: &Path) -> Result { + let bytes = fs::read(path)?; + let fingerprint = bytes.iter().fold(0xcbf29ce484222325u64, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }); + Ok( + json!({"path":path,"bytes":bytes.len(),"fnv1a64":format!("{fingerprint:016x}"),"purpose":"non-security build identity"}), + ) +} +fn run() -> Result { + let args = Args::parse()?; + let path = args + .output + .clone() + .unwrap_or_else(|| root().join(".runs").join(token())); + if path.exists() { + require( + fs::read_dir(&path)?.next().is_none(), + "output directory must be empty; refusing to overwrite evidence", + )?; + } + fs::create_dir_all(&path)?; + let path = path.canonicalize()?; + fs::copy(root().join("Cargo.lock"), path.join("Cargo.lock"))?; + let mut records = Records { + path: path.join("events.jsonl"), + values: Vec::new(), + }; + records.push(json!({"event":"run","seed":args.seed,"suite":args.suite,"construction_rounds":args.construction_rounds,"max_requests":args.max_requests}))?; + records.push(json!({"event":"build_environment","rustc":String::from_utf8_lossy(&Command::new("rustc").arg("--version").output()?.stdout).trim(),"host_os":std::env::consts::OS,"host_arch":std::env::consts::ARCH,"lockfile":artifact(&path.join("Cargo.lock"))?}))?; + let adapters = if args.adapter == "all" { + vec!["fastly", "cloudflare", "spin", "axum"] + } else { + vec![args.adapter.as_str()] + }; + for adapter in adapters { + let result = if adapter == "fastly" { + runners::fastly(&args, &path, &mut records) + } else { + runners::provider(adapter, &args, &path, &mut records) + }; + if let Err(error) = result { + records.push(json!({"status":"fail","adapter":adapter,"error":error.to_string()}))?; + } + } + let code = if records.values.iter().any(|r| r["status"] == "fail") { + 1 + } else if records + .values + .iter() + .any(|r| r["status"] == "unverified" || r["status"] == "unsupported") + { + 2 + } else { + 0 + }; + let summary = evidence::summarize(&records.values); + let code = if summary["build_identity"]["status"] == "invalid" { + 1 + } else { + code + }; + fs::write( + path.join("summary.json"), + serde_json::to_string_pretty(&summary)?, + )?; + for (key, value) in summary["variants"].as_object().unwrap() { + println!( + "{key}: {} samples, completion p50 {:.3} ms", + value["samples"], + value["completion_ns"]["p50"].as_f64().unwrap_or(0.) / 1_000_000. + ); + } + println!("{}", json!({"exit_code":code,"evidence":path})); + Ok(code) +} +fn main() { + match run() { + Ok(code) => std::process::exit(code), + Err(error) => { + eprintln!("fixture-harness: {error}"); + std::process::exit(1); + } + } +} diff --git a/tests/fixtures/reusable-app/crates/fixture-harness/src/net.rs b/tests/fixtures/reusable-app/crates/fixture-harness/src/net.rs new file mode 100644 index 00000000..c32166ff --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-harness/src/net.rs @@ -0,0 +1,328 @@ +use crate::evidence::{Result, require}; +use serde_json::{Value, json}; +use std::{ + io::{BufRead, BufReader, Read, Write}, + net::{TcpListener, TcpStream}, + sync::{ + Arc, Condvar, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, +}; + +pub type Signal = Arc<(Mutex, Condvar)>; +static CLIENT_EVENTS: Mutex> = Mutex::new(Vec::new()); +pub fn take_client_events() -> Vec { + std::mem::take(&mut *CLIENT_EVENTS.lock().unwrap()) +} + +#[derive(Debug)] +pub struct InterruptedResponse { + pub status: u16, + pub body: Vec, + pub reason: String, +} +impl std::fmt::Display for InterruptedResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "response {} interrupted after {} bytes: {}", + self.status, + self.body.len(), + self.reason + ) + } +} +impl std::error::Error for InterruptedResponse {} +pub fn release(signal: &Signal) { + *signal.0.lock().unwrap() = true; + signal.1.notify_all(); +} +pub struct Backend { + pub port: u16, + pub receipts: Arc>>, + pub release: Signal, + stop: Arc, + worker: Option>, +} +impl Backend { + pub fn start() -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + listener.set_nonblocking(true)?; + let receipts = Arc::new(Mutex::new(Vec::new())); + let release = Arc::new((Mutex::new(false), Condvar::new())); + let barrier = Arc::new((Mutex::new(0usize), Condvar::new())); + let stop = Arc::new(AtomicBool::new(false)); + let (r, s, b, done) = ( + receipts.clone(), + release.clone(), + barrier.clone(), + stop.clone(), + ); + let worker = thread::spawn(move || { + while !done.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => { + let (r, s, b) = (r.clone(), s.clone(), b.clone()); + thread::spawn(move || { + let _ = serve(stream, r, s, b); + }); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)) + } + Err(_) => break, + } + } + }); + Ok(Self { + port, + receipts, + release, + stop, + worker: Some(worker), + }) + } + pub fn url(&self, path: &str) -> String { + format!("http://127.0.0.1:{}{path}", self.port) + } +} +impl Drop for Backend { + fn drop(&mut self) { + release(&self.release); + self.stop.store(true, Ordering::Relaxed); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} +fn serve( + mut stream: TcpStream, + receipts: Arc>>, + signal: Signal, + barrier: Arc<(Mutex, Condvar)>, +) -> Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(10)))?; + let mut reader = BufReader::new(stream.try_clone()?); + let mut line = String::new(); + reader.read_line(&mut line)?; + let path = line + .split_whitespace() + .nth(1) + .ok_or("missing request path")? + .to_owned(); + loop { + line.clear(); + reader.read_line(&mut line)?; + if line == "\r\n" || line.is_empty() { + break; + } + } + receipts.lock().unwrap().push(path.clone()); + if path.starts_with("/barrier") { + let mut count = barrier.0.lock().unwrap(); + *count += 1; + barrier.1.notify_all(); + let (count, _) = barrier + .1 + .wait_timeout_while(count, Duration::from_secs(8), |n| *n < 2) + .map_err(|_| "barrier poisoned")?; + if *count < 2 { + stream.write_all(b"HTTP/1.1 504 Gateway Timeout\r\nContent-Length: 0\r\n\r\n")?; + return Ok(()); + } + } + if path.starts_with("/chunks") { + stream.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n5\r\nfirst\r\n")?; + stream.flush()?; + let (ready, _) = signal + .1 + .wait_timeout_while(signal.0.lock().unwrap(), Duration::from_secs(8), |v| !*v) + .map_err(|_| "release poisoned")?; + if *ready { + if path.contains("large=1") { + let body = vec![b'z'; 256 * 1024]; + write!(stream, "{:x}\r\n", body.len())?; + stream.write_all(&body)?; + stream.write_all(b"\r\n0\r\n\r\n")?; + } else { + stream.write_all(b"6\r\nsecond\r\n0\r\n\r\n")?; + } + } + } else if path.starts_with("/abort") { + stream.write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\npartial", + )?; + } else { + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")?; + } + Ok(()) +} +pub fn port() -> Result { + Ok(TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port()) +} + +pub fn malformed_request(port: u16, token: &str) -> Result { + let mut stream = TcpStream::connect(("127.0.0.1", port))?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + write!( + stream, + "POST /probe/malformed HTTP/1.1\r\nHost: localhost\r\nx-request-token: {token}\r\nContent-Length: invalid\r\nConnection: close\r\n\r\n" + )?; + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line)?; + Ok(line.trim().to_owned()) +} +pub fn request( + port: u16, + path: &str, + token: &str, + backend: Option<&str>, + signal: Option<&Signal>, +) -> Result { + let start = Instant::now(); + let mut stream = TcpStream::connect(("127.0.0.1", port))?; + stream.set_read_timeout(Some(Duration::from_secs(12)))?; + stream.set_write_timeout(Some(Duration::from_secs(12)))?; + let probe = path.starts_with("/probe/"); + let body = if probe { token } else { "" }; + write!( + stream, + "{} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nx-request-token: {token}\r\nContent-Length: {}\r\nConnection: close\r\n", + if probe { "POST" } else { "GET" }, + body.len() + )?; + if let Some(url) = backend { + write!(stream, "x-fixture-backend: {url}\r\n")?; + } + write!(stream, "\r\n{body}")?; + stream.flush()?; + let mut reader = BufReader::new(stream); + 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; + loop { + line.clear(); + require(reader.read_line(&mut line)? > 0, "truncated HTTP headers")?; + if line == "\r\n" { + break; + } + let (name, value) = line + .trim_end() + .split_once(':') + .ok_or("malformed HTTP header")?; + let value = value.trim(); + if name.eq_ignore_ascii_case("content-length") { + length = Some(value.parse::()?); + } + if name.eq_ignore_ascii_case("transfer-encoding") { + chunked = value.to_ascii_lowercase().contains("chunked"); + } + headers.push(json!([name, value])); + } + let header_ns = start.elapsed().as_nanos() as u64; + let mut first_byte_ns = None; + let mut bytes = Vec::new(); + let outcome = (|| -> Result<()> { + let mut read_part = |reader: &mut BufReader, count: usize| -> Result<()> { + if count == 0 { + return Ok(()); + } + let mut first = [0]; + reader.read_exact(&mut first)?; + bytes.push(first[0]); + if first_byte_ns.is_none() { + first_byte_ns = Some(start.elapsed().as_nanos() as u64); + if let Some(signal) = signal { + release(signal); + } + } + let mut remaining = count - 1; + let mut buffer = [0u8; 8192]; + while remaining > 0 { + let size = remaining.min(buffer.len()); + let read = reader.read(&mut buffer[..size])?; + require(read > 0, "truncated HTTP body")?; + bytes.extend_from_slice(&buffer[..read]); + remaining -= read; + } + Ok(()) + }; + if chunked { + loop { + line.clear(); + require(reader.read_line(&mut line)? > 0, "truncated chunk header")?; + let count = usize::from_str_radix(line.trim().split(';').next().unwrap_or(""), 16)?; + if count == 0 { + loop { + line.clear(); + require(reader.read_line(&mut line)? > 0, "truncated chunk trailer")?; + if line == "\r\n" { + break; + } + } + break; + } + read_part(&mut reader, count)?; + let mut crlf = [0; 2]; + reader.read_exact(&mut crlf)?; + require(crlf == *b"\r\n", "invalid chunk terminator")?; + } + } else if let Some(length) = length { + read_part(&mut reader, length)?; + } else { + let mut first = [0]; + if reader.read(&mut first)? != 0 { + bytes.push(first[0]); + first_byte_ns = Some(start.elapsed().as_nanos() as u64); + if let Some(signal) = signal { + release(signal); + } + reader.read_to_end(&mut bytes)?; + } + } + Ok(()) + })(); + if let Err(error) = outcome { + return Err(Box::new(InterruptedResponse { + status, + body: bytes, + reason: error.to_string(), + })); + } + let event = json!({"event":"client_completed","token":token,"status":status,"headers":headers,"body":String::from_utf8(bytes)?,"header_ns":header_ns,"first_byte_ns":first_byte_ns.unwrap_or(header_ns),"complete_ns":start.elapsed().as_nanos() as u64}); + CLIENT_EVENTS.lock().unwrap().push(event.clone()); + Ok(event) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn progressive_body_and_abort() { + let backend = Backend::start().unwrap(); + let result = request(backend.port, "/chunks", "x", None, Some(&backend.release)).unwrap(); + assert_eq!(result["body"], "firstsecond"); + let failure = request(backend.port, "/abort", "x", None, None).unwrap_err(); + let partial = failure + .downcast_ref::() + .expect("response started"); + assert_eq!(partial.status, 200); + assert_eq!(partial.body, b"partial"); + } + + #[test] + fn connection_failure_is_not_an_interrupted_response() { + let unused = port().unwrap(); + let failure = request(unused, "/abort", "x", None, None).unwrap_err(); + assert!(failure.downcast_ref::().is_none()); + } +} diff --git a/tests/fixtures/reusable-app/crates/fixture-harness/src/runners.rs b/tests/fixtures/reusable-app/crates/fixture-harness/src/runners.rs new file mode 100644 index 00000000..157d4aeb --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-harness/src/runners.rs @@ -0,0 +1,871 @@ +use crate::{evidence::*, net::Backend, *}; +use std::collections::HashMap; +fn wasm(variant: &str) -> PathBuf { + let name = match variant { + "a" => "single-request", + "b" => "rebuild-per-request", + "c" => "retained-app", + "custom-a" => "custom-single-request", + "custom-b" => "custom-rebuild-per-request", + "custom-c" => "custom-retained-app", + other => other, + }; + root().join(format!( + "target/wasm32-wasip1/release/fixture-fastly-{name}.wasm" + )) +} +fn fastly_command(exe: &Path, config: &Path, variant: &str, port: u16) -> Command { + let mut cmd = Command::new(exe); + cmd.args(["serve", "--addr", &format!("127.0.0.1:{port}"), "--config"]) + .arg(config) + .arg(wasm(variant)); + cmd +} + +fn custom_initialization_contract( + exe: &Path, + config: &Path, + run: &Path, + records: &mut Records, +) -> Result<()> { + let port = net::port()?; + let process = Process::start( + &mut fastly_command(exe, config, "custom-c", port), + &run.join("custom-initialization.log"), + port, + )?; + let outcome = (|| -> Result<()> { + let mut instances = Vec::new(); + for (path, status, attempts, initialized) in [ + ("/health", 200, 0, false), + ("/initialization-error", 503, 1, false), + ("/health", 200, 1, false), + ("/probe/recovered", 200, 2, true), + ("/probe/retained", 200, 2, true), + ] { + let reply = req(port, path)?; + require( + reply["status"] == status, + "custom initialization status mismatch", + )?; + let value: Value = serde_json::from_str(reply["body"].as_str().ok_or("missing body")?)?; + instances.push(value["instance"].clone()); + let same_instance = instances.iter().all(|id| id == &instances[0]); + if same_instance { + require( + value["ordinal"] == instances.len(), + "recovery ordinal mismatch", + )?; + if initialized { + require(value["builds"] == 1, "recovered application was rebuilt")?; + } else { + require( + value["retained"] == false, + "health or failure retained an app", + )?; + } + require( + reply["headers"].as_array().unwrap().iter().any(|h| { + h[0].as_str() + .is_some_and(|name| name.eq_ignore_ascii_case("x-fixture-attempts")) + && h[1].as_str().and_then(|v| v.parse::().ok()) == Some(attempts) + }), + "lazy build attempt count mismatch", + )?; + } + records.push(annotate( + reply, + json!({"variant":"custom-initialization", "guest":value}), + ))?; + } + records.push(json!({"assertion":"custom_lazy_initialization_recovery", "status":if instances.iter().all(|id| id == &instances[0]) {"pass"} else {"unverified"}, "reason":"recovery requires all five callbacks in the same guest"})) + })(); + drop(process); + outcome +} + +pub fn fastly(args: &Args, run: &Path, records: &mut Records) -> Result<()> { + let Some(exe) = executable("VICEROY_BIN", "viceroy") else { + return records.push(json!({"status":"unsupported","reason":"Viceroy unavailable"})); + }; + checked( + Command::new("cargo") + .args([ + "build", + "--locked", + "--release", + "-p", + "fixture-fastly", + "--bins", + "--target", + "wasm32-wasip1", + ]) + .current_dir(root()) + .env("FIXTURE_BUILD_ROUNDS", args.construction_rounds.to_string()) + .env("FIXTURE_MAX_REQUESTS", args.max_requests.to_string()), + )?; + records.push(json!({"event":"runtime","adapter":"fastly","executable":exe,"version":String::from_utf8_lossy(&Command::new(&exe).arg("--version").output()?.stdout).trim()}))?; + let config_snapshot = run.join("fastly.toml"); + fs::copy( + root().join("crates/fixture-fastly/fastly.toml"), + &config_snapshot, + )?; + if args.suite == "smoke" { + custom_initialization_contract(&exe, &config_snapshot, run, records)?; + } + let mut variants = vec!["a", "b", "c", "custom-a", "custom-b", "custom-c"]; + if args.suite == "smoke" { + variants.extend(["limit-requests", "limit-lifetime", "limit-memory"]); + } + let mut rng = args.seed; + for repetition in 0..if args.suite == "benchmark" { + args.repetitions + } else { + 1 + } { + // A recorded deterministic Fisher-Yates order; independent of language RNG versions. + for i in (1..variants.len()).rev() { + rng = rng + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + variants.swap(i, (rng as usize) % (i + 1)); + } + records + .push(json!({"event":"variant_order","repetition":repetition,"variants":variants}))?; + for variant in &variants { + records.push(json!({"event":"build","variant":variant,"repetition":repetition,"target":"wasm32-wasip1","artifact":artifact(&wasm(variant))?,"config":artifact(&config_snapshot)?}))?; + let port = net::port()?; + let log = run.join(format!("{variant}-{repetition}.log")); + let backend = Backend::start()?; + let process = Process::start( + &mut fastly_command(&exe, &config_snapshot, variant, port), + &log, + port, + )?; + let outcome = (|| -> Result<()> { + let mut seen: HashMap = HashMap::new(); + let mut reused = false; + for _ in 0..if args.suite == "benchmark" { + args.requests + } else { + 5 + } { + let token = token(); + let reply = net::request(port, &format!("/probe/{token}"), &token, None, None)?; + let value = guest(&reply)?; + require( + value["token"] == token && value["path"] == token && value["body"] == token, + format!("request isolation: {value}"), + )?; + if let Some(previous) = + seen.get(value["instance"].as_str().ok_or("missing instance")?) + { + reused = true; + require( + value["ordinal"].as_u64() > previous["ordinal"].as_u64() + && value["correlation"] != previous["correlation"], + "reuse ordinal/correlation mismatch", + )?; + } + require( + value["mutated"] == variant.starts_with("custom-"), + "raw request mutation mismatch", + )?; + if variant.starts_with("custom-") { + require( + reply["headers"].as_array().unwrap().iter().any(|h| { + h[0].as_str().is_some_and(|name| { + name.eq_ignore_ascii_case("x-fixture-finalized") + }) && h[1] == token + }), + "request-specific response extension was lost or leaked", + )?; + } + match *variant { + "c" | "custom-c" => require( + value["ordinal"] + .as_u64() + .is_some_and(|n| n <= args.max_requests as u64) + && value["builds"] == 1 + && value["configures"] == 1, + "retained app initialization mismatch", + )?, + "b" | "custom-b" => require( + value["builds"] == value["ordinal"] + && value["configures"] == value["ordinal"], + "per-request initialization mismatch", + )?, + _ => require( + value["ordinal"] == 1 && value["builds"] == 1, + "single-request initialization mismatch", + )?, + } + seen.insert(value["instance"].as_str().unwrap().into(), value.clone()); + records.push(annotate( + reply, + json!({"variant":variant,"repetition":repetition,"guest":value}), + ))?; + } + if ["b", "c", "custom-b", "custom-c"].contains(variant) && !reused { + records.push(json!({"status":"unverified","variant":variant,"repetition":repetition,"reason":"no actual guest reuse observed"}))?; + } + if ["c", "custom-c"].contains(variant) { + thread::sleep(Duration::from_millis(800)); + let reply = req(port, "/probe/after-idle")?; + require( + !seen.contains_key( + guest(&reply)?["instance"] + .as_str() + .ok_or("missing instance")?, + ), + "idle guest was reused", + )?; + records.push(annotate( + reply, + json!({"variant":variant,"repetition":repetition,"assertion":"idle_fresh_initialization"}), + ))?; + } + if !variant.starts_with("custom-") { + require( + guest(&req(port, "/bindings")?)? + == json!({"config":true,"kv":true,"secrets":true,"unknown":false}), + "binding mismatch", + )?; + } + require( + cookie_values(&req(port, "/cookies")?["headers"]) + == vec!["first=1; Path=/", "second=2; Path=/"], + "duplicate cookies lost", + )?; + require( + req(port, "/rendered-error")?["status"] == 400, + "rendered error mismatch", + )?; + require( + req(port, "/probe/after")?["status"] == 200, + "request after error failed", + )?; + if variant.starts_with("custom-") { + let token = token(); + let stream = net::request( + port, + "/stream", + &token, + Some(&backend.url(&format!("/chunks?token={token}"))), + Some(&backend.release), + )?; + require( + stream["body"] == "firstsecond", + "progressive stream mismatch", + )?; + require( + stream["headers"].as_array().unwrap().iter().any(|h| { + h[0].as_str() + .is_some_and(|s| s.eq_ignore_ascii_case("x-fixture-finalized")) + && h[1] == token + }), + "response not finalized", + )?; + await_evidence(|| { + validate_post_send(&token, &backend.receipts.lock().unwrap(), &logs(&log)) + })?; + records.push(annotate( + stream, + json!({"variant":variant,"repetition":repetition,"assertion":"progressive_stream_and_post_send"}), + ))?; + *backend.release.0.lock().unwrap() = false; + let large_token = crate::token(); + let large = net::request( + port, + "/stream", + &large_token, + Some(&backend.url("/chunks?large=1")), + Some(&backend.release), + )?; + let large_body = large["body"].as_str().ok_or("missing large body")?; + require( + large_body.starts_with("first") && large_body.len() == 5 + 256 * 1024, + "large stream truncated", + )?; + records.push(annotate(large,json!({"variant":variant,"repetition":repetition,"assertion":"large_progressive_stream"})))?; + let failure_token = crate::token(); + + let failure = net::request( + port, + "/stream-error", + &failure_token, + Some(&backend.url(&format!("/abort?token={failure_token}"))), + None, + ) + .expect_err("interrupted stream unexpectedly completed"); + let partial = failure.downcast_ref::().ok_or( + "stream failure occurred before response body: not post-commit evidence", + )?; + require( + partial.status == 200 && partial.body == b"partial", + "missing partial committed response", + )?; + await_evidence(|| { + require( + logs(&log).iter().any(|e| { + e["event"] == "post_commit_error" && e["token"] == failure_token + }), + "missing correlated post-commit error", + ) + })?; + records.push(json!({"event":"expected_stream_error","variant":variant,"repetition":repetition,"token":failure_token,"status_code":partial.status,"partial_body":String::from_utf8_lossy(&partial.body),"source":"controlled_backend_abort"}))?; + } + if args.suite == "smoke" { + for repeat in 0..3 { + let reply = net::request( + port, + "/origin/repeated", + &token(), + Some(&backend.url("/ok")), + None, + )?; + require( + reply["status"] == 200 && reply["body"] == "ok", + "repeated origin failed", + )?; + records.push(annotate(reply, json!({"variant":variant,"repetition":repetition,"origin_repeat":repeat,"assertion":"bounded_repeated_origin"})))?; + } + for origin in 0..3 { + let distinct = Backend::start()?; + let reply = net::request( + port, + &format!("/origin/{origin}"), + &token(), + Some(&distinct.url("/ok")), + None, + )?; + require( + reply["status"] == 200 && reply["body"] == "ok", + "distinct origin failed", + )?; + records.push(annotate( + reply, + json!({"variant":variant,"repetition":repetition,"assertion":"bounded_distinct_origin"}), + ))?; + } + if variant.starts_with("custom-") { + let token = token(); + if let Ok(failed) = net::request(port, "/panic", &token, None, None) { + require( + failed["status"].as_u64().is_some_and(|s| s >= 500), + "panic did not fail", + )?; + } + let fresh = req(port, "/probe/fresh")?; + let value = guest(&fresh)?; + let mut failed_guest = Value::Null; + await_evidence(|| { + failed_guest = logs(&log) + .iter() + .find(|e| e["event"] == "request_start" && e["token"] == token) + .map(|e| e["instance"].clone()) + .ok_or("missing panic attempt")?; + Ok(()) + })?; + require(value["instance"] != failed_guest, "terminated guest reused")?; + records.push(annotate(fresh,json!({"variant":variant,"repetition":repetition,"assertion":"terminated_guest_not_reused","failed_guest":failed_guest})))?; + } + } + if variant.starts_with("limit-") { + await_evidence(|| validate_limit_summaries(&logs(&log)))?; + } + records.push(json!({"status":"pass","variant":variant,"repetition":repetition,"assertions":["initialization","isolation","cookies","rendered_error"]}))?; + Ok(()) + })(); + drop(process); + records.collect_clients(json!({"variant":variant,"repetition":repetition}))?; + for event in logs(&log) { + records.push(annotate( + event, + json!({"variant":variant,"repetition":repetition,"repetition":repetition}), + ))?; + } + outcome?; + } + } + if args.suite == "smoke" { + logging(&exe, run, records)?; + faults(&exe, run, records)?; + } + if records + .values + .iter() + .any(|e| e["event"] == "heap_preflight" && e["heap_mib"].is_null()) + { + records.push(json!({"status":"unsupported","reason":"heap snapshot unavailable; memory limit unverified"}))?; + } + Ok(()) +} +fn logging(exe: &Path, run: &Path, records: &mut Records) -> Result<()> { + let Some(service) = records + .values + .iter() + .find_map(|e| e["service_id"].as_str()) + .map(str::to_owned) + else { + return records.push(json!({"status":"unverified","reason":"service ID unavailable for logging configuration"})); + }; + let base = fs::read_to_string(root().join("crates/fixture-fastly/fastly.toml"))?; + let config = run.join("logging.toml"); + fs::write( + &config, + format!( + "{base}\n[local_server.config_stores.edgezero_runtime_env]\nformat = \"inline-toml\"\n[local_server.config_stores.edgezero_runtime_env.contents]\n\"EDGEZERO__SERVICES__{service}__LOGGING__ENDPOINT\" = \"fixture-logs\"\n" + ), + )?; + for variant in ["b", "c", "logger-negative"] { + let port = net::port()?; + let log = run.join(format!("logging-{variant}.log")); + let process = Process::start(&mut fastly_command(exe, &config, variant, port), &log, port)?; + let mut replies = Vec::new(); + for _ in 0..if variant == "logger-negative" { 2 } else { 3 } { + replies.push(req(port, "/probe/log")?); + } + drop(process); + records.collect_clients(json!({"variant":format!("logging-{variant}"),"repetition":0}))?; + for event in logs(&log) { + records.push(annotate( + event, + json!({"variant":format!("logging-{variant}"),"repetition":0}), + ))?; + } + records.push(json!({"event":"logging_check","variant":variant,"statuses":replies.iter().map(|r|r["status"].clone()).collect::>(),"log_file":log.file_name()}))?; + if variant == "logger-negative" { + let starts = logs(&log) + .into_iter() + .filter(|e| e["event"] == "request_start") + .collect::>(); + let status = validate_negative_logging(&replies, &starts)?; + records.push(json!({"status":status,"assertion":"repeated_logger_installation","variant":variant}))?; + } else { + let guests = replies.iter().map(guest).collect::>>()?; + let text = fs::read_to_string(&log)?; + let delivered = text + .lines() + .filter(|l| l.starts_with("fixture-logs :: ")) + .filter_map(|l| { + l.rsplit_once("fixture request ") + .map(|(_, v)| v.trim().to_owned()) + }) + .collect::>(); + let status = validate_logging(&guests, &delivered)?; + records.push(json!({"status":status,"assertion":"named_endpoint_receipt","variant":variant,"received":delivered.len()}))?; + } + } + Ok(()) +} +pub fn provider(name: &str, args: &Args, run: &Path, records: &mut Records) -> Result<()> { + let package = root().join("crates").join(format!("fixture-{name}")); + let exe = if name == "axum" { + checked( + Command::new("cargo") + .args(["build", "--locked", "-p", "fixture-axum"]) + .current_dir(root()), + )?; + root().join("target/debug/fixture-axum") + } else { + let (variable, tool) = if name == "cloudflare" { + ("WRANGLER_BIN", "wrangler") + } else { + ("SPIN_BIN", "spin") + }; + let Some(exe) = executable(variable, tool) else { + return records.push(json!({"status":"unsupported","adapter":name,"reason":format!("{tool} unavailable")})); + }; + records.push(json!({"event":"runtime","adapter":name,"executable":exe,"version":String::from_utf8_lossy(&Command::new(&exe).arg("--version").output()?.stdout).trim()}))?; + if name == "spin" { + let help = Command::new(&exe).args(["up", "--help"]).output()?; + fs::write(run.join("spin-up-help.txt"), &help.stdout)?; + records.push(json!({"event":"runtime_controls","adapter":"spin","selected":"host defaults","source":"spin-up-help.txt"}))?; + + checked( + Command::new("cargo") + .args([ + "build", + "--locked", + "--release", + "-p", + "fixture-spin", + "--target", + "wasm32-wasip2", + ]) + .current_dir(root()), + )?; + } else { + let Some(builder) = executable("WORKER_BUILD_BIN", "worker-build") else { + return records + .push(json!({"status":"unsupported","reason":"worker-build unavailable"})); + }; + checked( + Command::new(builder) + .args(["--release", ".", "--", "--locked"]) + .current_dir(&package), + )?; + } + exe + }; + let built = match name { + "axum" => root().join("target/debug/fixture-axum"), + "spin" => root().join("target/wasm32-wasip2/release/fixture_spin.wasm"), + _ => package.join("build/index_bg.wasm"), + }; + records.push(json!({"event":"build","adapter":name,"artifact":artifact(&built)?}))?; + if name != "axum" { + let filename = if name == "spin" { + "spin.toml" + } else { + "wrangler.toml" + }; + fs::copy(package.join(filename), run.join(filename))?; + } + let modes = if name == "axum" { + vec!["retained"] + } else { + vec!["per-request", "retained"] + }; + for mode in modes { + let port = net::port()?; + let mut command = Command::new(&exe); + command + .current_dir(&package) + .env("EDGEZERO__ADAPTER__HOST", "127.0.0.1") + .env("EDGEZERO__ADAPTER__PORT", port.to_string()); + if name == "axum" { + let state = run.join("axum-state"); + fs::create_dir_all(state.join(".edgezero"))?; + fs::write( + state.join(".edgezero/local-config-fixture_config.json"), + "{\"marker\":\"local-fixture\"}", + )?; + command + .current_dir(state) + .env("fixture_marker", "fixture-only-value"); + } else if name == "cloudflare" { + let state = run.join(mode).join("worker-state"); + checked( + Command::new(&exe) + .args([ + "kv", + "key", + "put", + "marker", + "local-fixture", + "--binding", + "fixture_config", + "--local", + "--persist-to", + ]) + .arg(&state) + .current_dir(&package), + )?; + command + .args([ + "dev", + "--local", + "--ip", + "127.0.0.1", + "--port", + &port.to_string(), + "--var", + &format!("FIXTURE_MODE:{mode}"), + "--persist-to", + ]) + .arg(state); + } else { + let manifest = fs::read_to_string(package.join("spin.toml"))? + .replace( + "../../target/", + &format!("{}/", root().join("target").display()), + ) + .replace( + "FIXTURE_MODE = \"retained\"", + &format!("FIXTURE_MODE = \"{mode}\""), + ); + let config = run.join(format!("spin-{mode}.toml")); + fs::write(&config, manifest)?; + command + .args(["up", "--listen", &format!("127.0.0.1:{port}"), "--from"]) + .arg(config) + .arg("--runtime-config-file") + .arg(package.join("runtime-config.toml")); + } + let log = run.join(format!("{name}-{mode}.log")); + + let process = Process::start(&mut command, &log, port)?; + let outcome = (|| -> Result<()> { + let mut seen: HashMap = HashMap::new(); + let mut reused = false; + let repetitions = if args.suite == "benchmark" { + args.repetitions + } else { + 1 + }; + let requests = if args.suite == "benchmark" { + args.requests + } else { + 5 + }; + for repetition in 0..repetitions { + for _ in 0..requests { + let token = token(); + let reply = net::request(port, &format!("/probe/{token}"), &token, None, None)?; + let value = guest(&reply)?; + require( + value["token"] == token && value["path"] == token && value["body"] == token, + "probe request isolation mismatch", + )?; + if mode == "retained" { + require( + value["builds"] == 1 && value["configures"] == 1, + "retained initialization mismatch", + )?; + } + let instance = value["instance"].as_str().ok_or("missing instance")?; + if let Some(previous) = seen.get(instance) { + reused = true; + require( + value["ordinal"].as_u64() > previous["ordinal"].as_u64(), + "ordinal did not increase", + )?; + if mode == "per-request" { + require( + value["builds"].as_u64() > previous["builds"].as_u64(), + "per-request app was not rebuilt", + )?; + } + } + seen.insert(instance.into(), value.clone()); + records.push(annotate( + reply, + json!({"guest":value,"adapter":name,"mode":mode,"repetition":repetition}), + ))?; + } + } + if !reused { + records.push(json!({"status":"unverified","adapter":name,"mode":mode,"reason":"no same-guest sequential reuse"}))?; + } + if mode == "retained" && name != "axum" { + let failed = req(port, "/binding-failure")?; + require( + failed["status"] == 503, + "injected required binding did not fail", + )?; + let failed_guest: Value = serde_json::from_str(failed["body"].as_str().unwrap())?; + let recovered = guest(&req(port, "/probe/after-binding-failure")?)?; + require( + recovered["builds"] == 1, + "app rebuilt after binding failure", + )?; + records.push(json!({"adapter":name,"mode":mode,"status":if failed_guest["instance"] == recovered["instance"] {"pass"} else {"unverified"},"assertion":"same_guest_binding_recovery","source":"injected_required_binding"}))?; + } + let bindings = req(port, "/bindings")?; + + require( + guest(&bindings)? + == json!({"config":true,"kv":true,"secrets":true,"unknown":false}), + "binding mismatch", + )?; + records.push(annotate( + bindings, + json!({"adapter":name,"mode":mode,"assertion":"binding_reads"}), + ))?; + let cookies = req(port, "/cookies")?; + if cookie_values(&cookies["headers"]) != vec!["first=1; Path=/", "second=2; Path=/"] { + records.push(annotate(cookies,json!({"status":"fail","adapter":name,"mode":mode,"reason":"duplicate Set-Cookie values were not preserved"})))?; + } + require( + req(port, "/rendered-error")?["status"] == 400, + "rendered error mismatch", + )?; + if name != "axum" { + require( + req(port, "/other")?["body"] == "other-app", + "alternate app mismatch", + )?; + } + let mut observed_overlap = false; + for attempt in 0..3 { + let backend = Backend::start()?; + let tokens = [token(), token()]; + let replies = thread::scope(|scope| { + let jobs = tokens + .iter() + .map(|token| { + let url = backend.url("/barrier"); + scope.spawn(move || { + net::request( + port, + &format!("/overlap/{token}"), + token, + Some(&url), + None, + ) + }) + }) + .collect::>(); + jobs.into_iter() + .map(|job| { + job.join() + .map_err(|_| "overlap request thread panicked".into()) + .and_then(|v| v) + }) + .collect::>>() + })?; + let values = replies.iter().map(guest).collect::>>()?; + for (value, token) in values.iter().zip(&tokens) { + require( + value["token"] == *token + && value["before"]["token"] == *token + && value["path"] == *token + && value["before"]["path"] == *token, + "overlap request-local values changed", + )?; + } + if let Err(error) = validate_overlap(&values[0], &values[1]) { + records.push(json!({"event":"overlap_observation","adapter":name,"mode":mode,"attempt":attempt,"reason":error.to_string(),"observations":values}))?; + continue; + } + observed_overlap = true; + break; + } + records.push(json!({"status":if observed_overlap {"pass"} else {"unverified"},"adapter":name,"mode":mode,"assertion":"same_guest_overlap","attempt_limit":3}))?; + + Ok(()) + })(); + drop(process); + records.collect_clients(json!({"adapter":name,"mode":mode}))?; + for event in logs(&log) { + records.push(annotate(event, json!({"adapter":name,"mode":mode})))?; + } + outcome?; + } + Ok(()) +} + +fn faults(exe: &Path, run: &Path, records: &mut Records) -> Result<()> { + let backend = Backend::start()?; + let config = run.join("faults.toml"); + fs::write( + &config, + format!( + "{}\n[local_server.backends.fault-origin]\nurl = {:?}\n", + fs::read_to_string(root().join("crates/fixture-fastly/fastly.toml"))?, + backend.url("/") + ), + )?; + for (index, path) in [ + "/constructor-panic", + "/collect-error", + "/inbound-error", + "/terminal-store-error", + "/recoverable-store-error", + ] + .iter() + .enumerate() + { + let port = net::port()?; + let log = run.join(format!("fault-{index}.log")); + let process = Process::start( + &mut fastly_command(exe, &config, "faults", port), + &log, + port, + )?; + let outcome = (|| -> Result<()> { + let malformed_token = token(); + let rejected = net::malformed_request(port, &malformed_token)?; + require( + rejected.contains(" 400 "), + format!("malformed request was not rejected: {rejected}"), + )?; + require( + !logs(&log).iter().any(|e| e["token"] == malformed_token), + "malformed request reached callback unexpectedly", + )?; + 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 recoverable = *path == "/recoverable-store-error"; + require( + reply["status"] == if recoverable { 503 } else { 500 }, + format!("fault not observed: {path}: {reply}"), + )?; + let following = req(port, "/probe/recovered")?; + let value = guest(&following)?; + let mut failed_guest = Value::Null; + await_evidence(|| { + let events = logs(&log); + failed_guest = events + .iter() + .find(|e| e["event"] == "request_start" && e["token"] == failed_token) + .map(|e| e["instance"].clone()) + .ok_or("missing fault attempt")?; + let event = if *path == "/constructor-panic" { + "constructor_panic" + } else { + "adapter_error" + }; + require( + events + .iter() + .any(|e| e["event"] == event && e["instance"] == failed_guest), + "missing actual failure boundary", + ) + })?; + if recoverable { + let status = if value["instance"] == failed_guest { + "pass" + } else { + "unverified" + }; + require( + value["builds"] == 1, + "retained app rebuilt after handled store 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"}))?; + } else { + require( + value["instance"] != failed_guest, + "terminal failure guest reused", + )?; + if *path != "/constructor-panic" { + await_evidence(|| { + require( + logs(&log).iter().any(|e| { + e["event"] == "sdk_summary" + && e["instance"] == failed_guest + && e["attempted"] == 1 + }), + "missing terminal attempt summary", + ) + })?; + } + records.push(json!({"status":"pass","assertion":"terminal_fault","path":path,"failed_guest":failed_guest,"source":"controlled_fixture_input"}))?; + } + records.push(annotate( + reply, + json!({"variant":"faults","repetition":index,"assertion":path}), + ))?; + Ok(()) + })(); + drop(process); + records.collect_clients(json!({"variant":"faults","repetition":index}))?; + for event in logs(&log) { + records.push(annotate( + event, + json!({"variant":"faults","repetition":index}), + ))?; + } + outcome?; + } + Ok(()) +} diff --git a/tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml b/tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml new file mode 100644 index 00000000..91e3a823 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-spin/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "fixture-spin" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[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 } +anyhow = "1" + +[lib] +crate-type = ["cdylib", "rlib"] diff --git a/tests/fixtures/reusable-app/crates/fixture-spin/runtime-config.toml b/tests/fixtures/reusable-app/crates/fixture-spin/runtime-config.toml new file mode 100644 index 00000000..d126eb41 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-spin/runtime-config.toml @@ -0,0 +1,4 @@ +[key_value_store.fixture_config] +type = "spin" +[key_value_store.fixture_kv] +type = "spin" diff --git a/tests/fixtures/reusable-app/crates/fixture-spin/spin.toml b/tests/fixtures/reusable-app/crates/fixture-spin/spin.toml new file mode 100644 index 00000000..5f7a2362 --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-spin/spin.toml @@ -0,0 +1,17 @@ +spin_manifest_version = 2 +[application] +name = "reusable-app-fixture" +version = "0.1.0" +[variables] +fixture_marker = { default = "fixture-only-value", secret = true } +[[trigger.http]] +route = "/..." +component = "fixture" +[component.fixture] +source = "../../target/wasm32-wasip2/release/fixture_spin.wasm" +allowed_outbound_hosts = ["http://127.0.0.1:*", "http://localhost:*"] +key_value_stores = ["fixture_config", "fixture_kv"] +[component.fixture.variables] +fixture_marker = "{{ fixture_marker }}" +[component.fixture.environment] +FIXTURE_MODE = "retained" diff --git a/tests/fixtures/reusable-app/crates/fixture-spin/src/lib.rs b/tests/fixtures/reusable-app/crates/fixture-spin/src/lib.rs new file mode 100644 index 00000000..f9cf20ed --- /dev/null +++ b/tests/fixtures/reusable-app/crates/fixture-spin/src/lib.rs @@ -0,0 +1,62 @@ +use edgezero_core::app::{App, Hooks}; +use fixture_core::{FixtureApp, OtherApp}; +use spin_sdk::{ + http::{IntoResponse, Request}, + http_service, +}; +use std::sync::OnceLock; +static APP: OnceLock = OnceLock::new(); +static OTHER_APP: OnceLock = OnceLock::new(); +#[http_service] +async fn handle(req: Request) -> anyhow::Result { + if std::env::var("FIXTURE_MODE").as_deref() == Ok("retained") + && req.uri().path() == "/binding-failure" + { + let mut stores = FixtureApp::stores(); + stores.kv = Some(edgezero_core::app::StoreMetadata { + default: "missing_fixture_kv", + ids: &["missing_fixture_kv"], + }); + let result = edgezero_adapter_spin::dispatch_app( + APP.get_or_init(FixtureApp::build_app), + stores, + req, + ) + .await; + return match result { + Err(_) => { + let body = serde_json::json!({"instance":fixture_core::instance_id(),"source":"injected_required_binding"}).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?) + } + Ok(response) => Ok(response), + }; + } + if req.uri().path() == "/bindings" { + let store = spin_sdk::key_value::Store::open("fixture_config").await?; + store.set("marker", b"local-fixture").await?; + } + if req.uri().path() == "/other" { + return edgezero_adapter_spin::dispatch_app( + OTHER_APP.get_or_init(OtherApp::build_app), + OtherApp::stores(), + req, + ) + .await; + } + if std::env::var("FIXTURE_MODE").as_deref() == Ok("retained") { + edgezero_adapter_spin::dispatch_app( + APP.get_or_init(FixtureApp::build_app), + FixtureApp::stores(), + req, + ) + .await + } else { + edgezero_adapter_spin::run_app::(req).await + } +}