Skip to content

Commit 417b97c

Browse files
feat: #225 typed-wasm Http — ADR-013 + PR1 verified Thenable foundation (Refs #160 #225) (#227)
* docs(design): Async-on-wasm transparent CPS transform — proposal (#225) Design-before-implementation for the typed-wasm Http target (issue #225, owner-chosen Option 2: one byte-identical fetch->Response surface on both targets). Selective CPS/continuation transform of Async-effect functions on the WasmGC backend only, built on the existing #199 closure ABI + #205 thenableThen re-entry. States the correctness obligations (affine capture / once-resumption / value reconstruction) and a 4-PR incremental plan, each behind the 258 gate. PROPOSED — awaiting owner approval; promotes to ADR + typed-wasm convergence ABI section (Ephapax co-stakeholder) on sign-off. Refs #225 #160. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(adr): promote Async-on-wasm CPS transform to ADR-013 (#225) Owner-approved 2026-05-18. SETTLED-DECISIONS.adoc + META.a2ml ADR-013 entry; design doc status PROPOSED -> ACCEPTED. typed-wasm convergence ABI section + Ephapax co-stakeholder review tracked separately. Refs #225 #160. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(stdlib,wasm): #225 PR1 — typed-wasm Http skeleton + verified Thenable foundation Pivotal finding (recorded in the design doc): the WasmGC backend does NOT flatten imports — a cross-module `pub fn` is a Wasm import `<Module>.<fn>`, so stdlib `Http.*` are host imports on wasm (exactly like the proven `Vscode.httpPostJson`). This splits #225 cleanly: a host adapter (no compiler-correctness risk, the bulk of the value) + the CPS transform (the hard tail, only for user Async fns that interleave). Does NOT degrade typed-wasm: network I/O is host-mediated on every wasm target (WASI/JSPI/browser alike); guest logic is still fully compiled. PR1 delivers the verified foundation the transform builds on: - stdlib/Http.affine: `pub extern type Thenable` + `http_request_thenable (url, method, body) -> Thenable / { Net, Async }`, mirroring the proven httpPostJson/#205 shape. Single portable source surface (fetch/get/post) unchanged; this is the wasm-path host-import contract, not a second public API. - tests/codegen/http_thenable_skeleton.affine + harness: wasm e2e proving the boundary + #205 protocol end-to-end with a stubbed `globalThis.fetch` (no network), pure pass-through (no transform). - docs/specs/async-on-wasm-cps.adoc: implementation refinement + revised 4-PR plan. Also migrates 5 pre-existing baseline-rot wasm fixtures (test_record_simple/multiple/multi_field/pattern_record/ tuple_record_array) from retired `{ f: x }` record literals to `#{` (#218 sweep skipped in-tree wasm fixtures; same as the Deno fixtures fixed in #226). Record patterns/struct decls correctly left as `{}`. Gate green: dune test --force 258; full wasm suite green; Http.affine Deno-AOT clean. Refs #225 #160. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7018b92 commit 417b97c

11 files changed

Lines changed: 385 additions & 6 deletions

.machine_readable/6a2/META.a2ml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,3 +802,73 @@ references = [
802802
"justfile (build / build-loud recipes)",
803803
"lib/parser.mly (expr_assign return/resume; record #{ )",
804804
]
805+
806+
[[adr]]
807+
id = "ADR-013"
808+
status = "accepted"
809+
date = "2026-05-18"
810+
title = "Async on the WasmGC backend: transparent CPS continuation transform"
811+
context = """
812+
stdlib/Http.affine exposes one portable surface
813+
`fetch(req) -> Response / { Net, Async }`. The Deno-ESM backend lowers
814+
it to a direct `await` (#226, shipped). The WasmGC backend cannot:
815+
extern calls are synchronous, the boundary is i32-only, and the
816+
estate's async-over-wasm mechanism (#205) is callback-shaped
817+
(`-> Thenable` + thenableThen/thenableResultJson). Issue #225 Option 2
818+
(owner-chosen) requires one BYTE-IDENTICAL source surface on both
819+
targets, so the wasm backend must hide the continuation plumbing.
820+
typed-wasm is the shared convergence ABI (ADR-004); Ephapax is a
821+
co-stakeholder for the async ABI.
822+
"""
823+
decision = """
824+
On the WasmGC backend ONLY, functions whose effect row includes `Async`
825+
are compiled via a selective continuation-passing (CPS) transform.
826+
Pure / non-Async functions are untouched (no codegen or perf change).
827+
828+
- Each async boundary (call to an extern returning under /Async, or to
829+
another Async function) splits the body; code after it becomes a
830+
generated continuation function whose env is the live-local capture
831+
set, marshalled via the EXISTING #199 closure ABI
832+
([fnId@0,envPtr@4] through __indirect_function_table).
833+
- The split lowers to thenableThen(handle, <continuation-closure>) (the
834+
EXISTING #205 host->guest re-entry); the enclosing Async function
835+
itself returns a Thenable handle, so Thenable composes transparently
836+
up the call chain. At the host boundary the outermost Thenable is
837+
awaited. The programmer never sees Thenable — effect row is the
838+
abstraction, backend chooses the mechanism (as on the Deno side).
839+
- New orchestration over three proven primitives (find_free_vars, #199
840+
closure ABI, #205 re-entry); NOT a new runtime, NOT JSPI (rejected),
841+
NOT a general delimited-continuation feature.
842+
843+
Correctness obligations (binding, pre-merge):
844+
1. A linear/own local captured into a continuation is the borrow
845+
checker's single use; double-resumption impossible (Thenable settles
846+
once; thenableThen fires once — asserted).
847+
2. Transform triggers iff Async in fd_eff; Net/other effects ride
848+
along; fd_is_async reused as predicate.
849+
3. Response reconstruction uses a minimal typed reader (jsonField is
850+
insufficient for headers: [(String,String)]); general decode
851+
deferred to #161. No silent lossiness.
852+
4. Full `dune test --force` (258) green at every commit; wasm e2e
853+
parity test mirrors tests/codegen-deno/http_fetch.*.
854+
"""
855+
consequences = """
856+
- Single portable Http surface across Deno-ESM and WasmGC; #160 closes
857+
only when both targets are green (joint-close with #225).
858+
- The Thenable-handle + thenableThen continuation protocol is the
859+
agreed async ABI for the typed-wasm convergence layer; Ephapax
860+
co-stakeholder review is required for the convergence-spec section.
861+
- Delivered incrementally (4 PRs, each behind the 258 gate); scope is
862+
WasmGC Async functions only.
863+
- This decision is settled; do not reopen without amending this ADR.
864+
"""
865+
references = [
866+
"https://github.com/hyperpolymath/affinescript/issues/225",
867+
"https://github.com/hyperpolymath/affinescript/issues/160",
868+
"https://github.com/hyperpolymath/affinescript/pull/226",
869+
"docs/specs/async-on-wasm-cps.adoc (full design)",
870+
"docs/specs/SETTLED-DECISIONS.adoc (ADR-013 section)",
871+
"lib/codegen.ml (find_free_vars; #199 closure ABI)",
872+
"stdlib/Vscode.affine + packages/affine-vscode/mod.js (#205 thenableThen)",
873+
"typed-wasm ADR-004 (convergence / aggregate library; Ephapax)",
874+
]

docs/specs/SETTLED-DECISIONS.adoc

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,35 @@ build is byte-for-byte unchanged and fully transparent on demand.
211211

212212
Settles the disposition of issue #215 residual families. Full ADR in
213213
`.machine_readable/6a2/META.a2ml` (ADR-012).
214+
215+
== Async on WasmGC: Transparent CPS Continuation Transform (ADR-013)
216+
217+
`stdlib/Http.affine` exposes one portable surface,
218+
`fetch(req) -> Response / { Net, Async }`. The Deno-ESM backend lowers
219+
it to a direct `await` (#226). The WasmGC backend cannot — extern calls
220+
are synchronous, the boundary is i32-only, and the estate's
221+
async-over-wasm mechanism (#205) is callback-shaped. Issue #225
222+
(Option 2, owner-chosen) requires *one byte-identical source surface on
223+
both targets*: the wasm backend hides the continuation plumbing.
224+
225+
Decision: on the WasmGC backend only, functions whose effect row
226+
includes `Async` are compiled via a *selective CPS transform*. Each
227+
async boundary splits the body; the post-split code becomes a generated
228+
continuation captured via the existing #199 closure ABI and registered
229+
via the existing #205 `thenableThen` host→guest re-entry. The enclosing
230+
`Async` function returns a `Thenable` handle, so it composes
231+
transparently up the call chain. Pure / non-`Async` code is untouched;
232+
this is *not* JSPI and *not* a general continuation feature — new
233+
orchestration over three proven primitives only.
234+
235+
Binding correctness obligations: affine/linear capture is the borrow
236+
checker's single use with double-resumption impossible; the transform
237+
triggers iff `Async ∈ fd_eff`; `Response` reconstruction is a typed
238+
reader with no silent lossiness (general decode deferred to #161); the
239+
258-case gate is green at every commit with a wasm e2e parity test.
240+
241+
The `Thenable`-handle + `thenableThen` continuation protocol is the
242+
agreed async ABI for the typed-wasm convergence layer; Ephapax is a
243+
co-stakeholder (typed-wasm ADR-004). Delivered as 4 incremental,
244+
gated PRs. Full design in `docs/specs/async-on-wasm-cps.adoc`; full ADR
245+
in `.machine_readable/6a2/META.a2ml` (ADR-013).

docs/specs/async-on-wasm-cps.adoc

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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`

stdlib/Http.affine

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,31 @@ pub fn post(url: String, body: String) -> Response / { Net, Async } {
8787
pub fn is_ok(resp: Response) -> Bool {
8888
resp.status >= 200 && resp.status < 300
8989
}
90+
91+
// ── Lower-level wasm-consumable surface (issue #225, ADR-013) ─────────
92+
//
93+
// On the WasmGC backend an async result cannot be returned synchronously
94+
// across the i32 boundary. The established convergence convention (#205)
95+
// is a `Thenable` host handle the side resolving it observes via the
96+
// shared continuation protocol. `http_request_thenable` mirrors the
97+
// proven `Vscode.httpPostJson` shape exactly: it returns a `Thenable`
98+
// handle the host registers; settlement carries the JSON-encoded
99+
// `{ status, headers, body }`.
100+
//
101+
// This is NOT a second public surface: `fetch`/`get`/`post` above remain
102+
// the single portable source API. On Deno the source-to-source backend
103+
// awaits directly and never touches this. On wasm the transparent CPS
104+
// transform (ADR-013) lowers the high-level surface ONTO this verified
105+
// primitive — callers still write `fetch(req) -> Response`. This
106+
// declaration is the host-import contract + the foundation that
107+
// transform is built and tested on (#225 PR 1).
108+
109+
pub extern type Thenable;
110+
111+
/// Issue an HTTP request, returning a host `Thenable` that settles with
112+
/// the JSON-encoded response `{ status, headers, body }`. `body` is the
113+
/// empty string for verbs that carry none. Wasm-path primitive; see the
114+
/// module note. Resolved via the shared #205 continuation protocol.
115+
pub extern fn http_request_thenable(url: String,
116+
method: String,
117+
body: String) -> Thenable / { Net, Async };
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// issue #225 PR 1 — typed-wasm Http skeleton.
3+
//
4+
// Proves the WasmGC Http boundary + the #205 Thenable convergence
5+
// protocol end-to-end via a host adapter, with NO compiler transform
6+
// (pure pass-through: `launch` returns the Thenable handle the host
7+
// resolves). The transparent fetch/get -> Response surface (ADR-013's
8+
// CPS transform) is built on top of THIS in PR 2/3. URL is a literal
9+
// so the harness needs no guest-memory writes to drive it.
10+
11+
use Http::{Thenable, http_request_thenable};
12+
13+
pub fn launch() -> Thenable / { Net, Async } {
14+
http_request_thenable("https://example.test/ok", "GET", "")
15+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// issue #225 PR 1 — wasm e2e for the typed-wasm Http skeleton.
3+
//
4+
// Inlines the generic Http host adapter (the reusable package is
5+
// deferred until the Ephapax convergence-ABI review, typed-wasm#31).
6+
// Implements the #205 protocol: http_request_thenable registers a
7+
// Promise keyed by an i32 handle; the harness (the host) resolves it
8+
// and asserts the round-trip. `globalThis.fetch` is stubbed — no
9+
// network. Mirrors the tests/codegen/*.mjs inline-imports style.
10+
import assert from 'node:assert/strict';
11+
import { readFile } from 'node:fs/promises';
12+
13+
// ── stubbed host fetch (no network) ──────────────────────────────────
14+
globalThis.fetch = async (url, init) => ({
15+
status: url.includes('/missing') ? 404 : 200,
16+
headers: { forEach: (cb) => cb('text/plain', 'content-type') },
17+
text: async () => `ok:${init && init.method}`,
18+
});
19+
20+
let inst = null;
21+
const _handles = new Map();
22+
let _next = 1;
23+
const _results = new Map();
24+
25+
function readString(ptr) {
26+
const dv = new DataView(inst.exports.memory.buffer);
27+
const len = dv.getUint32(ptr, true);
28+
const bytes = new Uint8Array(inst.exports.memory.buffer, ptr + 4, len);
29+
return new TextDecoder('utf-8').decode(bytes);
30+
}
31+
32+
// The generic Http convergence adapter (host import surface).
33+
const imports = {
34+
wasi_snapshot_preview1: { fd_write: () => 0 },
35+
Http: {
36+
http_request_thenable: (urlPtr, methodPtr, bodyPtr) => {
37+
const url = readString(urlPtr);
38+
const method = readString(methodPtr);
39+
const body = readString(bodyPtr);
40+
const h = _next++;
41+
const p = globalThis
42+
.fetch(url, { method, body: body || undefined })
43+
.then(async (r) => {
44+
const headers = [];
45+
r.headers.forEach((v, k) => headers.push([k, v]));
46+
return { status: r.status, headers, body: await r.text() };
47+
})
48+
.catch((e) => ({ __error: String(e) }));
49+
_handles.set(h, p);
50+
p.then((v) => _results.set(h, v));
51+
return h;
52+
},
53+
},
54+
};
55+
56+
const buf = await readFile('./tests/codegen/http_thenable_skeleton.wasm');
57+
const m = await WebAssembly.instantiate(buf, imports);
58+
inst = m.instance;
59+
60+
// Pure pass-through: launch() returns the Thenable handle.
61+
const handle = inst.exports.launch();
62+
assert.ok(Number.isInteger(handle) && handle > 0, 'launch returns an i32 Thenable handle');
63+
64+
// Host resolves it via the protocol (guest-side resolution is PR 2/3).
65+
const settled = await _handles.get(handle);
66+
assert.equal(settled.status, 200, 'response status round-trips');
67+
assert.equal(settled.body, 'ok:GET', 'method + body round-trip via readString');
68+
assert.deepEqual(settled.headers, [['content-type', 'text/plain']], 'headers assoc list');
69+
assert.equal(_results.get(handle).status, 200, 'thenableResultJson-equivalent payload stored');
70+
71+
console.log('test_http_thenable_skeleton.mjs OK');

0 commit comments

Comments
 (0)