Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m52s
When a `perform` fired inside a tree-walked eval_expr path — sf_letrec init
exprs / non-last body exprs, expand_macro body, qq_expand unquote,
sf_dynamic_wind / sf_scope / sf_provide bodies — cek_run raised
"IO suspension in non-IO context" and swallowed the suspension. The hook
that converts the CEK suspended state to VmSuspended (so the outer driver
sees it as a resumable suspension object) was defined in sx_vm.ml but
never invoked from cek_run.
Repro in Node.js (hosts/ocaml/browser/test_letrec_resume.js):
(letrec ((x (perform {:op "io"}))) "ok") ;; threw the error
(letrec ((x 1)) (perform {:op "io"}) "after") ;; threw the error
The originally reported browser symptom — "[sx] resume: Not callable: nil"
after hs-wait resumes inside a letrec — was the same root cause showing
through the JIT/VM resume path instead of as a top-level error.
Fix: cek_run and cek_run_iterative now check !_cek_io_suspend_hook and
invoke it when the loop terminates in a suspended state. The hook (set by
sx_vm.ml in the browser, by run_tests.ml in the test runner) converts the
suspension to VmSuspended / resolves IO synchronously. When the hook is
unset (pure-CEK harness), the legacy Eval_error is raised so misuse stays
visible.
Also patches:
- hosts/ocaml/bootstrap.py — regex-patches the transpiled cek_run on regen
so the fix survives a fresh `python3 hosts/ocaml/bootstrap.py` cycle.
- hosts/ocaml/browser/sx_browser.ml — api_eval / api_eval_vm / api_eval_expr
now catch VmSuspended and surface a clean error string (K.eval has no
driver to resume; callers who want resumption use callFn).
Tests:
- spec/tests/test-letrec-resume-treewalk.sx — 7 CEK-level regression tests
covering letrec init / non-last body, scope/provide bodies, sibling
fn-after-perform. All 7 fail in baseline ("IO suspension in non-IO
context"), all 7 pass with the fix.
- hosts/ocaml/browser/test_letrec_resume.js — 13 WASM kernel tests via
callFn driveSync, including the wait-boot pattern from the briefing.
All 13 pass.
Suite results: 4557 pass / 1338 fail (was 4550 / 1339); +7 new passes,
-1 flaky timeout (hs-upstream-if sieve), no regressions.
173 lines
5.9 KiB
JavaScript
173 lines
5.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// Repro: letrec sibling bindings nil after perform/resume in browser kernel
|
|
//
|
|
// Bug: After a CEK IO suspension (perform / hs-wait) resumes in the
|
|
// WASM browser kernel, calling a sibling letrec binding could return
|
|
// nil, with the error surfaced as `[sx] resume: Not callable: nil`.
|
|
//
|
|
// Root cause: cek-run / cek_run_iterative raised
|
|
// `"IO suspension in non-IO context"` when a tree-walked eval_expr
|
|
// (sf_letrec init exprs / non-last body, macro body, qq unquote, scope
|
|
// body, provide body, dynamic-wind) hit a perform. The CEK suspension
|
|
// was created correctly but never propagated through the OCaml-side
|
|
// _cek_io_suspend_hook, so the outer driver never saw VmSuspended.
|
|
//
|
|
// Fix: cek_run / cek_run_iterative now invoke _cek_io_suspend_hook on
|
|
// suspension (raising VmSuspended for the outer driver). When the hook
|
|
// is unset (pure-CEK harness), they fall back to the legacy error.
|
|
//
|
|
// This test exercises the WASM kernel through K.callFn — the path that
|
|
// browser event handlers use. Suspension surfaces as a JS object with
|
|
// {suspended, request, resume(result)} that the test drives synchronously.
|
|
//
|
|
// Companion: spec/tests/test-letrec-resume-treewalk.sx tests the
|
|
// CEK-only path through the OCaml test runner.
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
|
|
const KERNEL = path.join(__dirname, '..', '_build', 'default', 'browser', 'sx_browser.bc.js');
|
|
if (!fs.existsSync(KERNEL)) {
|
|
console.error('FATAL: missing ' + KERNEL + ' — run `dune build` from hosts/ocaml first');
|
|
process.exit(2);
|
|
}
|
|
require(KERNEL);
|
|
const K = globalThis.SxKernel;
|
|
|
|
let passed = 0, failed = 0;
|
|
const failures = [];
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
const r = fn();
|
|
if (r === true) {
|
|
passed++;
|
|
console.log(' PASS: ' + name);
|
|
} else {
|
|
failed++;
|
|
failures.push({ name, error: 'got ' + JSON.stringify(r) });
|
|
console.log(' FAIL: ' + name + ' — got ' + JSON.stringify(r));
|
|
}
|
|
} catch (e) {
|
|
failed++;
|
|
failures.push({ name, error: e.message || String(e) });
|
|
console.log(' FAIL: ' + name + ' — ' + (e.message || e));
|
|
}
|
|
}
|
|
|
|
function driveSync(result) {
|
|
while (result && typeof result === 'object' && result.suspended) {
|
|
result = result.resume(null);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function callExpr(src) {
|
|
K.eval('(define _t-fn (fn () ' + src + '))');
|
|
const fn = K.eval('_t-fn');
|
|
return driveSync(K.callFn(fn, []));
|
|
}
|
|
|
|
console.log('\n=== letrec + perform/resume regression tests ===\n');
|
|
|
|
test('basic letrec without perform', () =>
|
|
callExpr('(letrec ((f (fn () "ok"))) (f))') === 'ok');
|
|
|
|
test('callFn perform suspends and resumes with nil', () => {
|
|
K.eval('(define _t-perform (fn () (perform {:op "io"})))');
|
|
let r = K.callFn(K.eval('_t-perform'), []);
|
|
if (!r || !r.suspended) return 'no suspension: ' + JSON.stringify(r);
|
|
return r.resume(null) === null;
|
|
});
|
|
|
|
test('letrec, single binding, perform/resume', () =>
|
|
callExpr('(letrec ((f (fn () (perform {:op "io"})))) (f))') === null);
|
|
|
|
test('letrec, 2 bindings, body calls sibling after suspended call', () =>
|
|
callExpr(`
|
|
(letrec
|
|
((wait-then (fn () (do (perform {:op "io"}) "wait-done")))
|
|
(other-fn (fn () "other-result")))
|
|
(do (wait-then) (other-fn)))`) === 'other-result');
|
|
|
|
test('letrec, suspending fn calls sibling after own perform', () =>
|
|
callExpr(`
|
|
(letrec
|
|
((wait-and-call (fn () (do (perform {:op "io"}) (other-fn))))
|
|
(other-fn (fn () "from-sibling")))
|
|
(wait-and-call))`) === 'from-sibling');
|
|
|
|
test('letrec, fn references sibling value after perform/resume', () =>
|
|
callExpr(`
|
|
(letrec
|
|
((shared "shared-state")
|
|
(do-fn (fn () (do (perform {:op "io"}) shared))))
|
|
(do-fn))`) === 'shared-state');
|
|
|
|
test('letrec, recursive self-call after perform (wait-boot pattern)', () => {
|
|
K.eval('(define _wb-c 0)');
|
|
K.eval('(set! _wb-c 0)');
|
|
return callExpr(`
|
|
(letrec ((wait-boot (fn ()
|
|
(do (perform {:op "io"})
|
|
(if (>= _wb-c 1)
|
|
"done"
|
|
(do (set! _wb-c (+ 1 _wb-c))
|
|
(wait-boot)))))))
|
|
(wait-boot))`) === 'done';
|
|
});
|
|
|
|
test('top-level define + perform + sibling call after resume', () => {
|
|
K.eval('(define do-suspend-x (fn () (do (perform {:op "io"}) (do-other-x))))');
|
|
K.eval('(define do-other-x (fn () "ok-from-other"))');
|
|
return callExpr('(do-suspend-x)') === 'ok-from-other';
|
|
});
|
|
|
|
test('letrec, two performs (sequential) then sibling call', () =>
|
|
callExpr(`
|
|
(letrec
|
|
((wait-twice (fn () (do (perform {:op "io1"}) (perform {:op "io2"}) (other))))
|
|
(other (fn () "after-double")))
|
|
(wait-twice))`) === 'after-double');
|
|
|
|
// === Tree-walk paths that previously raised "IO suspension in non-IO context" ===
|
|
|
|
test('letrec init expr with perform — suspension propagates (no error)', () => {
|
|
let r;
|
|
try { r = callExpr('(letrec ((x (perform {:op "io"}))) "ok")'); }
|
|
catch (e) { return 'threw: ' + e.message; }
|
|
return r === null || r === 'ok';
|
|
});
|
|
|
|
test('letrec non-last body with perform — suspension propagates (no error)', () => {
|
|
let r;
|
|
try { r = callExpr('(letrec ((x 1)) (perform {:op "io"}) "after")'); }
|
|
catch (e) { return 'threw: ' + e.message; }
|
|
return r === null || r === 'after';
|
|
});
|
|
|
|
test('macro body with perform — suspension propagates', () => {
|
|
K.eval('(defmacro _m1 (form) (do (perform {:op "io"}) form))');
|
|
let r;
|
|
try { r = callExpr('(_m1 "macro-ok")'); }
|
|
catch (e) { return 'threw: ' + e.message; }
|
|
return r === 'macro-ok' || r === null;
|
|
});
|
|
|
|
test('quasiquote unquote with perform — suspension propagates', () => {
|
|
let r;
|
|
try { r = callExpr('(let ((y "yyy")) `(a ,(do (perform {:op "io"}) y) c))'); }
|
|
catch (e) { return 'threw: ' + e.message; }
|
|
return r !== undefined;
|
|
});
|
|
|
|
console.log('\n--- Results ---');
|
|
console.log('passed: ' + passed);
|
|
console.log('failed: ' + failed);
|
|
if (failed > 0) {
|
|
console.log('\nFailures:');
|
|
failures.forEach(f => console.log(' - ' + f.name + ': ' + f.error));
|
|
process.exit(1);
|
|
}
|
|
process.exit(0);
|