Skip to content

Adopt EdgeZero reusable-sandbox lifecycle for Fastly - #1179

Draft
prk-Jr wants to merge 11 commits into
mainfrom
856-fastly-reusable-sandbox
Draft

prk-Jr wants to merge 11 commits into
mainfrom
856-fastly-reusable-sandbox

Conversation

@prk-Jr

@prk-Jr prk-Jr commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Fastly Compute normally starts a fresh Wasm sandbox per request, so every request repeats the whole initialization sequence: load and parse settings, resolve secrets, compile the auction plan, build the orchestrator and integration registry, construct the telemetry sink. This adopts EdgeZero's reusable-sandbox lifecycle so one sandbox can serve several requests and pay that cost once.
  • Reuse is off by default and opt-in twice over. It needs the reusable-sandbox Cargo feature (which fastly.toml does not pass) and explicit bounds in the runtime config store. Absent or partial configuration stays single-request, so the shipped build is unchanged.
  • Locally: 6 requests per sandbox with 1 build instead of 6, and a reused request is ~11x faster than the feature-off baseline on an edge-only route. Deployed behaviour is not established — see Limitations.

What changed, in order

The branch is meant to be read commit by commit.

Commit What
c56ff745e Serve loop, no retained state. Feature flag, main split, logger guard, measurement counters, ts dev sandbox-probe.
00c4e4278 Prerequisite: move script-rewriter accumulation buffers into per-document state.
ca7e7fa7a Retain the built application across requests.
f51ba6f51 vCPU/heap counters; record the local A/B/C results.
0b447f804 Test the real build decision; tighten evidence claims.
dd74fa202, 66529f8f1 Cross-platform fixes (CLI output module on Linux).
b6df0b3e9 Pin EdgeZero to 277544c4 (CLI + Cloudflare fixes).
281c89a98 Adopt edgezero_adapter_fastly::lifecycle, deleting our equivalents. Pin 76c59b44.
52942ba8b Fix the final initialization count in retirement logs.

A cross-request disclosure fix, worth reading on its own

GoogleTagManagerIntegration and NextJsNextDataRewriter accumulated inline script fragments in a Mutex<String> on the rewriter, which the registry holds for its whole lifetime. If a document's stream ended before its final fragment — client disconnect, origin error, truncated body — the partial script stayed in that buffer and the next document prepended it, corrupting the response and potentially disclosing the previous document's content.

This is latent today only because each sandbox serves one request and then dies. Retention is what makes it reachable, so it is fixed in its own commit (00c4e4278) ahead of retention. Both regression tests fail against the previous code with the first document's content prepended to the second's response.

What EdgeZero owns vs what we own

After 281c89a98 the framework owns lazy successful-only retention, the callback count, the initialization-attempt count, the one-time setup guard, and the serving wrappers. Deleted here:

Removed Replaced by
local struct Sandbox lifecycle::Sandbox<RetainedApp>
resolve_app / retain_app / retained_app Sandbox::initialize
ensure_logger + logger_installed Sandbox::setup_once
begin_request / record_build / … requests() / initialization_attempts()
Serve::new()…run_with_context(…) serve_custom / run_custom

Still ours, deliberately: feature gate, kill switch, limit parsing and fallback; settings retention and refresh policy; health/JA4/metrics routing; fresh per-request metadata, handles, services, extensions, bodies and correlation ids; raw request conversion and router dispatch; response-extension finalization; progressive streaming; duplicate Set-Cookie; post-send work; per-document rewrite-buffer isolation.

serve_app is not adopted: its response conversion buffers streams, which would end progressive delivery and leave nowhere for response-extension finalization.

Dependency

EdgeZero pinned by SHA to 76c59b440fb35d1317dcb3fa8c1172161e3f5309 on feat/reusable-app-lifecycle. Cargo.lock moves only the edgezero packages' source fields; no other dependency changes. The pin is an unmerged branch revision and should move to a release tag once one contains it.

Closes

Closes #856

