From c6ee9e4b7575259b6811d348c204807e204ff374 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 19 May 2026 20:54:51 +0100 Subject: [PATCH] =?UTF-8?q?test(codegen):=20#235=20=E2=80=94=20engine-vali?= =?UTF-8?q?date=20the=20#199=20closure=20ABI=20independent=20of=20CPS=20(R?= =?UTF-8?q?efs=20#235)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until #225 PR2 the #199 closure ABI ([fnId@0,envPtr@4] via the exported __indirect_function_table) had only ever been *statically* compiled estate-wide — the blind spot the two PR2 defects hid in. The async tests now exercise it but only THROUGH the CPS transform; the existing tests/codegen/test_closure_*.affine have NO `.mjs` host (compile-only). This adds the missing negative-control. tests/codegen/closure_indirect_dispatch.{affine,mjs}: a captured closure (`fn(u:Unit) => base + 35`, base = local 7) passed to a plain `extern fn invokeCallback(cb: fn(Unit)->Int)->Int`; the host dispatches it via __indirect_function_table (wrapHandler, identical to packages/affine-vscode/mod.js). Verified the unit imports ONLY `env.invokeCallback` (no thenableThen ⇒ async transform provably not involved) yet still exports `__indirect_function_table`; asserts table exported + closure fired once + captured local reached via envPtr (7+35=42). Protects the shared #199 path for ALL closure users independent of #205. Gate: full tools/run_codegen_wasm_tests.sh green incl. the new test; dune test --force 290/290. Zero regression. #235 estate-consumer audit (recorded on the issue): the ONLY estate consumer that genuinely warrants its own runtime smoke is rsr-certifier (standards#123 — full #199 closures + #205 path, ships its own wrapHandler clone, zero wasm tests). my-lang (#199-only), idaptik/reposystem (.ts generic vscode, not this ABI), boj-server/ gitbot-fleet (no consumer) are defensibly static-only now this upstream guarantee exists. Refs #235 — deliverable (1) done; the rsr-certifier per-repo smoke (standards repo) is the remaining tracked item, so not Closes. --- .../codegen/closure_indirect_dispatch.affine | 21 +++++++ .../test_closure_indirect_dispatch.mjs | 57 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 tests/codegen/closure_indirect_dispatch.affine create mode 100644 tests/codegen/test_closure_indirect_dispatch.mjs diff --git a/tests/codegen/closure_indirect_dispatch.affine b/tests/codegen/closure_indirect_dispatch.affine new file mode 100644 index 00000000..4fffe7af --- /dev/null +++ b/tests/codegen/closure_indirect_dispatch.affine @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// issue #235 — the #199 closure-pointer ABI exercised in a real wasm +// engine INDEPENDENT of the async / CPS transform. +// +// Until #225 PR2 the #199 ABI ([fnId@0,envPtr@4] dispatched via the +// exported __indirect_function_table) had only ever been STATICALLY +// compiled across the estate — the two defects PR2 found had hidden in +// that blind spot. The async tests now exercise it, but only *through* +// the CPS transform. tests/codegen/test_closure_capture.affine (and +// the other test_closure_*.affine) have NO `.mjs` host — they are +// compile-only. This fixture closes that gap: a captured-closure +// callback handed to a plain `extern fn`, dispatched by the host via +// __indirect_function_table, with NO `thenableThen` / Async effect, so +// the async transform is provably not involved. + +extern fn invokeCallback(cb: fn(Unit) -> Int) -> Int; + +pub fn launch() -> Int { + let base = 7; + invokeCallback(fn(u: Unit) => base + 35) // host-dispatched -> 42 +} diff --git a/tests/codegen/test_closure_indirect_dispatch.mjs b/tests/codegen/test_closure_indirect_dispatch.mjs new file mode 100644 index 00000000..61084432 --- /dev/null +++ b/tests/codegen/test_closure_indirect_dispatch.mjs @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// issue #235 — proves the #199 [fnId@0,envPtr@4] closure ABI through +// the exported __indirect_function_table in a real wasm engine, with +// NO async/CPS transform involved (no thenableThen import). This is +// the negative-control the http_cps_* tests cannot isolate: it +// validates the table export + captured-env marshalling for ANY +// closure user (rsr-certifier, my-lang, …), independent of #205. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +let inst = null; +let cbFired = 0; + +// Identical dispatch to wrapHandler in packages/affine-vscode/mod.js: +// closure = heap [i32 fnId @+0][i32 envPtr @+4]; look fnId up in the +// exported table, call with envPtr first, zero-pad to arity. +function wrapHandler(closurePtr) { + return () => { + const tbl = inst.exports.__indirect_function_table; + const dv = new DataView(inst.exports.memory.buffer); + const fnId = dv.getInt32(closurePtr, true); + const envPtr = dv.getInt32(closurePtr + 4, true); + const fn = tbl.get(fnId); + const args = [envPtr]; + while (args.length < fn.length) args.push(0); + return fn(...args); + }; +} + +const imports = { + wasi_snapshot_preview1: { fd_write: () => 0 }, + env: { + // Plain synchronous extern: dispatch the closure immediately — + // no Promise/Thenable. Proves the captured local (7) survives + // table dispatch via envPtr. + invokeCallback: (closurePtr) => { + cbFired += 1; + return wrapHandler(closurePtr)(); + }, + }, +}; + +const buf = await readFile('./tests/codegen/closure_indirect_dispatch.wasm'); +inst = (await WebAssembly.instantiate(buf, imports)).instance; + +assert.ok( + inst.exports.__indirect_function_table, + '__indirect_function_table is exported for a closure-bearing, non-async unit', +); +const r = inst.exports.launch(); +assert.equal(cbFired, 1, 'host dispatched the closure exactly once'); +assert.equal( + r, + 42, + 'captured local (7) reached the closure via envPtr; 7 + 35 = 42', +); +console.log('test_closure_indirect_dispatch.mjs OK');