Skip to content

Commit 52b799c

Browse files
feat(codegen): #234 S3 — effect-threaded async-boundary detection (ADR-016) (#277)
The WasmGC CPS async boundary is now ANY call whose effect row ⊇ `Async` (the S2b typecheck side-table), generalising the hardcoded `async_primitives=["http_request_thenable"]` set. User-defined `Async` functions now trigger the transform. Mechanism (ADR-016 ordinal bridge): `Effect_sites` gains an ordinal-keyed oracle. Producer: `Typecheck.populate_call_effects` publishes `ordinal -> has_Async` (`eff_has_async` over the resolved row) via `Effect_sites.set_async_by_ord`. Consumer: `Codegen.generate_module` calls `Effect_sites.bind_consumer prog` on the (post-optimizer) AST — the only optimizer is constant folding, which never adds/removes/reorders a call, so the pre-order ordinal of every `ExprApp` is stable across it and the producer's map keys correctly; a call-count mismatch makes `bind_consumer` a no-op. `is_async_prim_call` becomes `Effect_sites.is_async_call e || <old structural set>` — the structural disjunct is the sound table-miss fallback (empty oracle / count mismatch ⇒ exact pre-#234 behaviour; zero regression). S4 retires the hardcoded set. E2E `tests/codegen/effect_async_boundary.{affine,mjs}`: `fetchThing` is a user `extern fn … / { Async }` — NOT `http_request_thenable`, NOT in `async_primitives`. The compiler still synthesised the #205/#199 continuation purely from its effect row (verified: emits `Http.thenableThen` import + `__indirect_function_table` export; host round-trip returns the settled status once; once-resumption traps). Regression: all existing CPS e2e (http_cps_base/capture/chain, http_response_reader) still green — the effect path coexists with structural. `dune test --force` 290/290; full `tools/run_codegen_wasm_tests.sh` green. Refs #234. Not Closes — S4 (retire hardcoded set) remains; owner closes per ISSUE-CLOSURE.
1 parent 37b13f3 commit 52b799c

5 files changed

Lines changed: 189 additions & 1 deletion

File tree

lib/codegen.ml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1919,7 +1919,16 @@ let simple_pat_name (p : pattern) : string option =
19191919
this list as wasm-path async stdlib primitives are added. *)
19201920
let async_primitives = [ "http_request_thenable" ]
19211921

1922+
(* ADR-016 / #234 S3: the async boundary is now ANY call whose effect
1923+
row ⊇ Async (the typecheck side-table, keyed by the shared
1924+
Effect_sites ordinal, consulted via [Effect_sites.is_async_call]),
1925+
generalising the structural-conservative hardcoded set. The
1926+
structural disjunct is retained as the sound table-miss fallback
1927+
(empty oracle / count-mismatch ⇒ exactly the pre-#234 behaviour;
1928+
zero regression). S4 retires [async_primitives]. *)
19221929
let is_async_prim_call (e : expr) : bool =
1930+
Effect_sites.is_async_call e
1931+
||
19231932
match e with
19241933
| ExprApp (ExprVar id, _) -> List.mem id.name async_primitives
19251934
| _ -> false
@@ -2444,6 +2453,13 @@ let gen_imports (loader : Module_loader.t) (imports : import_decl list) (ctx : c
24442453
Defaults to a fresh loader with [Module_loader.default_config ()] so that
24452454
existing call sites keep working without modification. *)
24462455
let generate_module ?loader (prog : program) : wasm_module result =
2456+
(* ADR-016 / #234 S3: bind the effect-side-table oracle to THIS
2457+
(post-optimizer) program's nodes. Ordinals are stable across the
2458+
constant-folding optimizer (it never adds/removes/reorders calls),
2459+
so the producer's ordinal→has-Async map keys correctly here. A
2460+
count-mismatch makes [bind_consumer] a no-op ⇒ structural
2461+
fallback. Safe if the producer never ran (empty ⇒ no-op). *)
2462+
Effect_sites.bind_consumer prog;
24472463
let loader = match loader with
24482464
| Some l -> l
24492465
| None -> Module_loader.create (Module_loader.default_config ())