Test plan

  • cargo test-fastly && cargo test-axum && cargo test-cloudflare && cargo test-spin
  • cargo test-fastly-reuse — new alias; test-fastly does not pass --all-features, so feature-on tests would otherwise never run
  • All 8 CI clippy invocations, incl. host-target CLI and openrtb-codegen
  • cargo fmt --all -- --check
  • JS tests (959) and JS/docs format
  • Parity suite
  • Release WASM build, both feature configurations
  • Manual runtime validation under Viceroy 0.17.0 — see below
  • EdgeZero's own Fastly compatibility suite at the pinned revision: exit 0
  • Regressions mutation-checked: reintroducing each bug fails the corresponding test

Local evidence

Arm Requests/sandbox Builds/sandbox p50
feature off 1 1 3.546 ms
reuse, rebuild per request 6 6 3.162 ms
reuse, retained 6 1 0.308 ms

The middle row is the control: the serving loop alone buys almost nothing. The gain is retention.

Verified on observed reused sandboxes: progressive delivery (first byte ~2 s ahead of origin completion), duplicate Set-Cookie, request isolation with distinct per-request metadata, probes not constructing the app, failed initialization retried and never retained, and post-commitment stream failures producing exactly one response before a normal request succeeds on the same sandbox.

Full method, raw observations and provenance: docs/superpowers/specs/2026-09-17-fastly-reusable-sandbox-results.md.

Limitations — deployed behaviour is not established

  • Long-lived memory behaviour and deployed eviction: unverified. Viceroy admits ~6 requests per guest, so this cannot be established locally.
  • Six requests per sandbox is a repeated local observation under Viceroy 0.17.0, not a demonstrated universal ceiling.
  • Same-sandbox initialization recovery (fail → success → reuse) is unit-level only: Viceroy's stores are fixed for a guest's lifetime.
  • Correctness checks used mock origins; the real publisher origin's bot wall blocks the paths under test.
  • vCPU and heap readings are pre-send cumulative samples, not complete per-request costs.
  • Reuse is never guaranteed: any request may start a fresh sandbox, so correctness holds for a cold sandbox on every request.
  • Latency figures were measured at an earlier revision on this branch and were not re-measured after the lifecycle adoption; build-count and correctness were re-observed at the current pin.

Rollback

No rung is immediate — limits are read at sandbox startup, so a running sandbox retires on the limits it started with.

config key -> 1   later sandboxes stop reusing
feature off       redeploy; restores the original entry path
revert 52942ba8b / 281c89a98 / b6df0b3e9   back to v0.0.8 + local lifecycle
revert ca7e7fa7a  removes retention, keeps the loop and the core fix
revert 00c4e4278  removes per-document buffers (only safe with retention reverted)
revert c56ff745e  removes the lifecycle entirely

Checklist

  • Changes follow AGENTS.md conventions
  • No unwrap() in production code
  • Uses log macros (not println!)
  • New code has tests
  • No secrets or credentials committed

Fastly SDK 0.12.1 exposes `fastly::http::serve::Serve`, which lets one Wasm
sandbox serve several requests. Adopt it behind a `reusable-sandbox` feature
that is off by default, so the shipped entry point is unchanged.

Split `main` into a loop owner and `handle_request`. The handler keeps sending
its own response and returns `()`, which the SDK's `HandlerResult` impl treats
as already sent, so progressive streaming, duplicate `Set-Cookie` headers,
response extensions, and post-send pull sync are untouched. No application
state is retained yet; that follows in a later change.

Three execution modes, only the first identical to today:

    feature off                              -> original entry path
    feature on, effective max_requests <= 1  -> single-request handler path
    feature on, validated reuse limits       -> Serve loop

Bounds come from the `edgezero_runtime_env` config store. They cannot be read
through `runtime_env_config`, whose `runtime_env_keys` allowlist drops every
key outside the adapter, logging, and per-store selectors; routing them there
would read as absent and silently disable reuse while passing every test. The
adapter opens the store itself and builds the service-scoped key from
`service_id()`, since edgezero's own key helper is private. Reads use
`try_get`, never `get`: `get` panics on a lookup error, and this runs before
the health probe. Any failure resolves to single-request operation.

