Fix hs-repeat-times: wrap when multi-body in (do ...) for IO suspension

The when form's continuation for the second body expression was lost
across perform/cek_resume cycles. Wrapping (thunk) and (do-repeat)
in an explicit (do ...) gives when a single body, and do's own
continuation handles the sequencing correctly.

Sandbox confirms: 6/6 io-sleep suspensions now chain through
host-callback → _driveAsync → resume_vm (was 1/6 before fix).

Also fix sandbox async timing: _asyncPending counter tracks in-flight
IO chains so page.evaluate waits for all resumes to complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 20:31:12 +00:00
parent aeaa8cb498
commit c521ff8731
3 changed files with 653 additions and 1 deletions

View File

@@ -1296,6 +1296,339 @@ async function modeEvalAt(browser, url, phase, expr) {
return { url, phase, expr, result: evalResult, bootLog: bootLogs };
}
// ---------------------------------------------------------------------------
// Mode: sandbox — offline WASM kernel in a blank page, no server needed
//
// General-purpose browser test environment. Injects the real WASM kernel
// into about:blank with full FFI, IO suspension, and DOM.
//
// Predefined stacks (via stack param):
// "core" — kernel + FFI only (default)
// "web" — full web stack (render, signals, DOM, adapters, boot)
// "hs" — web + hyperscript modules
// "test" — web + test framework
//
// Custom files loaded after the stack. Setup expression runs before eval.
// All IO suspensions are traced. Results formatted via SX inspect.
// ---------------------------------------------------------------------------
const SANDBOX_STACKS = {
// Paths relative to shared/static/wasm/sx/
web: [
'render', 'core-signals', 'signals', 'deps', 'router',
'page-helpers', 'freeze', 'dom', 'browser',
'adapter-html', 'adapter-sx', 'adapter-dom',
'boot-helpers', 'hypersx', 'engine', 'orchestration', 'boot',
],
hs: [
// web stack first, then hyperscript from lib/
'_web_',
'hs-tokenizer', 'hs-parser', 'hs-compiler', 'hs-runtime', 'hs-integration',
],
test: [
'_web_',
'harness', 'harness-reactive', 'harness-web',
],
};
async function modeSandbox(page, expr, files, setup, stack) {
const fs = require('fs');
const path = require('path');
const PROJECT_ROOT = path.resolve(__dirname, '../..');
const WASM_DIR = path.join(PROJECT_ROOT, 'shared/static/wasm');
const SX_DIR = path.join(WASM_DIR, 'sx');
const consoleLogs = [];
page.on('console', msg => {
consoleLogs.push({ type: msg.type(), text: msg.text().slice(0, 500) });
});
// 1. Navigate to blank page
await page.goto('about:blank');
// 2. Inject WASM kernel
const kernelSrc = fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8');
await page.addScriptTag({ content: kernelSrc });
await page.waitForFunction('!!window.SxKernel', { timeout: 10000 });
// 3. Register FFI primitives + IO suspension driver
await page.evaluate(() => {
const K = window.SxKernel;
K.registerNative('host-global', args => {
const n = args[0];
return (n in globalThis) ? globalThis[n] : null;
});
K.registerNative('host-get', args => {
if (args[0] == null) return null;
const v = args[0][args[1]];
return v === undefined ? null : v;
});
K.registerNative('host-set!', args => {
if (args[0] != null) args[0][args[1]] = args[2];
return args[2];
});
K.registerNative('host-call', args => {
const [obj, method, ...rest] = args;
if (obj == null) {
const fn = globalThis[method];
if (typeof fn === 'function') return fn.apply(null, rest);
return null;
}
if (typeof obj[method] !== 'function') return null;
try { const r = obj[method].apply(obj, rest); return r === undefined ? null : r; }
catch(e) { console.error('[sandbox] host-call error:', method, e.message); return null; }
});
K.registerNative('host-new', args => {
const name = args[0];
const cArgs = args.slice(1);
const Ctor = typeof name === 'string' ? globalThis[name] : name;
if (typeof Ctor !== 'function') return null;
return new Ctor(...cArgs);
});
K.registerNative('host-callback', args => {
const fn = args[0];
if (typeof fn === 'function' && fn.__sx_handle === undefined) return fn;
if (fn && fn.__sx_handle !== undefined) {
return function() {
const r = K.callFn(fn, Array.from(arguments));
window._driveAsync(r);
return r;
};
}
return function() {};
});
K.registerNative('host-typeof', args => {
const obj = args[0];
if (obj == null) return 'nil';
if (obj instanceof Element) return 'element';
if (obj instanceof Text) return 'text';
if (obj instanceof DocumentFragment) return 'fragment';
if (obj instanceof Document) return 'document';
if (obj instanceof Event) return 'event';
if (obj instanceof Promise) return 'promise';
return typeof obj;
});
K.registerNative('host-await', args => {
const [promise, callback] = args;
if (promise && typeof promise.then === 'function') {
const cb = (callback && callback.__sx_handle !== undefined)
? v => K.callFn(callback, [v])
: () => {};
promise.then(cb);
}
});
K.registerNative('load-library!', args => false);
// IO suspension driver — traces every suspension/resume
// _asyncPending tracks in-flight IO chains so the sandbox can wait for completion
window._ioTrace = [];
window._asyncPending = 0;
window._driveAsync = function driveAsync(result) {
if (!result || !result.suspended) { return; }
window._asyncPending++;
const req = result.request;
const items = req && (req.items || req);
const op = items && items[0];
const opName = typeof op === 'string' ? op : (op && op.name) || String(op);
const arg = items && items[1];
window._ioTrace.push({ op: opName, arg: arg });
function doResume(val, delay) {
setTimeout(() => {
try {
const resumed = result.resume(val);
window._asyncPending--;
driveAsync(resumed);
} catch(e) {
window._asyncPending--;
console.error('[sandbox] resume error:', e.message);
window._ioTrace.push({ op: 'ERROR', arg: e.message });
}
}, delay);
}
if (opName === 'io-sleep' || opName === 'wait') {
doResume(null, Math.min(typeof arg === 'number' ? arg : 0, 10));
} else if (opName === 'io-navigate') {
window._asyncPending--;
} else if (opName === 'io-fetch') {
doResume({ ok: true, text: '' }, 1);
} else {
window._asyncPending--;
console.warn('[sandbox] unhandled IO:', opName);
window._ioTrace.push({ op: 'unhandled', arg: opName });
}
};
K.eval('(define SX_VERSION "sandbox-1.0")');
K.eval('(define SX_ENGINE "ocaml-vm-sandbox")');
K.eval('(define parse sx-parse)');
K.eval('(define serialize sx-serialize)');
});
// 4. Load stack modules
const loadErrors = [];
const loadedModules = [];
function resolveStack(name) {
const mods = SANDBOX_STACKS[name] || [];
const result = [];
for (const m of mods) {
if (m === '_web_') result.push(...resolveStack('web'));
else result.push(m);
}
return result;
}
if (stack && stack !== 'core') {
const modules = resolveStack(stack);
if (modules.length > 0) {
// beginModuleLoad if available
await page.evaluate(() => {
if (window.SxKernel.beginModuleLoad) window.SxKernel.beginModuleLoad();
});
for (const mod of modules) {
// Try .sx file in wasm/sx/ dir
const sxPath = path.join(SX_DIR, mod + '.sx');
// Also try lib/hyperscript/ for hs- prefixed modules
const libPath = path.join(PROJECT_ROOT, 'lib/hyperscript', mod.replace(/^hs-/, '') + '.sx');
let src;
try {
src = fs.existsSync(sxPath)
? fs.readFileSync(sxPath, 'utf8')
: fs.readFileSync(libPath, 'utf8');
} catch(e) {
loadErrors.push(mod + ': file not found');
continue;
}
const err = await page.evaluate(src => {
try { window.SxKernel.load(src); return null; }
catch(e) { return e.message; }
}, src);
if (err) loadErrors.push(mod + ': ' + err);
else loadedModules.push(mod);
}
await page.evaluate(() => {
if (window.SxKernel.endModuleLoad) window.SxKernel.endModuleLoad();
});
}
}
// 5. Load custom files
for (const f of files) {
const abs = path.isAbsolute(f) ? f : path.join(PROJECT_ROOT, f);
try {
const src = fs.readFileSync(abs, 'utf8');
const err = await page.evaluate(src => {
try { window.SxKernel.load(src); return null; }
catch(e) { return e.message; }
}, src);
if (err) loadErrors.push(f + ': ' + err);
else loadedModules.push(path.basename(f));
} catch(e) {
loadErrors.push(f + ': ' + e.message);
}
}
// 6. Run setup expression
if (setup) {
const setupErr = await page.evaluate(s => {
try { window.SxKernel.eval(s); return null; }
catch(e) { return e.message; }
}, setup);
if (setupErr) loadErrors.push('setup: ' + setupErr);
}
// 7. Evaluate — wraps in thunk + callFn for IO suspension support.
// After synchronous eval, waits for all _driveAsync chains to settle.
const evalResult = await page.evaluate(expr => {
return new Promise(resolve => {
const K = window.SxKernel;
window._ioTrace = [];
window._asyncPending = 0;
let syncResult;
try {
K.eval('(define _sandbox_thunk (fn () ' + expr + '))');
syncResult = K.callFn(K.eval('_sandbox_thunk'), []);
} catch(e) {
resolve({ result: 'Error: ' + e.message, ioTrace: window._ioTrace });
return;
}
// If the thunk itself suspended (direct perform), drive it inline
if (syncResult && syncResult.suspended) {
function tracingDrive(r) {
if (!r || !r.suspended) {
waitAndResolve(r);
return;
}
const req = r.request;
const items = req && (req.items || req);
const op = items && items[0];
const opName = typeof op === 'string' ? op : (op && op.name) || String(op);
const arg = items && items[1];
window._ioTrace.push({ op: opName, arg: arg });
if (opName === 'io-sleep' || opName === 'wait') {
setTimeout(() => {
try { tracingDrive(r.resume(null)); }
catch(e) { resolve({ result: 'Error: ' + e.message, ioTrace: window._ioTrace }); }
}, Math.min(typeof arg === 'number' ? arg : 0, 10));
} else if (opName === 'io-fetch') {
setTimeout(() => {
try { tracingDrive(r.resume({ ok: true, text: '' })); }
catch(e) { resolve({ result: 'Error: ' + e.message, ioTrace: window._ioTrace }); }
}, 1);
} else {
resolve({ result: 'unhandled IO: ' + opName, ioTrace: window._ioTrace });
}
}
tracingDrive(syncResult);
return;
}
// Synchronous result — but there may be pending _driveAsync chains
// (e.g. from host-callback event handlers triggered during eval)
waitAndResolve(syncResult);
function waitAndResolve(r) {
const display = (r === null || r === undefined) ? 'nil'
: typeof r === 'string' ? r
: typeof r === 'object' ? JSON.stringify(r) : String(r);
// Poll for _asyncPending to reach 0 (max 5s)
let waited = 0;
function check() {
if (window._asyncPending <= 0 || waited > 5000) {
resolve({ result: display, ioTrace: window._ioTrace });
} else {
waited += 20;
setTimeout(check, 20);
}
}
// Give first tick a chance to start
setTimeout(check, 20);
}
});
}, expr);
// 8. Collect logs
const ioLogs = consoleLogs.filter(l =>
l.text.includes('[sandbox]') || l.text.includes('[sx]') ||
l.type === 'error' || l.type === 'warning'
);
return {
mode: 'sandbox',
stack: stack || 'core',
modules: loadedModules.length > 0 ? loadedModules : undefined,
files: files.length > 0 ? files.map(f => path.basename(f)) : undefined,
result: evalResult.result,
ioTrace: evalResult.ioTrace.length > 0 ? evalResult.ioTrace : undefined,
loadErrors: loadErrors.length > 0 ? loadErrors : undefined,
log: ioLogs.length > 0 ? ioLogs : undefined,
};
}
async function main() {
const argsJson = process.argv[2] || '{}';
let args;
@@ -1354,6 +1687,9 @@ async function main() {
case 'eval-at':
result = await modeEvalAt(browser, url, args.phase || 'before-hydrate', args.expr || '(type-of ~cssx/tw)');
break;
case 'sandbox':
result = await modeSandbox(page, args.expr || '"hello"', args.files || [], args.setup || '', args.stack || '');
break;
default:
result = { error: `Unknown mode: ${mode}` };
}