lib/effect_sites.ml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,62 @@ let to_list (prog : program) : (int * expr) list =
138138
(** [iter f prog] runs [f ordinal node] for every call site, in order. *)
139139
let iter (f : int -> expr -> unit) (prog : program) : unit =
140140
fold_calls (fun () ord e -> f ord e) () prog
141+
142+
(* ── ADR-016 / #234 S3: ordinal-keyed async oracle ─────────────────────
143+
144+
The producer ([Typecheck.populate_call_effects]) records, per call-site
145+
ORDINAL, whether the callee's effect row ⊇ [Async]. The consumer
146+
([Codegen]) runs on the *post-optimizer* AST. The only optimizer is
147+
constant folding ([Opt.fold_constants_program]) which folds literal
148+
bin/unops to literals but never adds, removes, or reorders a function
149+
call ([ExprApp]) — so the pre-order ordinal of every call is STABLE
150+
across optimization. Hence the ORDINAL (not physical identity) bridges
151+
the producer's pre-opt AST and the consumer's post-opt AST, exactly as
152+
ADR-016 specifies.
153+
154+
Lifecycle (per compilation): the producer fills [async_by_ord];
155+
[bind_consumer prog] renumbers the (possibly post-opt) [prog] with the
156+
SAME traversal and materialises a physical-identity predicate over
157+
*that* prog's own nodes. Default empty ⇒ [is_async_call] is always
158+
false ⇒ codegen falls back to the structural recogniser (the exact
159+
pre-#234 behaviour; zero regression if the producer never ran or the
160+
counts disagree). *)
161+
162+
let async_by_ord : (int, bool) Hashtbl.t = Hashtbl.create 64
163+
let consumer_async : (expr * bool) list ref = ref []
164+
165+
(** Producer entry: replace the ordinal→has-async map. *)
166+
let set_async_by_ord (tbl : (int, bool) Hashtbl.t) : unit =
167+
Hashtbl.reset async_by_ord;
168+
Hashtbl.iter (fun k v -> Hashtbl.replace async_by_ord k v) tbl
169+
170+
(** Reset both sides (defensive; long-lived processes / LSP). *)
171+
let clear_async () =
172+
Hashtbl.reset async_by_ord;
173+
consumer_async := []
174+
175+
(** Consumer entry: bind the predicate to [prog]'s own nodes. If the
176+
call count disagrees with the producer's map size, the ordinal
177+
bridge is not trustworthy (an optimizer changed call structure) ⇒
178+
bind nothing, so codegen safely falls back to structural. *)
179+
let bind_consumer (prog : program) : unit =
180+
if Hashtbl.length async_by_ord = 0 || count prog <> Hashtbl.length async_by_ord
181+
then consumer_async := []
182+
else
183+
consumer_async :=
184+
fold_calls
185+
(fun acc ord node ->
186+
let b =
187+
match Hashtbl.find_opt async_by_ord ord with
188+
| Some b -> b
189+
| None -> false
190+
in
191+
(node, b) :: acc)
192+
[] prog
193+
194+
(** [is_async_call node] — does this call's (declared/inferred) effect
195+
row include [Async]? Physical-identity lookup over the bound prog. *)
196+
let is_async_call (node : expr) : bool =
197+
match List.assq node !consumer_async with
198+
| b -> b
199+
| exception Not_found -> false

lib/typecheck.ml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1808,7 +1808,21 @@ let populate_call_effects (ctx : context) (prog : Ast.program) : unit =
18081808
| _ -> EPure
18091809
in
18101810
Hashtbl.replace ctx.call_effects ord row)
1811-
prog
1811+
prog;
1812+
(* ADR-016 / #234 S3: publish the ordinal→has-Async view for the
1813+
codegen consumer. `Async` lowers to the canonical `ESingleton
1814+
"Async"` (or appears in an `EUnion`, e.g. `/{Net,Async}`). *)
1815+
let rec eff_has_async (e : eff) : bool =
1816+
match e with
1817+
| EPure | EVar _ -> false
1818+
| ESingleton s -> s = "Async"
1819+
| EUnion es -> List.exists eff_has_async es
1820+
in
1821+
let async_tbl : (int, bool) Hashtbl.t = Hashtbl.create 64 in
1822+
Hashtbl.iter
1823+
(fun ord e -> Hashtbl.replace async_tbl ord (eff_has_async e))
1824+
ctx.call_effects;
1825+
Effect_sites.set_async_by_ord async_tbl
18121826