Reuse needs all three bounds. A bare request limit is refused because the SDK
reads an omitted lifetime and wait timeout as `Duration::MAX`, and an
application-level `0` normalizes to `1` because the SDK reads
`with_max_requests(0)` as unlimited.

Guard the global logger. `fern`'s `apply()` panics when a logger is already
installed, which a reused sandbox would hit on its second request. Startup
diagnostics are deferred and flushed once the logger exists, since limit
resolution runs before there is anywhere to log.

Add measurement so the lifecycle can be evaluated rather than assumed. A
`sandbox_metrics_enabled` debug flag attaches instance id, request ordinal,
build count, and correlation id to workload responses before headers commit;
post-commitment stream failures carry the same context in logs. The flag is
skipped while false because `DebugConfig` denies unknown fields and a default
blob must stay readable by an older binary. The counters endpoint is feature
gated and short-circuits ahead of application construction so polling cannot
perturb the build count it reports. Response counters stay feature independent
so the feature-off baseline is measurable on the same channel.

Add `ts dev sandbox-probe`, which issues a keep-alive sequence and reports
reuse only from strictly increasing ordinals under one instance id. Repeated
or decreasing ordinals, incomplete attribution, a missing instance identity,
and transport errors all report unverified rather than a negative result.

`test-fastly` does not pass `--all-features` and CI invokes it directly, so
add a `test-fastly-reuse` alias and CI step; clippy compiling the feature is
not the same as running its tests.
`GoogleTagManagerIntegration` and `NextJsNextDataRewriter` accumulate inline
script fragments across `lol_html` chunk boundaries, draining only when
`is_last_in_text_node` arrives. Both held that buffer as a `Mutex<String>`
field on the rewriter itself.

The registry stores rewriters as `Arc<dyn IntegrationScriptRewriter>`,
registered once at build time, so the buffer lives as long as the registry.
When a document's stream ends before the final fragment — client disconnect,
origin error, truncated body — its partial script stays in the buffer, and the
next document through that registry prepends the residue to its own
accumulation. That corrupts the response and can disclose the previous
document's content.

Today each Fastly sandbox serves one request, so the buffer is destroyed
before anything else can observe it. Retaining the registry across requests is
what makes it reachable, so this has to land before retention does.

Add `ScriptTextAccumulator` and store it in `IntegrationDocumentState`, which
is already constructed per document and already threaded through
`IntegrationScriptContext`. Keyed per integration id, so each integration gets
its own buffer and it is dropped with the document. The rewriter objects
become stateless.

`NextJsRscPlaceholderRewriter` deliberately does not accumulate and is
unaffected.

Both regression tests drive one rewriter through two documents, interrupting
the first mid-accumulation, and assert the second carries no trace of it.
Against the previous code they fail with the first document's content
prepended to the second document's response.
Build the application lazily, once per sandbox, and keep it in `Sandbox`.
This is what actually amortizes the initialization the earlier commits made
safe to share: settings load and parse, secret resolution, auction plan
compilation, orchestrator and integration registry construction, and the
telemetry sink. The `OnceLock` regex caches in `settings.rs` now survive past
the request that populated them.

Construction sits behind the health, counters, and JA4 short-circuits, so none
of those pays for it.

A failed build is never retained. `build_app_with_state` returns an error
router with no state; that serves the current request and is dropped, so a
transient config-store failure cannot pin the sandbox into permanent error
mode, and the next request retries. The attempt is still counted, so a retry
loop shows up in the build counter rather than hiding.

Nothing request-scoped is retained. The config store handle, client info,
device signals, TLS metadata, `RuntimeServices`, correlation id, EC finalize
state and request filter effects are all still built per request. Everything
reachable from the retained `AppState` was audited as config-derived, and the
script accumulation buffers that were not are fixed in the preceding commit.

With no reuse configured this changes nothing observable: a single-request
sandbox builds once, serves once, and exits.

Retention does not remove the per-request Ed25519 signing key parse, which
runs from `RequestSigner::from_services` against the per-request
`RuntimeServices` inside the retained orchestrator. That needs its own
measurement and rotation decision.
Issue #856 asked for CPU and memory observations alongside build counts and
latency. Add them to the sandbox counters: cumulative guest vCPU milliseconds
from `elapsed_vcpu_ms` and the heap snapshot from `heap_memory_snapshot_mib`,
each reported as `unsupported` rather than a zero when the host lacks the
call, so an unsupported counter is never mistaken for an idle one.

