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:
@@ -172,7 +172,7 @@
|
|||||||
(n thunk)
|
(n thunk)
|
||||||
(define
|
(define
|
||||||
do-repeat
|
do-repeat
|
||||||
(fn (i) (when (< i n) (thunk) (do-repeat (+ i 1)))))
|
(fn (i) (when (< i n) (do (thunk) (do-repeat (+ i 1))))))
|
||||||
(do-repeat 0)))
|
(do-repeat 0)))
|
||||||
|
|
||||||
;; Repeat forever (until break — relies on exception/continuation).
|
;; Repeat forever (until break — relies on exception/continuation).
|
||||||
|
|||||||
316
shared/static/wasm/sx/hs-runtime.sx
Normal file
316
shared/static/wasm/sx/hs-runtime.sx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
;; _hyperscript runtime shims
|
||||||
|
;;
|
||||||
|
;; Thin wrappers over web/lib/dom.sx and web/lib/browser.sx primitives
|
||||||
|
;; that implement hyperscript-specific semantics (async transparency,
|
||||||
|
;; class toggling, event waiting, iteration, type coercion).
|
||||||
|
;;
|
||||||
|
;; These are the functions that hs-to-sx (compiler.sx) emits calls to.
|
||||||
|
;; Each is a pure define — no platform dependency beyond the DOM/browser
|
||||||
|
;; primitives already available in the SX web platform.
|
||||||
|
|
||||||
|
;; ── Event handling ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
;; Register an event listener. Returns unlisten function.
|
||||||
|
;; (hs-on target event-name handler) → unlisten-fn
|
||||||
|
(define
|
||||||
|
hs-on
|
||||||
|
(fn (target event-name handler) (dom-listen target event-name handler)))
|
||||||
|
|
||||||
|
;; Register for every occurrence (no queuing — each fires independently).
|
||||||
|
;; Stock hyperscript queues by default; "every" disables queuing.
|
||||||
|
(define
|
||||||
|
hs-on-every
|
||||||
|
(fn (target event-name handler) (dom-listen target event-name handler)))
|
||||||
|
|
||||||
|
;; Run an initializer function immediately.
|
||||||
|
;; (hs-init thunk) — called at element boot time
|
||||||
|
(define hs-init (fn (thunk) (thunk)))
|
||||||
|
|
||||||
|
;; ── Async / timing ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
;; Wait for a duration in milliseconds.
|
||||||
|
;; In hyperscript, wait is async-transparent — execution pauses.
|
||||||
|
;; Here we use perform/IO suspension for true pause semantics.
|
||||||
|
(define hs-wait (fn (ms) (perform (list (quote io-sleep) ms))))
|
||||||
|
|
||||||
|
;; Wait for a DOM event on a target.
|
||||||
|
;; (hs-wait-for target event-name) — suspends until event fires
|
||||||
|
(define
|
||||||
|
hs-wait-for
|
||||||
|
(fn
|
||||||
|
(target event-name)
|
||||||
|
(perform (list (quote io-wait-event) target event-name))))
|
||||||
|
|
||||||
|
;; Wait for CSS transitions/animations to settle on an element.
|
||||||
|
(define hs-settle (fn (target) (perform (list (quote io-settle) target))))
|
||||||
|
|
||||||
|
;; ── Class manipulation ──────────────────────────────────────────
|
||||||
|
|
||||||
|
;; Toggle a single class on an element.
|
||||||
|
(define
|
||||||
|
hs-toggle-class!
|
||||||
|
(fn
|
||||||
|
(target cls)
|
||||||
|
(if
|
||||||
|
(dom-has-class? target cls)
|
||||||
|
(dom-remove-class target cls)
|
||||||
|
(dom-add-class target cls))))
|
||||||
|
|
||||||
|
;; Toggle between two classes — exactly one is active at a time.
|
||||||
|
(define
|
||||||
|
hs-toggle-between!
|
||||||
|
(fn
|
||||||
|
(target cls1 cls2)
|
||||||
|
(if
|
||||||
|
(dom-has-class? target cls1)
|
||||||
|
(do (dom-remove-class target cls1) (dom-add-class target cls2))
|
||||||
|
(do (dom-remove-class target cls2) (dom-add-class target cls1)))))
|
||||||
|
|
||||||
|
;; Take a class from siblings — add to target, remove from others.
|
||||||
|
;; (hs-take! target cls) — like radio button class behavior
|
||||||
|
(define
|
||||||
|
hs-take!
|
||||||
|
(fn
|
||||||
|
(target cls)
|
||||||
|
(let
|
||||||
|
((parent (dom-parent target)))
|
||||||
|
(when
|
||||||
|
parent
|
||||||
|
(for-each
|
||||||
|
(fn (child) (dom-remove-class child cls))
|
||||||
|
(dom-child-list parent)))
|
||||||
|
(dom-add-class target cls))))
|
||||||
|
|
||||||
|
;; ── DOM insertion ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
;; Put content at a position relative to a target.
|
||||||
|
;; pos: "into" | "before" | "after"
|
||||||
|
(define
|
||||||
|
hs-put!
|
||||||
|
(fn
|
||||||
|
(value pos target)
|
||||||
|
(cond
|
||||||
|
((= pos "into") (dom-set-inner-html target value))
|
||||||
|
((= pos "before")
|
||||||
|
(dom-insert-adjacent-html target "beforebegin" value))
|
||||||
|
((= pos "after") (dom-insert-adjacent-html target "afterend" value)))))
|
||||||
|
|
||||||
|
;; ── Navigation / traversal ──────────────────────────────────────
|
||||||
|
|
||||||
|
;; Navigate to a URL.
|
||||||
|
(define hs-navigate! (fn (url) (perform (list (quote io-navigate) url))))
|
||||||
|
|
||||||
|
;; Find next sibling matching a selector (or any sibling).
|
||||||
|
(define
|
||||||
|
hs-next
|
||||||
|
(fn
|
||||||
|
(target sel)
|
||||||
|
(if
|
||||||
|
(= sel "*")
|
||||||
|
(dom-next-sibling target)
|
||||||
|
(let
|
||||||
|
((sibling (dom-next-sibling target)))
|
||||||
|
(define
|
||||||
|
find-next
|
||||||
|
(fn
|
||||||
|
(el)
|
||||||
|
(cond
|
||||||
|
((nil? el) nil)
|
||||||
|
((dom-matches? el sel) el)
|
||||||
|
(true (find-next (dom-next-sibling el))))))
|
||||||
|
(find-next sibling)))))
|
||||||
|
|
||||||
|
;; Find previous sibling matching a selector.
|
||||||
|
(define
|
||||||
|
hs-previous
|
||||||
|
(fn
|
||||||
|
(target sel)
|
||||||
|
(if
|
||||||
|
(= sel "*")
|
||||||
|
(dom-get-prop target "previousElementSibling")
|
||||||
|
(let
|
||||||
|
((sibling (dom-get-prop target "previousElementSibling")))
|
||||||
|
(define
|
||||||
|
find-prev
|
||||||
|
(fn
|
||||||
|
(el)
|
||||||
|
(cond
|
||||||
|
((nil? el) nil)
|
||||||
|
((dom-matches? el sel) el)
|
||||||
|
(true (find-prev (dom-get-prop el "previousElementSibling"))))))
|
||||||
|
(find-prev sibling)))))
|
||||||
|
|
||||||
|
;; First element matching selector within a scope.
|
||||||
|
(define hs-query-first (fn (sel) (dom-query sel)))
|
||||||
|
|
||||||
|
;; Last element matching selector.
|
||||||
|
(define
|
||||||
|
hs-query-last
|
||||||
|
(fn
|
||||||
|
(sel)
|
||||||
|
(let
|
||||||
|
((all (dom-query-all (dom-body) sel)))
|
||||||
|
(if (> (len all) 0) (nth all (- (len all) 1)) nil))))
|
||||||
|
|
||||||
|
;; First/last within a specific scope.
|
||||||
|
(define hs-first (fn (scope sel) (dom-query-all scope sel)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
hs-last
|
||||||
|
(fn
|
||||||
|
(scope sel)
|
||||||
|
(let
|
||||||
|
((all (dom-query-all scope sel)))
|
||||||
|
(if (> (len all) 0) (nth all (- (len all) 1)) nil))))
|
||||||
|
|
||||||
|
;; ── Iteration ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
;; Repeat a thunk N times.
|
||||||
|
(define
|
||||||
|
hs-repeat-times
|
||||||
|
(fn
|
||||||
|
(n thunk)
|
||||||
|
(define
|
||||||
|
do-repeat
|
||||||
|
(fn (i) (when (< i n) (do (thunk) (do-repeat (+ i 1))))))
|
||||||
|
(do-repeat 0)))
|
||||||
|
|
||||||
|
;; Repeat forever (until break — relies on exception/continuation).
|
||||||
|
(define
|
||||||
|
hs-repeat-forever
|
||||||
|
(fn
|
||||||
|
(thunk)
|
||||||
|
(define do-forever (fn () (thunk) (do-forever)))
|
||||||
|
(do-forever)))
|
||||||
|
|
||||||
|
;; ── Fetch ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
;; Fetch a URL, parse response according to format.
|
||||||
|
;; (hs-fetch url format) — format is "json" | "text" | "html"
|
||||||
|
(define
|
||||||
|
hs-fetch
|
||||||
|
(fn
|
||||||
|
(url format)
|
||||||
|
(let
|
||||||
|
((response (perform (list (quote io-fetch) url))))
|
||||||
|
(cond
|
||||||
|
((= format "json") (perform (list (quote io-parse-json) response)))
|
||||||
|
((= format "text") (perform (list (quote io-parse-text) response)))
|
||||||
|
((= format "html") (perform (list (quote io-parse-html) response)))
|
||||||
|
(true response)))))
|
||||||
|
|
||||||
|
;; ── Type coercion ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
;; Coerce a value to a type by name.
|
||||||
|
;; (hs-coerce value type-name) — type-name is "Int", "Float", "String", etc.
|
||||||
|
(define
|
||||||
|
hs-coerce
|
||||||
|
(fn
|
||||||
|
(value type-name)
|
||||||
|
(cond
|
||||||
|
((= type-name "Int") (floor (+ value 0)))
|
||||||
|
((= type-name "Integer") (floor (+ value 0)))
|
||||||
|
((= type-name "Float") (+ value 0))
|
||||||
|
((= type-name "Number") (+ value 0))
|
||||||
|
((= type-name "String") (str value))
|
||||||
|
((= type-name "Boolean") (if value true false))
|
||||||
|
((= type-name "Array") (if (list? value) value (list value)))
|
||||||
|
(true value))))
|
||||||
|
|
||||||
|
;; ── Object creation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
;; Make a new object of a given type.
|
||||||
|
;; (hs-make type-name) — creates empty object/collection
|
||||||
|
(define
|
||||||
|
hs-add
|
||||||
|
(fn (a b) (if (or (string? a) (string? b)) (str a b) (+ a b))))
|
||||||
|
|
||||||
|
;; ── Behavior installation ───────────────────────────────────────
|
||||||
|
|
||||||
|
;; Install a behavior on an element.
|
||||||
|
;; A behavior is a function that takes (me ...params) and sets up features.
|
||||||
|
;; (hs-install behavior-fn me ...args)
|
||||||
|
(define
|
||||||
|
hs-make
|
||||||
|
(fn
|
||||||
|
(type-name)
|
||||||
|
(cond
|
||||||
|
((= type-name "Object") (dict))
|
||||||
|
((= type-name "Array") (list))
|
||||||
|
((= type-name "Set") (list))
|
||||||
|
((= type-name "Map") (dict))
|
||||||
|
(true (dict)))))
|
||||||
|
|
||||||
|
;; ── Measurement ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
;; Measure an element's bounding rect, store as local variables.
|
||||||
|
;; Returns a dict with x, y, width, height, top, left, right, bottom.
|
||||||
|
(define hs-install (fn (behavior-fn) (behavior-fn me)))
|
||||||
|
|
||||||
|
;; ── Transition ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
;; Transition a CSS property to a value, optionally with duration.
|
||||||
|
;; (hs-transition target prop value duration)
|
||||||
|
(define
|
||||||
|
hs-measure
|
||||||
|
(fn (target) (perform (list (quote io-measure) target))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
hs-transition
|
||||||
|
(fn
|
||||||
|
(target prop value duration)
|
||||||
|
(when
|
||||||
|
duration
|
||||||
|
(dom-set-style
|
||||||
|
target
|
||||||
|
"transition"
|
||||||
|
(str prop " " (/ duration 1000) "s")))
|
||||||
|
(dom-set-style target prop value)
|
||||||
|
(when duration (hs-settle target))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
hs-type-check
|
||||||
|
(fn
|
||||||
|
(value type-name)
|
||||||
|
(if
|
||||||
|
(nil? value)
|
||||||
|
true
|
||||||
|
(cond
|
||||||
|
((= type-name "Number") (number? value))
|
||||||
|
((= type-name "String") (string? value))
|
||||||
|
((= type-name "Boolean") (or (= value true) (= value false)))
|
||||||
|
((= type-name "Array") (list? value))
|
||||||
|
((= type-name "Object") (dict? value))
|
||||||
|
(true true)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
hs-type-check!
|
||||||
|
(fn
|
||||||
|
(value type-name)
|
||||||
|
(if (nil? value) false (hs-type-check value type-name))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
hs-strict-eq
|
||||||
|
(fn (a b) (and (= (type-of a) (type-of b)) (= a b))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
hs-falsy?
|
||||||
|
(fn (v) (or (nil? v) (= v false) (and (string? v) (= v "")))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
hs-matches?
|
||||||
|
(fn
|
||||||
|
(target pattern)
|
||||||
|
(if
|
||||||
|
(string? target)
|
||||||
|
(if (= pattern ".*") true (string-contains? target pattern))
|
||||||
|
false)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
hs-contains?
|
||||||
|
(fn
|
||||||
|
(collection item)
|
||||||
|
(cond
|
||||||
|
((list? collection) (some (fn (x) (= x item)) collection))
|
||||||
|
((string? collection) (string-contains? collection item))
|
||||||
|
(true false))))
|
||||||
@@ -1296,6 +1296,339 @@ async function modeEvalAt(browser, url, phase, expr) {
|
|||||||
return { url, phase, expr, result: evalResult, bootLog: bootLogs };
|
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() {
|
async function main() {
|
||||||
const argsJson = process.argv[2] || '{}';
|
const argsJson = process.argv[2] || '{}';
|
||||||
let args;
|
let args;
|
||||||
@@ -1354,6 +1687,9 @@ async function main() {
|
|||||||
case 'eval-at':
|
case 'eval-at':
|
||||||
result = await modeEvalAt(browser, url, args.phase || 'before-hydrate', args.expr || '(type-of ~cssx/tw)');
|
result = await modeEvalAt(browser, url, args.phase || 'before-hydrate', args.expr || '(type-of ~cssx/tw)');
|
||||||
break;
|
break;
|
||||||
|
case 'sandbox':
|
||||||
|
result = await modeSandbox(page, args.expr || '"hello"', args.files || [], args.setup || '', args.stack || '');
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
result = { error: `Unknown mode: ${mode}` };
|
result = { error: `Unknown mode: ${mode}` };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user