|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | += Async on the WasmGC backend: transparent CPS continuation transform |
| 3 | +Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 4 | +:toc: |
| 5 | +:icons: font |
| 6 | +:status: PROPOSED — awaiting owner approval (issue #225) |
| 7 | + |
| 8 | +== Status |
| 9 | + |
| 10 | +PROPOSED. Design-before-implementation for a high-blast-radius, |
| 11 | +cross-stakeholder change (issue #225). On approval this is promoted to |
| 12 | +an ADR in `SETTLED-DECISIONS.adoc` + `.machine_readable/6a2/META.a2ml`, |
| 13 | +and becomes a section of the typed-wasm convergence ABI (Ephapax is a |
| 14 | +co-stakeholder — typed-wasm ADR-004). |
| 15 | + |
| 16 | +== Problem |
| 17 | + |
| 18 | +`stdlib/Http.affine` exposes one portable surface: |
| 19 | + |
| 20 | +[source] |
| 21 | +---- |
| 22 | +pub fn fetch(req: Request) -> Response / { Net, Async } |
| 23 | +---- |
| 24 | + |
| 25 | +The Deno-ESM backend lowers this to a direct `await` (shipped, #226). |
| 26 | +The WasmGC backend cannot: an `extern` call is synchronous and the |
| 27 | +wasm boundary is i32-only. The estate's established async-over-wasm |
| 28 | +mechanism (#205) is *callback-based* — an async extern returns a |
| 29 | +`Thenable` **handle**; the guest registers a continuation closure via |
| 30 | +`thenableThen` and the host re-enters the guest after settlement, |
| 31 | +whereupon `thenableResultJson` yields the value. |
| 32 | + |
| 33 | +That mechanism is correct but its *surface* is callback-shaped |
| 34 | +(`-> Thenable` + explicit `thenableThen`/`thenableResultJson`). The |
| 35 | +owner's decision (issue #225, Option 2) is to keep **one |
| 36 | +byte-identical source surface** — `fetch -> Response` on both targets — |
| 37 | +and make the wasm backend do the continuation plumbing invisibly. |
| 38 | + |
| 39 | +== Decision |
| 40 | + |
| 41 | +On the WasmGC backend only, functions whose effect row includes |
| 42 | +`Async` are compiled via a **selective continuation-passing (CPS) |
| 43 | +transform**. Pure / non-`Async` functions are untouched (no codegen or |
| 44 | +performance change for them). |
| 45 | + |
| 46 | +=== Mechanism |
| 47 | + |
| 48 | +For an `Async` function body, each *async boundary* — a call to an |
| 49 | +`extern` returning under `/ Async`, or a call to another `Async` |
| 50 | +function — splits the body: |
| 51 | + |
| 52 | +. Code up to and including the async call is emitted normally; the call |
| 53 | + yields a `Thenable` handle (the existing #205 host convention). |
| 54 | +. Code *after* the async call becomes a generated **continuation |
| 55 | + function**. Its environment is the set of live locals at the split |
| 56 | + point, captured exactly via the existing #199 closure ABI |
| 57 | + (`find_free_vars` + an `[fnId@0, envPtr@4]` heap record reachable |
| 58 | + through `__indirect_function_table`). |
| 59 | +. The split lowers to `thenableThen(handle, <continuation-closure>)`; |
| 60 | + the enclosing function itself returns a `Thenable` handle |
| 61 | + representing *its own* eventual completion. |
| 62 | +. The continuation, when re-entered by the host, reconstructs the |
| 63 | + resolved value (for `http_request`: a `Response` from the settled |
| 64 | + payload) and resumes. |
| 65 | + |
| 66 | +Because every `Async` caller is transformed identically, `Thenable` |
| 67 | +handles compose up the call chain transparently; at the host boundary |
| 68 | +the outermost `Thenable` is what the host awaits. The AffineScript |
| 69 | +programmer never sees any of this — the effect row is the abstraction, |
| 70 | +the backend chooses the mechanism, exactly as on the Deno side. |
| 71 | + |
| 72 | +=== Why this is sound to build on what exists |
| 73 | + |
| 74 | +* `find_free_vars` (codegen.ml) already computes capture sets. |
| 75 | +* The #199 closure ABI already marshals `[fnId, envPtr]` and dispatches |
| 76 | + through `__indirect_function_table`. |
| 77 | +* The #205 `thenableThen` primitive already performs host→guest |
| 78 | + re-entry after a settled `Promise`. |
| 79 | + |
| 80 | +The transform is new orchestration over proven primitives, not a new |
| 81 | +runtime mechanism. |
| 82 | + |
| 83 | +== Correctness obligations (must hold before any merge) |
| 84 | + |
| 85 | +. *Affine/linear capture.* A linear (`@linear` / `own`) local captured |
| 86 | + into a continuation env is used exactly once *by the continuation*. |
| 87 | + The borrow checker must treat continuation capture as that single |
| 88 | + use; double-resumption must be impossible (a `Thenable` settles once; |
| 89 | + `thenableThen` fires its callback once — assert this). |
| 90 | +. *Effect-row fidelity.* The transform triggers iff `Async ∈ fd_eff`. |
| 91 | + `Net`/other effects ride along unchanged; `fd_is_async` already |
| 92 | + exists (Deno side) and is reused as the trigger predicate. |
| 93 | +. *Value reconstruction.* `Response` reconstruction from the settled |
| 94 | + payload needs structured decode. `jsonField` (one-level scalar) is |
| 95 | + insufficient for `headers: [(String,String)]`; this slice introduces |
| 96 | + a minimal typed reader for the fixed `Response` shape and defers |
| 97 | + general decode to #161 (Json). No silent lossiness. |
| 98 | +. *Gate.* Full `dune test --force` (currently 258) green at every |
| 99 | + commit; a wasm e2e parity test mirrors |
| 100 | + `tests/codegen-deno/http_fetch.*`. |
| 101 | + |
| 102 | +== Blast radius / non-goals |
| 103 | + |
| 104 | +* Scope: WasmGC backend `Async` functions only. Deno-ESM unchanged. |
| 105 | + Non-`Async` code unchanged. |
| 106 | +* Not JSPI (explicitly rejected, issue #225). No browser/runtime |
| 107 | + suspension dependency. |
| 108 | +* Not a general delimited-continuation feature — only the `Async` |
| 109 | + effect, only enough to make `Thenable` transparent. |
| 110 | + |
| 111 | +== Delivery plan (incremental, each behind the 258 gate) |
| 112 | + |
| 113 | +. *This doc → ADR* on approval (`SETTLED-DECISIONS.adoc` + |
| 114 | + `META.a2ml`); mirror the ABI section into the typed-wasm convergence |
| 115 | + spec; flag Ephapax co-stakeholder review. |
| 116 | +. *PR 1 — transform skeleton:* single async boundary, no live-local |
| 117 | + capture (straight-line `Async` fn ending in one async call). Wasm |
| 118 | + e2e: `Http.get` round-trip via stubbed host. |
| 119 | +. *PR 2 — live-local capture* via the #199 env ABI; multi-statement |
| 120 | + continuations; affine-capture borrow rule + once-resumption assert. |
| 121 | +. *PR 3 — composition:* `Async` fn calling another `Async` fn |
| 122 | + (chained `Thenable`); `Response` typed reader; full |
| 123 | + `http_fetch`-parity wasm test. |
| 124 | +. *PR 4 — joint-close:* both targets green ⇒ close #160 and #225. |
| 125 | + |
| 126 | +== References |
| 127 | + |
| 128 | +* Issue #225 (this work), #160 (Http primitive), #226 (Deno-ESM, shipped) |
| 129 | +* #199 (function-value closure ABI), #205 (Thenable resolution) |
| 130 | +* typed-wasm ADR-004 (convergence / aggregate library; Ephapax) |
| 131 | +* `docs/guides/migration-playbook.adoc` `#portable-http` |
0 commit comments