Skip to content

Commit e440de3

Browse files
committed
feat(http): #225 PR3d — typed Response reader (ADR-013 §value-reconstruction) (Refs #225 #160)
Depends on: #265 (merged — `++` list-concat codegen; `readResponse` builds `headers` via `headers ++ [pair]`). ADR-013 §Value-reconstruction: after a `Thenable` settles the continuation needs the full `Response`, but `jsonField` (one top-level *scalar*) cannot decode `headers: [(String,String)]`. This adds the *minimal typed reader for the fixed `Response` shape*, deferring general JSON decode to #161. stdlib/Http.affine: - `responseStatus` / `responseBody` / `responseHeaderCount` / `responseHeaderName` / `responseHeaderValue` — host-mediated scalar/string primitives (the proven `thenableResultJson`/`jsonField` convention; no structured value crosses the i32 boundary, the host reads its own settled record keyed by the `Thenable`). - `readResponse(t) -> Response` — reconstructs the fixed `{ status, headers, body }` in-guest; the header loop is the structured decode `jsonField` can't do (and exercises the #255 + #264 fixes). On Deno-ESM this runs flattened; on WasmGC, since imports are not flattened (ADR-013 §refinement), the *consuming unit* reconstructs from the scalar primitives — proven by the e2e. tests/codegen/http_response_reader.{affine,mjs}: full http_fetch-parity wasm e2e (mirrors tests/codegen-deno/http_fetch.* field access). The author writes straight-line code; the CPS transform lowers the async boundary; the continuation reconstructs the typed Response over the scalar primitives and folds status + decoded-header-count + is_ok into 1210 — a value only a correct + structured decode produces. Same #205 Thenable + #199 closure host scaffold as test_http_cps_base; asserts the once-resumption trap (ADR-013 obl. 1) too. Gate: AOT Http OK; `dune test --force` 278/278; `tools/run_codegen_wasm_tests.sh` all pass incl. the new e2e. Zero regression. Refs #225 #160. Not Closes — PR4 joint-closes both (per ISSUE-CLOSURE + the requirements-target convention).
1 parent 910bfe5 commit e440de3

3 files changed

Lines changed: 243 additions & 0 deletions

File tree

