diff --git a/.machine_readable/6a2/META.a2ml b/.machine_readable/6a2/META.a2ml index fc6a7fd..4651258 100644 --- a/.machine_readable/6a2/META.a2ml +++ b/.machine_readable/6a2/META.a2ml @@ -802,3 +802,73 @@ references = [ "justfile (build / build-loud recipes)", "lib/parser.mly (expr_assign return/resume; record #{ )", ] + +[[adr]] +id = "ADR-013" +status = "accepted" +date = "2026-05-18" +title = "Async on the WasmGC backend: transparent CPS continuation transform" +context = """ +stdlib/Http.affine exposes one portable surface +`fetch(req) -> Response / { Net, Async }`. The Deno-ESM backend lowers +it to a direct `await` (#226, shipped). The WasmGC backend cannot: +extern calls are synchronous, the boundary is i32-only, and the +estate's async-over-wasm mechanism (#205) is callback-shaped +(`-> Thenable` + thenableThen/thenableResultJson). Issue #225 Option 2 +(owner-chosen) requires one BYTE-IDENTICAL source surface on both +targets, so the wasm backend must hide the continuation plumbing. +typed-wasm is the shared convergence ABI (ADR-004); Ephapax is a +co-stakeholder for the async ABI. +""" +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 perf change). + +- Each async boundary (call to an extern returning under /Async, or to + another Async function) splits the body; code after it becomes a + generated continuation function whose env is the live-local capture + set, marshalled via the EXISTING #199 closure ABI + ([fnId@0,envPtr@4] through __indirect_function_table). +- The split lowers to thenableThen(handle, ) (the + EXISTING #205 host->guest re-entry); the enclosing Async function + itself returns a Thenable handle, so Thenable composes transparently + up the call chain. At the host boundary the outermost Thenable is + awaited. The programmer never sees Thenable — effect row is the + abstraction, backend chooses the mechanism (as on the Deno side). +- New orchestration over three proven primitives (find_free_vars, #199 + closure ABI, #205 re-entry); NOT a new runtime, NOT JSPI (rejected), + NOT a general delimited-continuation feature. + +Correctness obligations (binding, pre-merge): +1. A linear/own local captured into a continuation is the borrow + checker's single use; double-resumption impossible (Thenable settles + once; thenableThen fires once — asserted). +2. Transform triggers iff Async in fd_eff; Net/other effects ride + along; fd_is_async reused as predicate. +3. Response reconstruction uses a minimal typed reader (jsonField is + insufficient for headers: [(String,String)]); general decode + deferred to #161. No silent lossiness. +4. Full `dune test --force` (258) green at every commit; wasm e2e + parity test mirrors tests/codegen-deno/http_fetch.*. +""" +consequences = """ +- Single portable Http surface across Deno-ESM and WasmGC; #160 closes + only when both targets are green (joint-close with #225). +- The Thenable-handle + thenableThen continuation protocol is the + agreed async ABI for the typed-wasm convergence layer; Ephapax + co-stakeholder review is required for the convergence-spec section. +- Delivered incrementally (4 PRs, each behind the 258 gate); scope is + WasmGC Async functions only. +- This decision is settled; do not reopen without amending this ADR. +""" +references = [ + "https://github.com/hyperpolymath/affinescript/issues/225", + "https://github.com/hyperpolymath/affinescript/issues/160", + "https://github.com/hyperpolymath/affinescript/pull/226", + "docs/specs/async-on-wasm-cps.adoc (full design)", + "docs/specs/SETTLED-DECISIONS.adoc (ADR-013 section)", + "lib/codegen.ml (find_free_vars; #199 closure ABI)", + "stdlib/Vscode.affine + packages/affine-vscode/mod.js (#205 thenableThen)", + "typed-wasm ADR-004 (convergence / aggregate library; Ephapax)", +] diff --git a/docs/specs/SETTLED-DECISIONS.adoc b/docs/specs/SETTLED-DECISIONS.adoc index 6a1f475..b19b396 100644 --- a/docs/specs/SETTLED-DECISIONS.adoc +++ b/docs/specs/SETTLED-DECISIONS.adoc @@ -211,3 +211,35 @@ build is byte-for-byte unchanged and fully transparent on demand. Settles the disposition of issue #215 residual families. Full ADR in `.machine_readable/6a2/META.a2ml` (ADR-012). + +== Async on WasmGC: Transparent CPS Continuation Transform (ADR-013) + +`stdlib/Http.affine` exposes one portable surface, +`fetch(req) -> Response / { Net, Async }`. The Deno-ESM backend lowers +it to a direct `await` (#226). The WasmGC backend cannot — extern calls +are synchronous, the boundary is i32-only, and the estate's +async-over-wasm mechanism (#205) is callback-shaped. Issue #225 +(Option 2, owner-chosen) requires *one byte-identical source surface on +both targets*: the wasm backend hides the continuation plumbing. + +Decision: on the WasmGC backend only, functions whose effect row +includes `Async` are compiled via a *selective CPS transform*. Each +async boundary splits the body; the post-split code becomes a generated +continuation captured via the existing #199 closure ABI and registered +via the existing #205 `thenableThen` host→guest re-entry. The enclosing +`Async` function returns a `Thenable` handle, so it composes +transparently up the call chain. Pure / non-`Async` code is untouched; +this is *not* JSPI and *not* a general continuation feature — new +orchestration over three proven primitives only. + +Binding correctness obligations: affine/linear capture is the borrow +checker's single use with double-resumption impossible; the transform +triggers iff `Async ∈ fd_eff`; `Response` reconstruction is a typed +reader with no silent lossiness (general decode deferred to #161); the +258-case gate is green at every commit with a wasm e2e parity test. + +The `Thenable`-handle + `thenableThen` continuation protocol is the +agreed async ABI for the typed-wasm convergence layer; Ephapax is a +co-stakeholder (typed-wasm ADR-004). Delivered as 4 incremental, +gated PRs. Full design in `docs/specs/async-on-wasm-cps.adoc`; full ADR +in `.machine_readable/6a2/META.a2ml` (ADR-013). diff --git a/docs/specs/async-on-wasm-cps.adoc b/docs/specs/async-on-wasm-cps.adoc new file mode 100644 index 0000000..d587bf3 --- /dev/null +++ b/docs/specs/async-on-wasm-cps.adoc @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later += Async on the WasmGC backend: transparent CPS continuation transform +Jonathan D.A. Jewell +:toc: +:icons: font +:status: ACCEPTED 2026-05-18 — ADR-013 (issue #225) + +== 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: + +[source] +---- +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 surface** — `fetch -> 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: + +. Code up to and including the async call is emitted normally; the call + yields a `Thenable` handle (the existing #205 host convention). +. 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`). +. The split lowers to `thenableThen(handle, )`; + the enclosing function itself returns a `Thenable` handle + representing *its own* eventual completion. +. 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) + +. *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). +. *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. +. *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. +. *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 +`.` — 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) + +. *This doc → ADR* — done (ADR-013). +. *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. +. *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. +. *PR 3 — live-local capture + composition:* #199 env ABI capture; + affine-capture borrow rule; `Async`→`Async` chaining; `Response` + typed reader; full `http_fetch`-parity wasm test. (Gated behind + Ephapax ABI review, typed-wasm#31.) +. *PR 4 — joint-close:* both targets green ⇒ close #160 and #225. + +== 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` diff --git a/stdlib/Http.affine b/stdlib/Http.affine index 7060ee9..0edfe9d 100644 --- a/stdlib/Http.affine +++ b/stdlib/Http.affine @@ -87,3 +87,31 @@ pub fn post(url: String, body: String) -> Response / { Net, Async } { pub fn is_ok(resp: Response) -> Bool { resp.status >= 200 && resp.status < 300 } + +// ── Lower-level wasm-consumable surface (issue #225, ADR-013) ───────── +// +// On the WasmGC backend an async result cannot be returned synchronously +// across the i32 boundary. The established convergence convention (#205) +// is a `Thenable` host handle the side resolving it observes via the +// shared continuation protocol. `http_request_thenable` mirrors the +// proven `Vscode.httpPostJson` shape exactly: it returns a `Thenable` +// handle the host registers; settlement carries the JSON-encoded +// `{ status, headers, body }`. +// +// This is NOT a second public surface: `fetch`/`get`/`post` above remain +// the single portable source API. On Deno the source-to-source backend +// awaits directly and never touches this. On wasm the transparent CPS +// transform (ADR-013) lowers the high-level surface ONTO this verified +// primitive — callers still write `fetch(req) -> Response`. This +// declaration is the host-import contract + the foundation that +// transform is built and tested on (#225 PR 1). + +pub extern type Thenable; + +/// Issue an HTTP request, returning a host `Thenable` that settles with +/// the JSON-encoded response `{ status, headers, body }`. `body` is the +/// empty string for verbs that carry none. Wasm-path primitive; see the +/// module note. Resolved via the shared #205 continuation protocol. +pub extern fn http_request_thenable(url: String, + method: String, + body: String) -> Thenable / { Net, Async }; diff --git a/tests/codegen/http_thenable_skeleton.affine b/tests/codegen/http_thenable_skeleton.affine new file mode 100644 index 0000000..fa294b1 --- /dev/null +++ b/tests/codegen/http_thenable_skeleton.affine @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// issue #225 PR 1 — typed-wasm Http skeleton. +// +// Proves the WasmGC Http boundary + the #205 Thenable convergence +// protocol end-to-end via a host adapter, with NO compiler transform +// (pure pass-through: `launch` returns the Thenable handle the host +// resolves). The transparent fetch/get -> Response surface (ADR-013's +// CPS transform) is built on top of THIS in PR 2/3. URL is a literal +// so the harness needs no guest-memory writes to drive it. + +use Http::{Thenable, http_request_thenable}; + +pub fn launch() -> Thenable / { Net, Async } { + http_request_thenable("https://example.test/ok", "GET", "") +} diff --git a/tests/codegen/test_http_thenable_skeleton.mjs b/tests/codegen/test_http_thenable_skeleton.mjs new file mode 100644 index 0000000..5c4b5d7 --- /dev/null +++ b/tests/codegen/test_http_thenable_skeleton.mjs @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// issue #225 PR 1 — wasm e2e for the typed-wasm Http skeleton. +// +// Inlines the generic Http host adapter (the reusable package is +// deferred until the Ephapax convergence-ABI review, typed-wasm#31). +// Implements the #205 protocol: http_request_thenable registers a +// Promise keyed by an i32 handle; the harness (the host) resolves it +// and asserts the round-trip. `globalThis.fetch` is stubbed — no +// network. Mirrors the tests/codegen/*.mjs inline-imports style. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +// ── stubbed host fetch (no network) ────────────────────────────────── +globalThis.fetch = async (url, init) => ({ + status: url.includes('/missing') ? 404 : 200, + headers: { forEach: (cb) => cb('text/plain', 'content-type') }, + text: async () => `ok:${init && init.method}`, +}); + +let inst = null; +const _handles = new Map(); +let _next = 1; +const _results = new Map(); + +function readString(ptr) { + const dv = new DataView(inst.exports.memory.buffer); + const len = dv.getUint32(ptr, true); + const bytes = new Uint8Array(inst.exports.memory.buffer, ptr + 4, len); + return new TextDecoder('utf-8').decode(bytes); +} + +// The generic Http convergence adapter (host import surface). +const imports = { + wasi_snapshot_preview1: { fd_write: () => 0 }, + Http: { + http_request_thenable: (urlPtr, methodPtr, bodyPtr) => { + const url = readString(urlPtr); + const method = readString(methodPtr); + const body = readString(bodyPtr); + const h = _next++; + const p = globalThis + .fetch(url, { method, body: body || undefined }) + .then(async (r) => { + const headers = []; + r.headers.forEach((v, k) => headers.push([k, v])); + return { status: r.status, headers, body: await r.text() }; + }) + .catch((e) => ({ __error: String(e) })); + _handles.set(h, p); + p.then((v) => _results.set(h, v)); + return h; + }, + }, +}; + +const buf = await readFile('./tests/codegen/http_thenable_skeleton.wasm'); +const m = await WebAssembly.instantiate(buf, imports); +inst = m.instance; + +// Pure pass-through: launch() returns the Thenable handle. +const handle = inst.exports.launch(); +assert.ok(Number.isInteger(handle) && handle > 0, 'launch returns an i32 Thenable handle'); + +// Host resolves it via the protocol (guest-side resolution is PR 2/3). +const settled = await _handles.get(handle); +assert.equal(settled.status, 200, 'response status round-trips'); +assert.equal(settled.body, 'ok:GET', 'method + body round-trip via readString'); +assert.deepEqual(settled.headers, [['content-type', 'text/plain']], 'headers assoc list'); +assert.equal(_results.get(handle).status, 200, 'thenableResultJson-equivalent payload stored'); + +console.log('test_http_thenable_skeleton.mjs OK'); diff --git a/tests/codegen/test_pattern_record.affine b/tests/codegen/test_pattern_record.affine index 8d95219..f023191 100644 --- a/tests/codegen/test_pattern_record.affine +++ b/tests/codegen/test_pattern_record.affine @@ -1,7 +1,7 @@ // Test record pattern matching fn main() -> Int { - let r = {x: 10, y: 20}; + let r = #{x: 10, y: 20}; let result = match r { {x: a, y: b} => a + b diff --git a/tests/codegen/test_record_multi_field.affine b/tests/codegen/test_record_multi_field.affine index ce45104..400f7d3 100644 --- a/tests/codegen/test_record_multi_field.affine +++ b/tests/codegen/test_record_multi_field.affine @@ -1,6 +1,6 @@ // Test record with multiple fields fn main() -> Int { - let r = {x: 10, y: 20, z: 30}; + let r = #{x: 10, y: 20, z: 30}; return r.x + r.y + r.z; // Should return 60 } diff --git a/tests/codegen/test_record_multiple.affine b/tests/codegen/test_record_multiple.affine index 4b9ec5e..0a950d0 100644 --- a/tests/codegen/test_record_multiple.affine +++ b/tests/codegen/test_record_multiple.affine @@ -1,8 +1,8 @@ // Test multiple distinct records fn main() -> Int { - let r1 = {a: 5, b: 10}; - let r2 = {x: 20, y: 30, z: 40}; + let r1 = #{a: 5, b: 10}; + let r2 = #{x: 20, y: 30, z: 40}; return r1.a + r1.b + r2.x + r2.y + r2.z; // 5 + 10 + 20 + 30 + 40 = 105 } diff --git a/tests/codegen/test_record_simple.affine b/tests/codegen/test_record_simple.affine index 072353f..afd0f28 100644 --- a/tests/codegen/test_record_simple.affine +++ b/tests/codegen/test_record_simple.affine @@ -1,6 +1,6 @@ // Test simple record construction and field access fn main() -> Int { - let r = {x: 42}; + let r = #{x: 42}; return r.x; // Should return 42 } diff --git a/tests/codegen/test_tuple_record_array.affine b/tests/codegen/test_tuple_record_array.affine index d0f597d..6efecc8 100644 --- a/tests/codegen/test_tuple_record_array.affine +++ b/tests/codegen/test_tuple_record_array.affine @@ -3,7 +3,7 @@ type Point = { x: Int, y: Int, z: Int }; fn main() -> Int { - let p = { x: 10, y: 20, z: 30 }; + let p = #{ x: 10, y: 20, z: 30 }; let t = (1, 2, 3, 4); let a = [5, 6]; return p.x + p.y + p.z + t.0 + t.1 + t.2 + t.3 + a[0] + a[1];