Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .machine_readable/6a2/META.a2ml
Original file line number Diff line number Diff line change
Expand Up @@ -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, <continuation-closure>) (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)",
]
32 changes: 32 additions & 0 deletions docs/specs/SETTLED-DECISIONS.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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).
163 changes: 163 additions & 0 deletions docs/specs/async-on-wasm-cps.adoc
Original file line number Diff line number Diff line change
@@ -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 <j.d.a.jewell@open.ac.uk>
: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, <continuation-closure>)`;
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
`<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)

. *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`
28 changes: 28 additions & 0 deletions stdlib/Http.affine
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
15 changes: 15 additions & 0 deletions tests/codegen/http_thenable_skeleton.affine
Original file line number Diff line number Diff line change
@@ -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", "")
}
71 changes: 71 additions & 0 deletions tests/codegen/test_http_thenable_skeleton.mjs
Original file line number Diff line number Diff line change
@@ -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');
Loading
Loading