Skip to content

Commit 3d28b10

Browse files
hyperpolymathclaude
andcommitted
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>
1 parent c6fa8ec commit 3d28b10

9 files changed

Lines changed: 164 additions & 19 deletions

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

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,19 +109,50 @@ runtime mechanism.
109109
* Not a general delimited-continuation feature — only the `Async`
110110
effect, only enough to make `Thenable` transparent.
111111

112-
== Delivery plan (incremental, each behind the 258 gate)
113-
114-
. *This doc → ADR* on approval (`SETTLED-DECISIONS.adoc` +
115-
`META.a2ml`); mirror the ABI section into the typed-wasm convergence
116-
spec; flag Ephapax co-stakeholder review.
117-
. *PR 1 — transform skeleton:* single async boundary, no live-local
118-
capture (straight-line `Async` fn ending in one async call). Wasm
119-
e2e: `Http.get` round-trip via stubbed host.
120-
. *PR 2 — live-local capture* via the #199 env ABI; multi-statement
121-
continuations; affine-capture borrow rule + once-resumption assert.
122-
. *PR 3 — composition:* `Async` fn calling another `Async` fn
123-
(chained `Thenable`); `Response` typed reader; full
124-
`http_fetch`-parity wasm test.
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.)
125156
. *PR 4 — joint-close:* both targets green ⇒ close #160 and #225.
126157

127158
== References

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');

tests/codegen/test_pattern_record.affine

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Test record pattern matching
22

33
fn main() -> Int {
4-
let r = {x: 10, y: 20};
4+
let r = #{x: 10, y: 20};
55

66
let result = match r {
77
{x: a, y: b} => a + b
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Test record with multiple fields
22

33
fn main() -> Int {
4-
let r = {x: 10, y: 20, z: 30};
4+
let r = #{x: 10, y: 20, z: 30};
55
return r.x + r.y + r.z; // Should return 60
66
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Test multiple distinct records
22

33
fn main() -> Int {
4-
let r1 = {a: 5, b: 10};
5-
let r2 = {x: 20, y: 30, z: 40};
4+
let r1 = #{a: 5, b: 10};
5+
let r2 = #{x: 20, y: 30, z: 40};
66

77
return r1.a + r1.b + r2.x + r2.y + r2.z; // 5 + 10 + 20 + 30 + 40 = 105
88
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Test simple record construction and field access
22

33
fn main() -> Int {
4-
let r = {x: 42};
4+
let r = #{x: 42};
55
return r.x; // Should return 42
66
}

tests/codegen/test_tuple_record_array.affine

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
type Point = { x: Int, y: Int, z: Int };
44

55
fn main() -> Int {
6-
let p = { x: 10, y: 20, z: 30 };
6+
let p = #{ x: 10, y: 20, z: 30 };
77
let t = (1, 2, 3, 4);
88
let a = [5, 6];
99
return p.x + p.y + p.z + t.0 + t.1 + t.2 + t.3 + a[0] + a[1];

0 commit comments

Comments
 (0)