Skip to content

Latest commit

 

History

History
169 lines (140 loc) · 7.73 KB

File metadata and controls

169 lines (140 loc) · 7.73 KB

Async on the WasmGC backend: transparent CPS continuation transform

Status

ACCEPTED (owner-approved 2026-05-18). Recorded as ADR-013 (SETTLED-DECISIONS.adoc + .machine_readable/6a2/META.a2ml). The async ABI section is mirrored into the typed-wasm convergence layer with an Ephapax co-stakeholder review flag (typed-wasm ADR-004 — tracked separately). Implementation proceeds as the 4-PR plan below, each behind the 258-case gate.

Problem

stdlib/Http.affine exposes one portable surface:

pub fn fetch(req: Request) -> Response / { Net, Async }

The Deno-ESM backend lowers this to a direct await (shipped, #226). The WasmGC backend cannot: an extern call is synchronous and the wasm boundary is i32-only. The estate’s established async-over-wasm mechanism (#205) is callback-based — an async extern returns a Thenable handle; the guest registers a continuation closure via thenableThen and the host re-enters the guest after settlement, whereupon thenableResultJson yields the value.

That mechanism is correct but its surface is callback-shaped (→ Thenable + explicit thenableThen/thenableResultJson). The owner’s decision (issue #225, Option 2) is to keep one byte-identical source surfacefetch → Response on both targets — and make the wasm backend do the continuation plumbing invisibly.

Decision

On the WasmGC backend only, functions whose effect row includes Async are compiled via a selective continuation-passing (CPS) transform. Pure / non-Async functions are untouched (no codegen or performance change for them).

Mechanism

For an Async function body, each async boundary — a call to an extern returning under / Async, or a call to another Async function — splits the body:

  1. Code up to and including the async call is emitted normally; the call yields a Thenable handle (the existing #205 host convention).

  2. Code after the async call becomes a generated continuation function. Its environment is the set of live locals at the split point, captured exactly via the existing #199 closure ABI (find_free_vars + an [fnId@0, envPtr@4] heap record reachable through __indirect_function_table).

  3. The split lowers to thenableThen(handle, <continuation-closure>); the enclosing function itself returns a Thenable handle representing its own eventual completion.

  4. The continuation, when re-entered by the host, reconstructs the resolved value (for http_request: a Response from the settled payload) and resumes.

Because every Async caller is transformed identically, Thenable handles compose up the call chain transparently; at the host boundary the outermost Thenable is what the host awaits. The AffineScript programmer never sees any of this — the effect row is the abstraction, the backend chooses the mechanism, exactly as on the Deno side.

Why this is sound to build on what exists

  • find_free_vars (codegen.ml) already computes capture sets.

  • The #199 closure ABI already marshals [fnId, envPtr] and dispatches through __indirect_function_table.

  • The #205 thenableThen primitive already performs host→guest re-entry after a settled Promise.

The transform is new orchestration over proven primitives, not a new runtime mechanism.

Correctness obligations (must hold before any merge)

  1. Affine/linear capture. A linear (@linear / own) local captured into a continuation env is used exactly once by the continuation. The borrow checker must treat continuation capture as that single use; double-resumption must be impossible (a Thenable settles once; thenableThen fires its callback once — assert this).

  2. Effect-row fidelity. The transform triggers iff Async ∈ fd_eff. Net/other effects ride along unchanged; fd_is_async already exists (Deno side) and is reused as the trigger predicate.

  3. Value reconstruction. Response reconstruction from the settled payload needs structured decode. jsonField (one-level scalar) is insufficient for headers: [(String,String)]; this slice introduces a minimal typed reader for the fixed Response shape and defers general decode to #161 (Json). No silent lossiness.

  4. Gate. Full dune test --force (currently 258) green at every commit; a wasm e2e parity test mirrors tests/codegen-deno/http_fetch.*.

Blast radius / non-goals

  • Scope: WasmGC backend Async functions only. Deno-ESM unchanged. Non-Async code unchanged.

  • Not JSPI (explicitly rejected, issue #225). No browser/runtime suspension dependency.

  • Not a general delimited-continuation feature — only the Async effect, only enough to make Thenable transparent.

Implementation refinement (amends the plan; finding 2026-05-18)

Confirmed against bin/main.ml + lib/codegen.ml: the WasmGC backend does not flatten imports (only the source-to-source backends do). A cross-module pub fn is emitted as a Wasm import named <Module>.<fn> — the stdlib AffineScript bodies are not compiled into the unit; the host supplies them. Verified: a unit using Http.get imports {module:"Http", name:"get"}, exactly as httpPostJson is a host import today.

Consequence — the work splits cleanly in two:

  • Host adapter (no compiler-correctness risk). Http.get/fetch/ post/request/is_ok on wasm are host imports, implemented in JS exactly like the proven httpPostJson/#205 surface. This is an adapter, mirroring packages/affine-vscode/mod.js. It delivers the large majority of the typed-wasm Http target with zero risk to the compiler.

  • CPS transform (the hard tail). Needed only for a user Async function that interleaves an async call with subsequent local work in the same body (get(u).status, is_ok(get(u)), …). This is the binding-correctness piece and is gated behind the Ephapax ABI review (typed-wasm#31).

The transparent fetch/get → Response source surface (ADR-013’s goal) is unchanged; it is delivered by the transform sitting on top of the verified Thenable-surface primitives.

Delivery plan (revised; each behind the 258 gate)

  1. This doc → ADR — done (ADR-013).

  2. PR 1 — host adapter + verified foundation: generic Http wasm adapter (packages/affine-http/mod.js) + Thenable-returning lower primitives in stdlib/Http.affine (mirroring the proven httpPostJson/#205 shape) + wasm e2e round-trip with a stubbed fetch. No compiler change; this is the verified base the transform builds on.

  3. PR 2 — transform base case: WasmGC Async user fn with a single async boundary and no live-local capture (get(u).status); emits the continuation registration; once-resumption assert.

  4. PR 3 — live-local capture + composition: #199 env ABI capture (PR3a #236); affine-capture borrow rule (PR3b #237); AsyncAsync chaining (PR3c #238); Response typed reader (PR3d #266); full http_fetch-parity wasm test. DONE.

  5. PR 4 — joint-close: both targets green ⇒ close #160 and #225. DONE — Deno-ESM (#226) + the typed-wasm CPS line PR1..PR3d all merged; this PR joint-closes #160 + #225. The ADR-013 delivery plan is complete; the transparent fetch/get → Response surface is delivered on both targets. (Two follow-ups, not part of this slice, remain tracked: effect-threaded boundary generalisation #234; estate #199/#205 re-validation #235.)

References

  • Issue #225 (this work), #160 (Http primitive), #226 (Deno-ESM, shipped)

  • #199 (function-value closure ABI), #205 (Thenable resolution)

  • typed-wasm ADR-004 (convergence / aggregate library; Ephapax)

  • docs/guides/migration-playbook.adoc #portable-http