When cek_call_or_suspend runs a CEK machine for a non-bytecoded Lambda (e.g. a thunk), _active_vm still pointed to the caller's VM. VmClosure calls inside the CEK (e.g. hs-wait) would merge their frames with the caller's VM via call_closure_reuse, causing the VM to skip the CEK's remaining continuation on resume — producing wrong DOM mutation order (+active, +active, -active instead of +active, -active, +active). Fix: swap _active_vm with an empty isolation VM before running the CEK, restore after. This keeps VmClosure calls on their own frame stack while preserving js_of_ocaml exception identity (Some path, not None). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
295 lines
11 KiB
JavaScript
295 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
// test_driveAsync_order.js — Verify DOM mutation order with real _driveAsync
|
|
//
|
|
// This test mimics the exact browser flow:
|
|
// 1. host-callback wraps handler with K.callFn + _driveAsync
|
|
// 2. dom-listen uses host-callback + host-call addEventListener
|
|
// 3. Event fires → wrapper runs → _driveAsync drives suspension chain
|
|
//
|
|
// If there's a dual-path issue (_driveAsync + CEK chain both driving),
|
|
// mutations will appear out of order.
|
|
//
|
|
// Expected: +active, -active, +active, -active, +active, -active (3 iterations)
|
|
// Bug: +active, +active, -active, ... (overlapping iterations)
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const PROJECT_ROOT = path.resolve(__dirname, '../../..');
|
|
const WASM_DIR = path.join(PROJECT_ROOT, 'shared/static/wasm');
|
|
|
|
// --- Track ALL mutations in order ---
|
|
const mutations = [];
|
|
|
|
function makeElement(tag) {
|
|
const el = {
|
|
tagName: tag, _attrs: {}, _children: [], _classes: new Set(),
|
|
_listeners: {},
|
|
style: {}, childNodes: [], children: [], textContent: '',
|
|
nodeType: 1,
|
|
classList: {
|
|
add(c) {
|
|
el._classes.add(c);
|
|
mutations.push('+' + c);
|
|
console.log(' [DOM] classList.add("' + c + '") → {' + [...el._classes] + '}');
|
|
},
|
|
remove(c) {
|
|
el._classes.delete(c);
|
|
mutations.push('-' + c);
|
|
console.log(' [DOM] classList.remove("' + c + '") → {' + [...el._classes] + '}');
|
|
},
|
|
contains(c) { return el._classes.has(c); },
|
|
toggle(c) { if (el._classes.has(c)) el._classes.delete(c); else el._classes.add(c); },
|
|
},
|
|
setAttribute(k, v) { el._attrs[k] = String(v); },
|
|
getAttribute(k) { return el._attrs[k] || null; },
|
|
removeAttribute(k) { delete el._attrs[k]; },
|
|
appendChild(c) { el._children.push(c); el.childNodes.push(c); el.children.push(c); return c; },
|
|
insertBefore(c) { el._children.push(c); el.childNodes.push(c); el.children.push(c); return c; },
|
|
removeChild(c) { return c; }, replaceChild(n) { return n; },
|
|
cloneNode() { return makeElement(tag); },
|
|
addEventListener(event, fn) {
|
|
if (!el._listeners[event]) el._listeners[event] = [];
|
|
el._listeners[event].push(fn);
|
|
},
|
|
removeEventListener(event, fn) {
|
|
if (el._listeners[event]) {
|
|
el._listeners[event] = el._listeners[event].filter(f => f !== fn);
|
|
}
|
|
},
|
|
dispatchEvent(e) {
|
|
const name = typeof e === 'string' ? e : e.type;
|
|
(el._listeners[name] || []).forEach(fn => fn(e));
|
|
},
|
|
get innerHTML() { return ''; }, set innerHTML(v) {},
|
|
get outerHTML() { return '<' + tag + '>'; },
|
|
dataset: {}, querySelectorAll() { return []; }, querySelector() { return null; },
|
|
};
|
|
return el;
|
|
}
|
|
|
|
global.window = global;
|
|
global.document = {
|
|
createElement: makeElement,
|
|
createDocumentFragment() { return makeElement('fragment'); },
|
|
head: makeElement('head'), body: makeElement('body'),
|
|
querySelector() { return null; }, querySelectorAll() { return []; },
|
|
createTextNode(s) { return { _isText: true, textContent: String(s), nodeType: 3 }; },
|
|
addEventListener() {},
|
|
createComment(s) { return { _isComment: true, textContent: s || '', nodeType: 8 }; },
|
|
getElementsByTagName() { return []; },
|
|
};
|
|
global.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} };
|
|
global.CustomEvent = class { constructor(n, o) { this.type = n; this.detail = (o || {}).detail || {}; } };
|
|
global.MutationObserver = class { observe() {} disconnect() {} };
|
|
global.requestIdleCallback = fn => setTimeout(fn, 0);
|
|
global.matchMedia = () => ({ matches: false });
|
|
global.navigator = { serviceWorker: { register() { return Promise.resolve(); } } };
|
|
global.location = { href: '', pathname: '/', hostname: 'localhost' };
|
|
global.history = { pushState() {}, replaceState() {} };
|
|
global.fetch = () => Promise.resolve({ ok: true, text() { return Promise.resolve(''); } });
|
|
global.XMLHttpRequest = class { open() {} send() {} };
|
|
|
|
async function main() {
|
|
// Load WASM kernel
|
|
require(path.join(WASM_DIR, 'sx_browser.bc.js'));
|
|
const K = globalThis.SxKernel;
|
|
if (!K) { console.error('FATAL: SxKernel not found'); process.exit(1); }
|
|
console.log('WASM kernel loaded');
|
|
|
|
// --- Register FFI with the REAL _driveAsync (same as sx-platform.js) ---
|
|
K.registerNative('host-global', function(args) {
|
|
var name = args[0];
|
|
if (name in globalThis) return globalThis[name];
|
|
return null;
|
|
});
|
|
K.registerNative('host-get', function(args) {
|
|
var obj = args[0], prop = args[1];
|
|
if (obj == null) return null;
|
|
var v = obj[prop];
|
|
return v === undefined ? null : v;
|
|
});
|
|
K.registerNative('host-set!', function(args) {
|
|
if (args[0] != null) args[0][args[1]] = args[2];
|
|
});
|
|
K.registerNative('host-call', function(args) {
|
|
var obj = args[0], method = args[1];
|
|
var callArgs = [];
|
|
for (var i = 2; i < args.length; i++) callArgs.push(args[i]);
|
|
if (obj == null) return null;
|
|
if (typeof obj[method] === 'function') {
|
|
try { return obj[method].apply(obj, callArgs); }
|
|
catch(e) { console.error('[sx] host-call error:', e); return null; }
|
|
}
|
|
return null;
|
|
});
|
|
K.registerNative('host-new', function(args) {
|
|
return null;
|
|
});
|
|
K.registerNative('host-typeof', function(args) { return typeof args[0]; });
|
|
K.registerNative('host-await', function(args) { return args[0]; });
|
|
|
|
// THE REAL host-callback (same as sx-platform.js lines 82-97)
|
|
K.registerNative('host-callback', function(args) {
|
|
var fn = args[0];
|
|
if (typeof fn === 'function' && fn.__sx_handle === undefined) return fn;
|
|
if (fn && fn.__sx_handle !== undefined) {
|
|
return function() {
|
|
var a = Array.prototype.slice.call(arguments);
|
|
var result = K.callFn(fn, a);
|
|
// This is the line under investigation:
|
|
_driveAsync(result);
|
|
return result;
|
|
};
|
|
}
|
|
return function() {};
|
|
});
|
|
|
|
// THE REAL _driveAsync (same as sx-platform.js lines 104-138)
|
|
var _asyncPending = 0;
|
|
function _driveAsync(result) {
|
|
if (!result || !result.suspended) return;
|
|
_asyncPending++;
|
|
console.log('[driveAsync] suspension detected, pending=' + _asyncPending);
|
|
var req = result.request;
|
|
if (!req) { _asyncPending--; return; }
|
|
var items = req.items || req;
|
|
var op = (items && items[0]) || req;
|
|
var opName = (typeof op === 'string') ? op : (op && op.name) || String(op);
|
|
|
|
if (opName === 'wait' || opName === 'io-sleep') {
|
|
var ms = (items && items[1]) || 0;
|
|
if (typeof ms !== 'number') ms = parseFloat(ms) || 0;
|
|
// Use 1ms for test speed
|
|
setTimeout(function() {
|
|
try {
|
|
var resumed = result.resume(null);
|
|
_asyncPending--;
|
|
console.log('[driveAsync] resumed, pending=' + _asyncPending +
|
|
', suspended=' + (resumed && resumed.suspended));
|
|
_driveAsync(resumed);
|
|
} catch(e) {
|
|
_asyncPending--;
|
|
console.error('[driveAsync] resume error:', e);
|
|
}
|
|
}, 1);
|
|
} else {
|
|
_asyncPending--;
|
|
console.warn('[driveAsync] unhandled IO:', opName);
|
|
}
|
|
}
|
|
|
|
K.eval('(define SX_VERSION "test-drive-async")');
|
|
K.eval('(define SX_ENGINE "ocaml-vm-wasm-test")');
|
|
K.eval('(define parse sx-parse)');
|
|
K.eval('(define serialize sx-serialize)');
|
|
|
|
// Load the REAL dom-listen (uses host-callback + host-call addEventListener)
|
|
K.eval(`(define dom-listen
|
|
(fn (el event-name handler)
|
|
(let ((cb (host-callback handler)))
|
|
(host-call el "addEventListener" event-name cb)
|
|
(fn () (host-call el "removeEventListener" event-name cb)))))`);
|
|
|
|
K.eval('(define dom-add-class (fn (el cls) (host-call (host-get el "classList") "add" cls)))');
|
|
K.eval('(define dom-remove-class (fn (el cls) (host-call (host-get el "classList") "remove" cls)))');
|
|
K.eval('(define dom-has-class? (fn (el cls) (host-call (host-get el "classList") "contains" cls)))');
|
|
|
|
// Load hyperscript modules — try bytecode first, fall back to source
|
|
const SX_DIR = path.join(WASM_DIR, 'sx');
|
|
const useBytecode = process.argv.includes('--bytecode');
|
|
if (useBytecode) {
|
|
console.log('Loading BYTECODE modules...');
|
|
const bcNames = ['hs-tokenizer', 'hs-parser', 'hs-compiler', 'hs-runtime'];
|
|
for (const f of bcNames) {
|
|
const bcPath = path.join(SX_DIR, f + '.sxbc');
|
|
if (fs.existsSync(bcPath)) {
|
|
const bcSrc = fs.readFileSync(bcPath, 'utf8');
|
|
K.load('(load-sxbc (first (parse "' + bcSrc.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '")))');
|
|
console.log(' loaded ' + f + '.sxbc');
|
|
} else {
|
|
console.error(' MISSING ' + bcPath);
|
|
}
|
|
}
|
|
} else {
|
|
console.log('Loading SOURCE modules...');
|
|
const hsFiles = ['tokenizer', 'parser', 'compiler', 'runtime'];
|
|
for (const f of hsFiles) {
|
|
K.load(fs.readFileSync(path.join(PROJECT_ROOT, 'lib/hyperscript', f + '.sx'), 'utf8'));
|
|
}
|
|
}
|
|
console.log('Hyperscript modules loaded');
|
|
|
|
// Create element
|
|
const btn = makeElement('button');
|
|
global._testBtn = btn;
|
|
K.eval('(define _btn (host-global "_testBtn"))');
|
|
|
|
// Compile + register handler using hs-on (which uses dom-listen → host-callback → addEventListener)
|
|
console.log('\n=== Setting up hs-on handler ===');
|
|
K.eval(`(hs-on _btn "click"
|
|
(fn (event)
|
|
(hs-repeat-times 3
|
|
(fn ()
|
|
(do
|
|
(dom-add-class _btn "active")
|
|
(hs-wait 300)
|
|
(dom-remove-class _btn "active")
|
|
(hs-wait 300))))))`);
|
|
|
|
console.log('Handler registered, listeners:', Object.keys(btn._listeners));
|
|
console.log('Click listeners count:', (btn._listeners.click || []).length);
|
|
|
|
// Simulate click — fires the event listener which goes through host-callback + _driveAsync
|
|
console.log('\n=== Simulating click ===');
|
|
mutations.length = 0;
|
|
btn.dispatchEvent({ type: 'click', target: btn });
|
|
|
|
// Wait for all async resumes to complete
|
|
await new Promise(resolve => {
|
|
function check() {
|
|
if (_asyncPending === 0 && mutations.length > 0) {
|
|
// Give a tiny extra delay to make sure nothing else fires
|
|
setTimeout(() => {
|
|
if (_asyncPending === 0) resolve();
|
|
else check();
|
|
}, 10);
|
|
} else {
|
|
setTimeout(check, 5);
|
|
}
|
|
}
|
|
setTimeout(check, 50);
|
|
});
|
|
|
|
// Verify mutation order
|
|
console.log('\n=== Results ===');
|
|
console.log('Mutations:', mutations.join(', '));
|
|
console.log('Count:', mutations.length, '(expected: 6)');
|
|
|
|
const expected = ['+active', '-active', '+active', '-active', '+active', '-active'];
|
|
let pass = true;
|
|
if (mutations.length !== expected.length) {
|
|
console.error(`FAIL: expected ${expected.length} mutations, got ${mutations.length}`);
|
|
pass = false;
|
|
} else {
|
|
for (let i = 0; i < expected.length; i++) {
|
|
if (mutations[i] !== expected[i]) {
|
|
console.error(`FAIL at index ${i}: expected ${expected[i]}, got ${mutations[i]}`);
|
|
pass = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pass) {
|
|
console.log('PASS: mutation order is correct');
|
|
} else {
|
|
console.log('FAIL: mutation order is wrong');
|
|
console.log('Expected:', expected.join(', '));
|
|
console.log('Got: ', mutations.join(', '));
|
|
}
|
|
|
|
process.exit(pass ? 0 : 1);
|
|
}
|
|
|
|
main().catch(e => { console.error('FATAL:', e); process.exit(1); });
|