Take the instance id from `compute_runtime::sandbox_id()` instead of reading
`FASTLY_TRACE_ID` directly. The SDK documents that function as the per-sandbox
identifier and resolves it to `FASTLY_TRACE_ID` on wasm32-wasip1, so this is
the same value by the supported API, and it keeps working on the component
path where the environment variable does not exist.

Record the local A/B/C measurement in the results document: commands, commit
ids, configuration, raw observations, and limitations.

Headline: retention takes builds per sandbox from 6 to 1. Reuse without
retention does not (arm B rebuilds on all 6). A reused request is 11.7x faster
than the feature-off baseline at p50 on an edge-only route, and about 3.9x
averaged over a full sandbox once the one build is amortized.

Progressive delivery, duplicate Set-Cookie, and request isolation are each
verified on observed reused instances. Failed builds are confirmed not
retained; recovery after a failure in the same sandbox is covered only at unit
level, because Viceroy's stores are fixed for a guest's lifetime.

Long-lived memory behaviour and deployed eviction remain unverified, and the
six-requests-per-sandbox figure is a repeated local observation under Viceroy
0.17.0 rather than a demonstrated universal ceiling.
The recovery test drove the `Sandbox` API by hand: it incremented the build
counter and inserted an application itself, never invoking the production
build/reuse/retry decision. It would have passed with that decision broken, so
it was not evidence for the recovery claim made in the results.

Extract the decision into `Sandbox::resolve_app`, which takes the builder as a
closure. `edgezero_main` now calls it instead of open-coding the same logic,
and the tests drive it with an injected builder.

Two tests replace the hand-driven one. Reuse: build once, then three requests
whose builder panics if called. Recovery: a failing build, then a succeeding
one, then a third request whose builder panics if called — so recovery is
shown to be both reachable and durable. Both were checked against deliberate
mutations of `resolve_app` (never reusing the retained app, and swallowing the
failed build) and fail as intended.

Results document corrections:

- Recovery is described as unit-level only, naming the new test and why an
  end-to-end same-sandbox recovery is not locally reproducible.
- The vCPU and heap numbers are labelled pre-send cumulative samples. They are
  read before headers commit, so the first excludes that request's remaining
  work, consecutive differences straddle two requests, and the heap figure is
  linear memory including host buffering rounded to MiB, not Rust heap usage.
- The aggregate comparison uses like-for-like sample means (3.872 / 0.934 ≈
  4.15x) instead of a modelled six-request average against a median. The
  modelled amortization is kept but labelled as a model.
- Resource-measurement provenance is recorded: those headers landed in
  f51ba6f, not in the arm C commit, they were read with curl rather than the
  probe, and the latency experiment was not rerun on that revision.

Also drop the stale "no application state" comment on `Sandbox`, which stopped
being true when retention landed.
@prk-Jr prk-Jr self-assigned this Sep 17, 2026
`ts dev sandbox-probe` builds on all non-wasm hosts and writes through
`crate::output`, but that module was gated to macOS because the macOS-only
`ts dev proxy` was its first consumer. On Linux the probe therefore referenced
a module that did not exist, and CI failed with `cannot find output in crate`
in three jobs.

Widen the gate to `not(target_arch = "wasm32")`, matching `commands`, `run`,
and the crate's other host-only modules. Nothing in `output` is platform
specific: it is a `println!` / `eprintln!` wrapper and the only place in the
crate permitted to write to the console.

This was not caught locally because the host-target CLI clippy was run with
the macOS triple; CI hardcodes `x86_64-unknown-linux-gnu`.
`output::warn` had no caller outside the macOS-gated proxy, so widening
`mod output` to every host target left it dead on Linux and CI failed with
`function warn is never used`.

The probe should have been warning anyway. A transport error invalidates the
run, and the report goes to stdout, so a caller piping stdout to a file would
otherwise lose that signal entirely. Emit it on stderr as well.