stdlib/Http.affine

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,44 @@ pub extern fn http_request_thenable(url: String,
128128
/// single-fire + a guest-side defensive trap on re-entry).
129129
pub extern fn thenableThen(t: Thenable,
130130
on_settle: fn(Unit) -> Int) -> Int / { Async };
131+
132+
// ── Typed Response reader (issue #225 PR3d, ADR-013 §Value-reconstruction) ──
133+
//
134+
// After a `Thenable` settles, the continuation needs the full
135+
// `Response`. `jsonField` (one top-level *scalar* String) cannot decode
136+
// `headers: [(String, String)]` — a JSON array of pairs. ADR-013
137+
// §Value-reconstruction calls for a *minimal typed reader for the fixed
138+
// `Response` shape*, deferring general JSON decode to #161.
139+
//
140+
// These host-mediated primitives expose the settled payload as
141+
// scalars/strings — the same proven extern convention as
142+
// `thenableResultJson`/`jsonField` (the host reads its own settled
143+
// record, keyed by the `Thenable` handle; no structured value crosses
144+
// the i32 boundary). The guest reconstructs the fixed record in
145+
// `readResponse`; its header loop exercises the #255-fixed `while`
146+
// codegen.
147+
148+
pub extern fn responseStatus(t: Thenable) -> Int / { Async };
149+
pub extern fn responseBody(t: Thenable) -> String / { Async };
150+
pub extern fn responseHeaderCount(t: Thenable) -> Int / { Async };
151+
pub extern fn responseHeaderName(t: Thenable, index: Int) -> String / { Async };
152+
pub extern fn responseHeaderValue(t: Thenable, index: Int) -> String / { Async };
153+
154+
/// Reconstruct the fixed `Response` shape from a settled `Thenable`.
155+
///
156+
/// The ADR-013 *minimal typed reader*: it performs the structured
157+
/// `headers` decode `jsonField` cannot, for exactly the fixed
158+
/// `{ status, headers, body }` shape (no silent lossiness — those are
159+
/// the only `Response` fields). General JSON decode stays #161's
160+
/// concern. Call only after `t` has settled (from a `thenableThen`
161+
/// continuation / the CPS-lowered surface).
162+
pub fn readResponse(t: Thenable) -> Response {
163+
let mut headers = [];
164+
let mut i = 0;
165+
let n = responseHeaderCount(t);
166+
while i < n {
167+
headers = headers ++ [(responseHeaderName(t, i), responseHeaderValue(t, i))];
168+
i = i + 1;
169+
}
170+
#{ status: responseStatus(t), headers: headers, body: responseBody(t) }
171+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// issue #225 PR3d — wasm e2e: the typed `Response` reader primitives
3+
// (ADR-013 §Value-reconstruction) + http_fetch-parity on the WasmGC
4+
// path.
5+
//
6+
// Mirrors tests/codegen-deno/http_fetch.* (same Response field access
7+
// on Deno-ESM) but on the wasm Thenable + CPS path. The author writes
8+
// straight-line code: an async boundary (`http_request_thenable`) then
9+
// a continuation that reconstructs the FULL typed `Response`
10+
// (`status` + `headers: [(String,String)]` + `body`) from the settled
11+
// payload via the scalar/string host primitives — the structured
12+
// `headers` decode `jsonField` cannot do.
13+
//
14+
// On WasmGC the backend does NOT flatten imports (ADR-013 §refinement):
15+
// a cross-module `pub fn` like `Http.readResponse` would become a host
16+
// import *returning a `Response` record* — structured value across the
17+
// i32 boundary, exactly what the scalar-extern convention exists to
18+
// avoid. So the reconstruction is done HERE, in the compiled-in
19+
// consuming unit, over the scalar/string primitives (each a simple
20+
// host import like `jsonField`). `Http.readResponse` (stdlib) is the
21+
// same logic for the Deno-ESM backend, which DOES flatten. The header
22+
// loop runs the #255-fixed `while`/`for` codegen.
23+
24+
// `thenableThen` is in the `use` list so it resolves as an import for
25+
// the CPS transform to emit, even though the author never calls it
26+
// (auto-injecting that import is broader transparent-surface work,
27+
// PR3+; same convention as http_cps_base.affine).
28+
use Http::{
29+
Thenable, http_request_thenable, thenableThen,
30+
responseStatus, responseHeaderCount,
31+
responseHeaderName, responseHeaderValue
32+
};
33+
34+
// Reconstruct + fold the typed Response into one i32 the host asserts:
35+
// status(200) + 10*header_count(1) + 1000 (2xx => is_ok) = 1210.
36+
// A wrong/partial decode cannot produce 1210; the header count proves
37+
// the [(String,String)] structured decode jsonField cannot do.
38+
fn decode(t: Thenable) -> Int {
39+
let status = responseStatus(t);
40+
let mut headers = [];
41+
let mut i = 0;
42+
let n = responseHeaderCount(t);
43+
while i < n {
44+
headers = headers ++ [(responseHeaderName(t, i), responseHeaderValue(t, i))];
45+
i = i + 1;
46+
}
47+
let mut hc = 0;
48+
for h in headers {
49+
hc = hc + 1;
50+
}
51+
let ok = if status >= 200 && status < 300 { 1000 } else { 0 };
52+
status + 10 * hc + ok
53+
}
54+
55+
// Single async boundary, then the continuation reconstructs + folds.
56+
pub fn launch() -> Int / { Net, Async } {
57+
let t = http_request_thenable("https://example.test/ok", "GET", "");
58+
decode(t)
59+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// issue #225 PR3d — wasm e2e host for http_response_reader.affine.
3+
//
4+
// Same #205 Thenable + #199 closure scaffold as test_http_cps_base.mjs
5+
// (mirrors packages/affine-vscode/mod.js). The continuation here is the
6+
// typed-Response reconstruction over the scalar/string primitives; the
7+
// harness implements those host imports off the settled record and
8+
// asserts the folded result proves status + structured `headers`
9+
// decode + is_ok — i.e. http_fetch parity (cf. the Deno-ESM
10+
// tests/codegen-deno/http_fetch.* field access) on the wasm path.
11+
import assert from 'node:assert/strict';
12+
import { readFile } from 'node:fs/promises';
13+
14+
// Stubbed host fetch: 1 response header (proves the [(String,String)]
15+
// decode jsonField cannot do), status 200.
16+
globalThis.fetch = async (url, init) => ({
17+
status: url.includes('/missing') ? 404 : 200,
18+
headers: { forEach: (cb) => cb('text/plain', 'content-type') },
19+
text: async () => `ok:${init && init.method}`,
20+
});
21+
22+
let inst = null;
23+
const _handles = new Map();
24+
const _results = new Map();
25+
let _next = 1;
26+
let contFired = 0;
27+
let contReturn = null;
28+
let savedCb = null;
29+
const reads = []; // names of response* primitives the reader invoked
30+
31+
function readString(ptr) {
32+
const dv = new DataView(inst.exports.memory.buffer);
33+
const len = dv.getUint32(ptr, true);
34+
const bytes = new Uint8Array(inst.exports.memory.buffer, ptr + 4, len);
35+
return new TextDecoder('utf-8').decode(bytes);
36+
}
37+
38+
function wrapHandler(closurePtr) {
39+
return () => {
40+
const tbl = inst.exports.__indirect_function_table;
41+
const dv = new DataView(inst.exports.memory.buffer);
42+
const fnId = dv.getInt32(closurePtr, true);
43+
const envPtr = dv.getInt32(closurePtr + 4, true);
44+
const fn = tbl.get(fnId);
45+
const args = [envPtr];
46+
while (args.length < fn.length) args.push(0);
47+
return fn(...args);
48+
};
49+
}
50+
51+
const settled = (tHandle) => _results.get(tHandle);
52+
53+
const imports = {
54+
wasi_snapshot_preview1: { fd_write: () => 0 },
55+
Http: {
56+
http_request_thenable: (urlPtr, methodPtr, bodyPtr) => {
57+
const url = readString(urlPtr);
58+
const method = readString(methodPtr);
59+
const body = readString(bodyPtr);
60+
const h = _next++;
61+
const p = globalThis
62+
.fetch(url, { method, body: body || undefined })
63+
.then(async (r) => {
64+
const headers = [];
65+
r.headers.forEach((v, k) => headers.push([k, v]));
66+
return { status: r.status, headers, body: await r.text() };
67+
})
68+
.catch((e) => ({ __error: String(e) }));
69+
_handles.set(h, p);
70+
return h;
71+
},
72+
thenableThen: (tHandle, onSettlePtr) => {
73+
const cb = wrapHandler(onSettlePtr);
74+
savedCb = cb;
75+
Promise.resolve(_handles.get(tHandle)).then((v) => {
76+
_results.set(tHandle, v);
77+
contFired += 1;
78+
contReturn = cb();
79+
});
80+
return 1;
81+
},
82+
// Typed-reader scalar/string primitives (ADR-013 minimal reader;
83+
// the proven jsonField-style convention — no record crosses the
84+
// i32 boundary). String returns are opaque host handles the guest
85+
// only stores; the fold asserts status + header count + is_ok.
86+
responseStatus: (t) => {
87+
reads.push('status');
88+
const v = settled(t);
89+
return v && typeof v.status === 'number' ? v.status : -1;
90+
},
91+
responseHeaderCount: (t) => {
92+
reads.push('count');
93+
const v = settled(t);
94+
return v && Array.isArray(v.headers) ? v.headers.length : -1;
95+
},
96+
responseHeaderName: (t, i) => {
97+
reads.push(`name${i}`);
98+
return 0x4000 + i; // opaque String handle (guest never decodes)
99+
},
100+
responseHeaderValue: (t, i) => {
101+
reads.push(`value${i}`);
102+
return 0x8000 + i;
103+
},
104+
},
105+
};
106+
107+
const buf = await readFile('./tests/codegen/http_response_reader.wasm');
108+
inst = (await WebAssembly.instantiate(buf, imports)).instance;
109+
110+
// 1. launch() returns synchronously (async deferred via the transform).
111+
const disposable = inst.exports.launch();
112+
assert.ok(Number.isInteger(disposable), 'launch() returns synchronously');
113+
assert.equal(contFired, 0, 'continuation deferred until settlement');
114+
115+
// 2. settle.
116+
await new Promise((r) => setTimeout(r, 0));
117+
await Promise.resolve();
118+
119+
assert.equal(contFired, 1, 'continuation fired exactly once');
120+
// status 200 + 10*headerCount(1) + 1000 (200 is 2xx) = 1210. Only a
121+
// correct status + structured-header decode + is_ok yields this.
122+
assert.equal(
123+
contReturn,
124+
1210,
125+
'typed Response reconstructed: status=200, headers decoded (count=1), is_ok=true',
126+
);
127+
assert.ok(
128+
reads.includes('status') &&
129+
reads.includes('count') &&
130+
reads.includes('name0') &&
131+
reads.includes('value0'),
132+
'continuation invoked the typed-reader primitives (incl. per-header decode)',
133+
);
134+
135+
// 3. ADR-013 obligation 1: a forced second resumption traps.
136+
assert.throws(
137+
() => savedCb(),
138+
(e) => e instanceof WebAssembly.RuntimeError,
139+
'second continuation entry traps (once-resumption guard)',
140+
);
141+
assert.equal(contFired, 1, 'trapped re-entry did not re-run the continuation');
142+
143+
console.log('test_http_response_reader.mjs OK');

0 commit comments

Comments
 (0)