|
| 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: ACCEPTED 2026-05-18 — ADR-013 (issue #225) |
| 7 | + |
| 8 | +== Status |
| 9 | + |
| 10 | +ACCEPTED (owner-approved 2026-05-18). Recorded as ADR-013 |
| 11 | +(`SETTLED-DECISIONS.adoc` + `.machine_readable/6a2/META.a2ml`). The |
| 12 | +async ABI section is mirrored into the typed-wasm convergence layer |
| 13 | +with an Ephapax co-stakeholder review flag (typed-wasm ADR-004 — |
| 14 | +tracked separately). Implementation proceeds as the 4-PR plan below, |
| 15 | +each behind the 258-case gate. |
| 16 | + |
| 17 | +== Problem |
| 18 | + |
| 19 | +`stdlib/Http.affine` exposes one portable surface: |
| 20 | + |
| 21 | +[source] |
| 22 | +---- |
| 23 | +pub fn fetch(req: Request) -> Response / { Net, Async } |
| 24 | +---- |
| 25 | + |
| 26 | +The Deno-ESM backend lowers this to a direct `await` (shipped, #226). |
| 27 | +The WasmGC backend cannot: an `extern` call is synchronous and the |
| 28 | +wasm boundary is i32-only. The estate's established async-over-wasm |
| 29 | +mechanism (#205) is *callback-based* — an async extern returns a |
| 30 | +`Thenable` **handle**; the guest registers a continuation closure via |
| 31 | +`thenableThen` and the host re-enters the guest after settlement, |
| 32 | +whereupon `thenableResultJson` yields the value. |
| 33 | + |
| 34 | +That mechanism is correct but its *surface* is callback-shaped |
| 35 | +(`-> Thenable` + explicit `thenableThen`/`thenableResultJson`). The |
| 36 | +owner's decision (issue #225, Option 2) is to keep **one |
| 37 | +byte-identical source surface** — `fetch -> Response` on both targets — |
| 38 | +and make the wasm backend do the continuation plumbing invisibly. |
| 39 | + |
| 40 | +== Decision |
| 41 | + |
| 42 | +On the WasmGC backend only, functions whose effect row includes |
| 43 | +`Async` are compiled via a **selective continuation-passing (CPS) |
| 44 | +transform**. Pure / non-`Async` functions are untouched (no codegen or |
| 45 | +performance change for them). |
| 46 | + |
| 47 | +=== Mechanism |
| 48 | + |
| 49 | +For an `Async` function body, each *async boundary* — a call to an |
| 50 | +`extern` returning under `/ Async`, or a call to another `Async` |
| 51 | +function — splits the body: |
| 52 | + |
| 53 | +. Code up to and including the async call is emitted normally; the call |
| 54 | + yields a `Thenable` handle (the existing #205 host convention). |
| 55 | +. Code *after* the async call becomes a generated **continuation |
| 56 | + function**. Its environment is the set of live locals at the split |
| 57 | + point, captured exactly via the existing #199 closure ABI |
| 58 | + (`find_free_vars` + an `[fnId@0, envPtr@4]` heap record reachable |
| 59 | + through `__indirect_function_table`). |
| 60 | +. The split lowers to `thenableThen(handle, <continuation-closure>)`; |
| 61 | + the enclosing function itself returns a `Thenable` handle |
| 62 | + representing *its own* eventual completion. |
| 63 | +. The continuation, when re-entered by the host, reconstructs the |
| 64 | + resolved value (for `http_request`: a `Response` from the settled |
| 65 | + payload) and resumes. |
| 66 | + |
| 67 | +Because every `Async` caller is transformed identically, `Thenable` |
| 68 | +handles compose up the call chain transparently; at the host boundary |
| 69 | +the outermost `Thenable` is what the host awaits. The AffineScript |
| 70 | +programmer never sees any of this — the effect row is the abstraction, |
| 71 | +the backend chooses the mechanism, exactly as on the Deno side. |
| 72 | + |
| 73 | +=== Why this is sound to build on what exists |
| 74 | + |
| 75 | +* `find_free_vars` (codegen.ml) already computes capture sets. |
| 76 | +* The #199 closure ABI already marshals `[fnId, envPtr]` and dispatches |
| 77 | + through `__indirect_function_table`. |
| 78 | +* The #205 `thenableThen` primitive already performs host→guest |
| 79 | + re-entry after a settled `Promise`. |
| 80 | + |
| 81 | +The transform is new orchestration over proven primitives, not a new |
| 82 | +runtime mechanism. |
| 83 | + |
| 84 | +== Correctness obligations (must hold before any merge) |
| 85 | + |
| 86 | +. *Affine/linear capture.* A linear (`@linear` / `own`) local captured |
| 87 | + into a continuation env is used exactly once *by the continuation*. |
| 88 | + The borrow checker must treat continuation capture as that single |
| 89 | + use; double-resumption must be impossible (a `Thenable` settles once; |
| 90 | + `thenableThen` fires its callback once — assert this). |
| 91 | +. *Effect-row fidelity.* The transform triggers iff `Async ∈ fd_eff`. |
| 92 | + `Net`/other effects ride along unchanged; `fd_is_async` already |
| 93 | + exists (Deno side) and is reused as the trigger predicate. |
| 94 | +. *Value reconstruction.* `Response` reconstruction from the settled |
| 95 | + payload needs structured decode. `jsonField` (one-level scalar) is |
| 96 | + insufficient for `headers: [(String,String)]`; this slice introduces |
| 97 | + a minimal typed reader for the fixed `Response` shape and defers |
| 98 | + general decode to #161 (Json). No silent lossiness. |
| 99 | +. *Gate.* Full `dune test --force` (currently 258) green at every |
| 100 | + commit; a wasm e2e parity test mirrors |
| 101 | + `tests/codegen-deno/http_fetch.*`. |
| 102 | + |
| 103 | +== Blast radius / non-goals |
| 104 | + |
| 105 | +* Scope: WasmGC backend `Async` functions only. Deno-ESM unchanged. |
| 106 | + Non-`Async` code unchanged. |
| 107 | +* Not JSPI (explicitly rejected, issue #225). No browser/runtime |
| 108 | + suspension dependency. |
| 109 | +* Not a general delimited-continuation feature — only the `Async` |
| 110 | + effect, only enough to make `Thenable` transparent. |
| 111 | + |
| 112 | +== Implementation refinement (amends the plan; finding 2026-05-18) |
| 113 | + |
| 114 | +Confirmed against `bin/main.ml` + `lib/codegen.ml`: *the WasmGC backend |
| 115 | +does not flatten imports* (only the source-to-source backends do). A |
| 116 | +cross-module `pub fn` is emitted as a Wasm **import** named |
| 117 | +`<Module>.<fn>` — the stdlib AffineScript bodies are not compiled into |
| 118 | +the unit; the host supplies them. Verified: a unit using `Http.get` |
| 119 | +imports `{module:"Http", name:"get"}`, exactly as `httpPostJson` is a |
| 120 | +host import today. |
| 121 | + |
| 122 | +Consequence — the work splits cleanly in two: |
| 123 | + |
| 124 | +* *Host adapter (no compiler-correctness risk).* `Http.get`/`fetch`/ |
| 125 | + `post`/`request`/`is_ok` on wasm are *host imports*, implemented in |
| 126 | + JS exactly like the proven `httpPostJson`/#205 surface. This is an |
| 127 | + adapter, mirroring `packages/affine-vscode/mod.js`. It delivers the |
| 128 | + large majority of the typed-wasm Http target with zero risk to the |
| 129 | + compiler. |
| 130 | +* *CPS transform (the hard tail).* Needed only for a **user** `Async` |
| 131 | + function that interleaves an async call with subsequent local work |
| 132 | + in the same body (`get(u).status`, `is_ok(get(u))`, …). This is the |
| 133 | + binding-correctness piece and is gated behind the Ephapax ABI review |
| 134 | + (typed-wasm#31). |
| 135 | + |
| 136 | +The transparent `fetch/get -> Response` source surface (ADR-013's |
| 137 | +goal) is unchanged; it is delivered by the transform sitting *on top |
| 138 | +of* the verified Thenable-surface primitives. |
| 139 | + |
| 140 | +== Delivery plan (revised; each behind the 258 gate) |
| 141 | + |
| 142 | +. *This doc → ADR* — done (ADR-013). |
| 143 | +. *PR 1 — host adapter + verified foundation:* generic Http wasm |
| 144 | + adapter (`packages/affine-http/mod.js`) + `Thenable`-returning lower |
| 145 | + primitives in `stdlib/Http.affine` (mirroring the proven |
| 146 | + `httpPostJson`/#205 shape) + wasm e2e round-trip with a stubbed |
| 147 | + `fetch`. No compiler change; this is the verified base the transform |
| 148 | + builds on. |
| 149 | +. *PR 2 — transform base case:* WasmGC `Async` user fn with a single |
| 150 | + async boundary and no live-local capture (`get(u).status`); emits |
| 151 | + the continuation registration; once-resumption assert. |
| 152 | +. *PR 3 — live-local capture + composition:* #199 env ABI capture; |
| 153 | + affine-capture borrow rule; `Async`→`Async` chaining; `Response` |
| 154 | + typed reader; full `http_fetch`-parity wasm test. (Gated behind |
| 155 | + Ephapax ABI review, typed-wasm#31.) |
| 156 | +. *PR 4 — joint-close:* both targets green ⇒ close #160 and #225. |
| 157 | + |
| 158 | +== References |
| 159 | + |
| 160 | +* Issue #225 (this work), #160 (Http primitive), #226 (Deno-ESM, shipped) |
| 161 | +* #199 (function-value closure ABI), #205 (Thenable resolution) |
| 162 | +* typed-wasm ADR-004 (convergence / aggregate library; Ephapax) |
| 163 | +* `docs/guides/migration-playbook.adoc` `#portable-http` |
0 commit comments