An earlier draft of the probe did call `warn`; the rewrite onto hyper replaced
that path with the `Ending` enum and dropped the call without noticing.
Move all six EdgeZero dependencies from `tag = "v0.0.8"` to
`rev = "277544c431c1ab9bafa14a45d5f35975b5587e97"` on
`feat/reusable-app-lifecycle`. The lockfile changes 16 lines, all of them the
`source` field of the eight edgezero packages; no other dependency moves, one
distinct edgezero source resolves, and no `v0.0.8` reference survives.

The repin is deliberately not for the `Serve` re-export. That type comes from
the already-pinned `fastly 0.12.1` SDK, and EdgeZero's own custom-lifecycle
contract says not to repin merely to swap the import; the entry point still
calls `fastly::http::serve::Serve` directly. The pin is for three fixes at that
revision: push/diff validation scoped to the selected adapter, secret
references redacted from Spin diagnostics, and duplicate response headers
preserved on Cloudflare. The first two are the CLI defects found while
measuring this branch, where a Spin naming rule blocked a Fastly push and the
error printed the rejected reference.

No local code was removed. The contract allows removal only where a public
EdgeZero API now provides equivalent behaviour; at this revision
`service_scoped_runtime_env_key` is still private and `runtime_env_keys` is
still a closed allowlist, so the local key construction remains necessary.
Every `edgezero-core` change across the range is test-only and the Fastly
adapter change is additive, so nothing we depend on moved.

Re-verified fresh against this revision rather than carrying prior results:
default stays single-request with limits configured, health and debug probes
still bypass construction, a reused sandbox holds one successful build, failed
initialization is retried every request and never retained, request state stays
isolated, delayed chunks reach the client about two seconds before the origin
finishes, duplicate Set-Cookie and finalization survive, and three consecutive
post-commitment stream failures on one sandbox each produced exactly one
response before a normal request succeeded on that same sandbox.

EdgeZero's own Fastly compatibility suite passes at this checkout, including
the custom-lifecycle arms.

Documentation records the dependency diff, the CLI checks performed with
synthetic values, the fresh observations, and the remaining gaps: in-guest
initialization recovery is unit-level only for this application, Linux
verification stays CI-only, comparative latency was not rerun, and the pin is
an unmerged branch revision that should move to a release tag once available.
Repin EdgeZero to `76c59b440fb35d1317dcb3fa8c1172161e3f5309` on
`feat/reusable-app-lifecycle`, which adds
`edgezero_adapter_fastly::lifecycle`, and use it instead of this adapter's own
equivalents. The lockfile moves only the eight edgezero packages' `source`
fields; one distinct source resolves and no other dependency changes.

Deleted here, now owned by the framework:

  local `struct Sandbox`                -> `lifecycle::Sandbox<RetainedApp>`
  `resolve_app`/`retain_app`/`retained_app` -> `Sandbox::initialize`
  `ensure_logger` + `logger_installed`  -> `Sandbox::setup_once`
  `begin_request`/`record_build`/...    -> `requests()`/`initialization_attempts()`
  `Serve::new()…run_with_context(…)`    -> `serve_custom` / `run_custom`

`initialize` retains only success. A failed build hands its error router back
as the error payload, which serves the current request and is dropped, so the
next callback retries. Request ordinals now come from the framework, which
counts a callback before invoking it, so probe short-circuits advance the
ordinal without attempting construction — and no local counter shadows it.

`logging::init_logger` returns `Result` rather than panicking on the install
path, so `setup_once` marks setup complete only after a successful install.
That allows a retry; it does not by itself make retrying safe, since
`setup_once` rolls nothing back. It is safe here because neither failure mode
leaves the process partially configured: a failed builder installs nothing,
and a failed `apply()` means a global logger already exists so the retry fails
identically. The function's docs record that reasoning.

`serve_custom` owns the `Sandbox` and drops it when serving ends, so the
retirement line reports snapshots the callback captured rather than reading
the sandbox afterwards.

`serve_app` is deliberately not adopted: its response conversion buffers
streams, which would end progressive delivery and leave no place for
response-extension finalization.