18131827
let check_program ?(import_types : (string, scheme) Hashtbl.t option)
18141828
(symbols : Symbol.t) (prog : Ast.program)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// issue #234 S3 — the payoff: the WasmGC CPS async boundary is now
3+
// detected from the callee's *effect row* (the typecheck side-table,
4+
// ADR-016), not a hardcoded name set. `fetchThing` is a USER-defined
5+
// async primitive — it is NOT `http_request_thenable` and NOT in
6+
// codegen's `async_primitives` list. The transform fires solely
7+
// because it is declared `/ { Async }`. Same #205/#199 host scaffold
8+
// as test_http_cps_base; `thenableThen` is in scope so the transform
9+
// can emit it.
10+
11+
use Http::{Thenable, thenableThen};
12+
13+
extern fn fetchThing(url: String) -> Thenable / { Async };
14+
extern fn thingStatus(t: Thenable) -> Int;
15+
16+
pub fn launch() -> Int / { Async } {
17+
let t = fetchThing("https://example.test/x");
18+
thingStatus(t)
19+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// issue #234 S3 — proves the effect-threaded async-boundary detection
3+
// end-to-end: the source uses `fetchThing` (a user-defined `/ {Async}`
4+
// extern, NOT `http_request_thenable`, NOT in codegen's hardcoded
5+
// `async_primitives`). The compiler still synthesised the #205/#199
6+
// continuation purely from the typecheck effect side-table (ADR-016).
7+
// Same host scaffold as test_http_cps_base.mjs.
8+
import assert from 'node:assert/strict';
9+
import { readFile } from 'node:fs/promises';
10+
11+
let inst = null;
12+
const _handles = new Map();
13+
const _results = new Map();
14+
let _next = 1;
15+
let contFired = 0;
16+
let contReturn = null;
17+
let savedCb = null;
18+
19+
function wrapHandler(closurePtr) {
20+
return () => {
21+
const tbl = inst.exports.__indirect_function_table;
22+
const dv = new DataView(inst.exports.memory.buffer);
23+
const fnId = dv.getInt32(closurePtr, true);
24+
const envPtr = dv.getInt32(closurePtr + 4, true);
25+
const fn = tbl.get(fnId);
26+
const args = [envPtr];
27+
while (args.length < fn.length) args.push(0);
28+
return fn(...args);
29+
};
30+
}
31+
32+
const imports = {
33+
wasi_snapshot_preview1: { fd_write: () => 0 },
34+
env: {
35+
fetchThing: (_urlPtr) => {
36+
const h = _next++;
37+
_handles.set(h, Promise.resolve({ status: 200 }));
38+
return h;
39+
},
40+
thingStatus: (tHandle) => {
41+
const v = _results.get(tHandle);
42+
return v && typeof v.status === 'number' ? v.status : -1;
43+
},
44+
},
45+
Http: {
46+
thenableThen: (tHandle, onSettlePtr) => {
47+
const cb = wrapHandler(onSettlePtr);
48+
savedCb = cb;
49+
Promise.resolve(_handles.get(tHandle)).then((v) => {
50+
_results.set(tHandle, v);
51+
contFired += 1;
52+
contReturn = cb();
53+
});
54+
return 1;
55+
},
56+
},
57+
};
58+
59+
const buf = await readFile('./tests/codegen/effect_async_boundary.wasm');
60+
inst = (await WebAssembly.instantiate(buf, imports)).instance;
61+
62+
const disposable = inst.exports.launch();
63+
assert.ok(Number.isInteger(disposable), 'launch() returns synchronously');
64+
assert.equal(contFired, 0, 'continuation deferred until settlement');
65+
66+
await new Promise((r) => setTimeout(r, 0));
67+
await Promise.resolve();
68+
69+
assert.equal(contFired, 1, 'continuation fired exactly once');
70+
assert.equal(
71+
contReturn,
72+
200,
73+
'effect-detected boundary: continuation read the settled status',
74+
);
75+
assert.throws(
76+
() => savedCb(),
77+
(e) => e instanceof WebAssembly.RuntimeError,
78+
'second continuation entry traps (ADR-013 once-resumption)',
79+
);
80+
console.log('test_effect_async_boundary.mjs OK');

0 commit comments

Comments
 (0)