Application-owned and unchanged: feature gate, kill switch, limit parsing and
fallback, settings retention and refresh, health/JA4/metrics routing, fresh
per-request metadata, handles, services, extensions, bodies and correlation
ids, raw request conversion, router dispatch, response-extension
finalization, progressive streaming, duplicate Set-Cookie, post-send work, and
per-document rewrite-buffer isolation. One new local type,
`StartupDiagnostics`, holds messages produced by limit resolution before any
logger exists; it cannot live in the framework `Sandbox`, which exposes no
slot for application state other than the retained payload.

Verified fresh at this revision on macOS: feature-off stays single-request
with limits configured; lazy start with six requests and one build; probes
counted but not constructing; failed initialization retried five times and
never retained; request isolation; duplicate Set-Cookie and finalization;
progressive delivery about two seconds ahead of origin completion; and two
post-commitment failures each producing exactly one response before a normal
request succeeded on the same sandbox. EdgeZero's own Fastly compatibility
suite exits 0 at this checkout.

Limitations recorded in the results document: same-sandbox initialization
recovery stays unit-level because Viceroy's stores are fixed for a guest's
lifetime; Linux remains CI-only and is not claimed to pass from these macOS
runs; long-lived memory and deployed eviction stay unverified; no comparative
latency was rerun at this revision. The pin is an unmerged branch revision and
should move to a release tag once one contains it.
`serve_loop` snapshotted `initialization_attempts()` before invoking the
callback. Initialization happens during the callback, so a build performed by
the final callback was missing from the retirement line. The count itself was
never lost — it lives in EdgeZero's `Sandbox` until serving ends — but the
snapshot was stale by the time that callback finished.

`requests()` on entry was already correct: `lifecycle::Sandbox::handle`
increments its callback count before invoking the handler, so the value read
on entry is the current callback's 1-based ordinal.

Extract `RetirementCounters::observe`, which reads `requests()` on entry and
`initialization_attempts()` on exit, and wrap every callback in it. Extracting
rather than reordering one line means the regression tests drive the same
method production uses, instead of restating the ordering in a test where it
could drift.

Both values are still read from EdgeZero's counters; nothing here increments
anything.

Three regressions cover it: a successful build on the final callback, a failed
build on the final callback, and that the request snapshot mirrors the
framework counter rather than deriving one. The failed case matters because
the application state is not retained, so the attempt count is the only record
that the build happened. Reintroducing the entry-read ordering fails the first
two.

`RetirementCounters` is gated to the reuse feature, matching `scoped_key` and
`sandbox_metrics_response`, so the default build stays warning-free.

Also record a follow-up on `sandbox::scoped_key`, which reproduces EdgeZero's
private runtime-store key format. A public key-construction or lookup helper
would remove the duplication; the working implementation stays until one
exists, and the `TS__SANDBOX__*` suffixes and limit-validation policy remain
application-owned either way.
@prk-Jr prk-Jr changed the title Amortize Fastly initialization with an opt-in reusable sandbox Adopt EdgeZero reusable-sandbox lifecycle for Fastly Sep 17, 2026
`SANDBOX_METRICS_PATH` and `sandbox_metrics_response` were gated
`#[cfg(any(feature = "reusable-sandbox", test))]`, but their only caller is
the short-circuit gated on the feature alone. A feature-off test build
therefore compiled both without a caller, and CI failed on `dead_code`.

Gate them on `feature = "reusable-sandbox"` to match their call site. The
other `any(feature, test)` gates in this module stay: `resolve_mode`,
`collect_raw_limits`, `scoped_key`, the key constants and
`RetirementCounters` are all exercised by tests that run in both feature
configurations.

This did not reproduce locally because CI's `cargo test` job uses
`actions-rust-lang/setup-rust-toolchain`, which defaults `RUSTFLAGS` to
`-D warnings`. The `test-*` aliases carry no such flag, so warnings that fail
CI are merely printed locally. Verified this fix under `RUSTFLAGS="-D warnings"`
across every test alias, the CLI suite, the parity suite, and all clippy
invocations.
@aram356
aram356 marked this pull request as draft September 17, 2026 15:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spike: opt-in reusable sandbox mode (Serve::with_max_requests)

1 participant