VM import suspension for browser lazy loading

Bytecode compiler now emits OP_PERFORM for (import ...) and compiles
(define-library ...) bodies. The VM stores the import request in
globals["__io_request"] and stops the run loop — no exceptions needed.
vm-execute-module returns a suspension dict, vm-resume-module continues.

Browser: sx_browser.ml detects suspension dicts from execute_module and
returns JS {suspended, op, request, resume} objects. The sx-platform.js
while loop handles cascading suspensions via handleImportSuspension.

13 modules load via .sxbc bytecode in 226ms (manifest-driven), both
islands hydrate, all handlers wired. 2650/2650 tests pass including
6 new vm-import-suspension tests.

Also: consolidated sx-platform-2.js → sx-platform.js, fixed
vm-execute-module missing code-from-value call, fixed bootstrap.py
protocol registry transpiler issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 17:11:12 +00:00
parent efd0d9168f
commit 2727577702
43 changed files with 4672 additions and 3991 deletions

View File

@@ -1175,6 +1175,78 @@ let run_spec_tests env test_files =
exit 1
end;
(* IO-aware evaluation: resolve library paths and handle import suspension *)
let lib_base = Filename.concat project_dir "lib" in
let spec_base = Filename.concat project_dir "spec" in
let web_base = Filename.concat project_dir "web" in
let resolve_library_path lib_spec =
let parts = match lib_spec with List l | ListRef { contents = l } -> l | _ -> [] in
match List.map (fun v -> match v with Symbol s -> s | String s -> s | _ -> "") parts with
| ["sx"; name] ->
let spec_path = Filename.concat spec_base (name ^ ".sx") in
let lib_path = Filename.concat lib_base (name ^ ".sx") in
let web_lib_path = Filename.concat (Filename.concat web_base "lib") (name ^ ".sx") in
if Sys.file_exists spec_path then Some spec_path
else if Sys.file_exists lib_path then Some lib_path
else if Sys.file_exists web_lib_path then Some web_lib_path
else None
| ["web"; name] ->
let path = Filename.concat web_base (name ^ ".sx") in
let lib_path = Filename.concat (Filename.concat web_base "lib") (name ^ ".sx") in
if Sys.file_exists path then Some path
else if Sys.file_exists lib_path then Some lib_path
else None
| [prefix; name] ->
let path = Filename.concat (Filename.concat project_dir prefix) (name ^ ".sx") in
if Sys.file_exists path then Some path else None
| _ -> None
in
(* Run CEK step loop, handling IO suspension for imports *)
let rec eval_with_io expr env_val =
let state = Sx_ref.make_cek_state expr env_val (List []) in
run_with_io state
and load_library_file path =
let exprs = Sx_parser.parse_file path in
List.iter (fun expr -> ignore (eval_with_io expr (Env env))) exprs
and run_with_io state =
let s = ref state in
let is_terminal st = match Sx_ref.cek_terminal_p st with Bool true -> true | _ -> false in
let is_suspended st = match Sx_runtime.get_val st (String "phase") with String "io-suspended" -> true | _ -> false in
let rec loop () =
while not (is_terminal !s) && not (is_suspended !s) do
s := Sx_ref.cek_step !s
done;
if is_suspended !s then begin
let request = Sx_runtime.get_val !s (String "request") in
let op = match Sx_runtime.get_val request (String "op") with String s -> s | _ -> "" in
let response = match op with
| "import" ->
let lib_spec = Sx_runtime.get_val request (String "library") in
let key = Sx_ref.library_name_key lib_spec in
if Sx_types.sx_truthy (Sx_ref.library_loaded_p key) then
Nil
else begin
(match resolve_library_path lib_spec with
| Some path ->
(try load_library_file path
with Sx_types.Eval_error msg ->
Printf.eprintf "[import] Warning loading %s: %s\n%!"
(Sx_runtime.value_to_str lib_spec) msg)
| None -> ()); (* silently skip unresolvable libraries *)
Nil
end
| _ -> Nil (* Other IO ops return nil in test context *)
in
s := Sx_ref.cek_resume !s response;
loop ()
end else
Sx_ref.cek_value !s
in
loop ()
in
let load_and_eval path =
let ic = open_in path in
let n = in_channel_length ic in
@@ -1184,7 +1256,8 @@ let run_spec_tests env test_files =
let src = Bytes.to_string s in
let exprs = parse_all src in
List.iter (fun expr ->
ignore (eval_expr expr (Env env))
try ignore (eval_with_io expr (Env env))
with Sx_types.Eval_error _ -> () (* skip expressions that fail during load *)
) exprs
in

View File

@@ -75,6 +75,8 @@ SKIP = {
# JIT dispatch + active VM (platform-specific)
"*active-vm*", "*jit-compile-fn*",
"try-jit-call", "vm-call-closure",
# Module execution (thin wrappers over native execute_module)
"vm-execute-module", "vm-resume-module",
# Env access (used by env-walk)
"env-walk", "env-walk-set!",
# CEK interop

View File

@@ -26,7 +26,7 @@ cp dist/sx_browser.bc.wasm.js "$DEST/"
cp dist/sx_browser.bc.js "$DEST/"
rm -rf "$DEST/sx_browser.bc.wasm.assets"
cp -r dist/sx_browser.bc.wasm.assets "$DEST/"
cp dist/sx-platform.js "$DEST/sx-platform-2.js"
cp dist/sx-platform.js "$DEST/sx-platform.js"
cp dist/sx/*.sx "$DEST/sx/"
cp dist/sx/*.sxbc "$DEST/sx/" 2>/dev/null || true
# Keep assets dir for Node.js WASM tests

View File

@@ -70,37 +70,26 @@ for (const file of FILES) {
}
// ---------------------------------------------------------------------------
// Strip define-library/import wrappers for bytecode compilation.
// Strip define-library wrapper for bytecode compilation.
//
// The VM's execute_module doesn't handle define-library or import — they're
// CEK special forms. So the compiled bytecode should contain just the body
// defines. The eval-blob phase (above) already handled library registration
// via CEK. The JS loader pre-resolves deps, so no registry needed at runtime.
// Keeps (import ...) forms — the compiler emits OP_PERFORM for these, enabling
// lazy loading: when the VM hits an import for an unloaded library, it suspends
// to the JS platform which fetches the library on demand.
//
// Strips define-library header (name, export) and (begin ...) wrapper, leaving
// the body defines + import instructions as top-level forms.
// ---------------------------------------------------------------------------
function stripLibraryWrapper(source) {
// Line-based stripping: remove (import ...), unwrap (define-library ... (begin BODY)).
// Works with both pre-existing and newly-wrapped formats.
// Line-based stripping: unwrap (define-library ... (begin BODY)), keep (import ...).
const lines = source.split('\n');
const result = [];
let skip = false; // inside header region (define-library, export)
let importDepth = 0; // tracking multi-line import paren depth
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Skip (import ...) — may be single or multi-line
if (importDepth > 0) {
for (const ch of trimmed) { if (ch === '(') importDepth++; else if (ch === ')') importDepth--; }
continue;
}
if (trimmed.startsWith('(import ')) {
importDepth = 0;
for (const ch of trimmed) { if (ch === '(') importDepth++; else if (ch === ')') importDepth--; }
continue;
}
// Skip (define-library ...) header lines until (begin
if (trimmed.startsWith('(define-library ')) { skip = true; continue; }
if (skip && trimmed.startsWith('(export')) { continue; }

View File

@@ -243,7 +243,7 @@ let api_parse src_js =
(** Build a JS suspension marker for the platform to handle.
Returns {suspended: true, op: string, request: obj, resume: fn(result)} *)
let make_js_suspension request resume_fn =
let _make_js_suspension request resume_fn =
let obj = Js.Unsafe.obj [||] in
Js.Unsafe.set obj (Js.string "suspended") (Js.Unsafe.inject (Js.bool true));
let op = match request with
@@ -380,44 +380,36 @@ let sync_vm_to_env () =
end
) _vm_globals
(** Recursive suspension handler: resumes VM, catches further suspensions,
resolves imports locally when possible, otherwise returns JS suspension
objects that the platform's while loop can process. *)
let rec resume_with_suspensions vm result =
try
let v = Sx_vm.resume_vm vm result in
(** Convert a VM suspension dict to a JS suspension object for the platform. *)
let rec make_js_import_suspension (d : (string, value) Hashtbl.t) =
let obj = Js.Unsafe.obj [||] in
Js.Unsafe.set obj (Js.string "suspended") (Js.Unsafe.inject Js._true);
Js.Unsafe.set obj (Js.string "op") (Js.Unsafe.inject (Js.string "import"));
let request = match Hashtbl.find_opt d "request" with Some v -> v | None -> Nil in
Js.Unsafe.set obj (Js.string "request") (value_to_js request);
(* resume callback: clears __io_request, pushes nil, re-runs VM *)
Js.Unsafe.set obj (Js.string "resume") (Js.wrap_callback (fun _result_js ->
let resumed = Sx_vm_ref.resume_module (Dict d) in
sync_vm_to_env ();
value_to_js v
with Sx_vm.VmSuspended (request, vm2) ->
handle_suspension request vm2
and handle_suspension request vm =
let op = match request with
| Dict d -> (match Hashtbl.find_opt d "op" with Some (String s) -> s | _ -> "")
| _ -> "" in
if op = "import" then
match handle_import_suspension request with
| Some result ->
(* Library already loaded — resume and handle further suspensions *)
resume_with_suspensions vm result
| None ->
(* Library not loaded — return suspension to JS for async fetch *)
Js.Unsafe.inject (make_js_suspension request (fun _result ->
resume_with_suspensions vm Nil))
else
Js.Unsafe.inject (make_js_suspension request (fun result ->
resume_with_suspensions vm result))
match resumed with
| Dict d2 when (match Hashtbl.find_opt d2 "suspended" with Some (Bool true) -> true | _ -> false) ->
Js.Unsafe.inject (make_js_import_suspension d2)
| result -> value_to_js result));
obj
let api_load_module module_js =
try
let code_val = js_to_value module_js in
let code = Sx_vm.code_from_value code_val in
let _result = Sx_vm_ref.execute_module code _vm_globals in
sync_vm_to_env ();
Js.Unsafe.inject (Hashtbl.length _vm_globals)
let result = Sx_vm_ref.execute_module code _vm_globals in
match result with
| Dict d when (match Hashtbl.find_opt d "suspended" with Some (Bool true) -> true | _ -> false) ->
(* VM suspended on OP_PERFORM (import) — return JS suspension object *)
Js.Unsafe.inject (make_js_import_suspension d)
| _ ->
sync_vm_to_env ();
Js.Unsafe.inject (Hashtbl.length _vm_globals)
with
| Sx_vm.VmSuspended (request, vm) ->
handle_suspension request vm
| Eval_error msg -> Js.Unsafe.inject (Js.string ("Error: " ^ msg))
| exn -> Js.Unsafe.inject (Js.string ("Error: " ^ Printexc.to_string exn))
@@ -628,7 +620,7 @@ let () =
in
let module_val = convert_code code_form in
let code = Sx_vm.code_from_value module_val in
let _result = Sx_vm_ref.execute_module code _vm_globals in
let _result = Sx_vm.execute_module code _vm_globals in
sync_vm_to_env ();
Number (float_of_int (Hashtbl.length _vm_globals))
| _ -> raise (Eval_error "load-sxbc: expected (sxbc version hash (code ...))"));

View File

@@ -95,7 +95,7 @@ node -e '
var K = globalThis.SxKernel;
if (!K) { console.error("FAIL: SxKernel not found"); process.exit(1); }
// --- Register 8 FFI host primitives (normally done by sx-platform-2.js) ---
// --- Register 8 FFI host primitives (normally done by sx-platform.js) ---
K.registerNative("host-global", function(args) {
var name = args[0];
if (typeof globalThis !== "undefined" && name in globalThis) return globalThis[name];
@@ -195,7 +195,7 @@ node -e '
assert("is-html-tag? fake", K.eval("(is-html-tag? \"fake\")"), false);
// =====================================================================
// Load web stack modules (same as sx-platform-2.js loadWebStack)
// Load web stack modules (same as sx-platform.js loadWebStack)
// =====================================================================
var fs = require("fs");
var webStackFiles = [

View File

@@ -598,6 +598,16 @@ let execute_module code globals =
run vm;
pop vm
(** Execute module, catching VmSuspended locally (same compilation unit).
Returns [Ok result] or [Error (request, vm)] for import suspension.
Needed because js_of_ocaml can't catch exceptions across module boundaries. *)
let execute_module_safe code globals =
try
let result = execute_module code globals in
Ok result
with VmSuspended (request, vm) ->
Error (request, vm)
(** {1 Lazy JIT compilation} *)

View File

@@ -455,7 +455,20 @@ let () = _vm_call_fn := vm_call
Public API — matches Sx_vm interface for drop-in replacement
================================================================ *)
(** Execute a compiled module — entry point for load-sxbc, compile-blob. *)
(** Build a suspension dict from __io_request in globals. *)
let check_io_suspension globals vm_val =
match Hashtbl.find_opt globals "__io_request" with
| Some req when sx_truthy req ->
let d = Hashtbl.create 4 in
Hashtbl.replace d "suspended" (Bool true);
Hashtbl.replace d "op" (String "import");
Hashtbl.replace d "request" req;
Hashtbl.replace d "vm" vm_val;
Some (Dict d)
| _ -> None
(** Execute a compiled module — entry point for load-sxbc, compile-blob.
Returns the result value, or a suspension dict if OP_PERFORM fired. *)
let execute_module (code : vm_code) (globals : (string, value) Hashtbl.t) =
let cl = { vm_code = code; vm_upvalues = [||]; vm_name = Some "module";
vm_env_ref = globals; vm_closure_env = None } in
@@ -468,7 +481,25 @@ let execute_module (code : vm_code) (globals : (string, value) Hashtbl.t) =
done;
m.vm_frames <- [frame];
ignore (vm_run vm_val);
vm_pop vm_val
match check_io_suspension globals vm_val with
| Some suspension -> suspension
| None -> vm_pop vm_val
(** Resume a suspended module. Clears __io_request, pushes nil, re-runs. *)
let resume_module (suspended : value) =
match suspended with
| Dict d ->
let vm_val = Hashtbl.find d "vm" in
let globals = match vm_val with
| VmMachine m -> m.vm_globals
| _ -> raise (Eval_error "resume_module: expected VmMachine") in
Hashtbl.replace globals "__io_request" Nil;
ignore (vm_push vm_val Nil);
ignore (vm_run vm_val);
(match check_io_suspension globals vm_val with
| Some suspension -> suspension
| None -> vm_pop vm_val)
| _ -> raise (Eval_error "resume_module: expected suspension dict")
(** Execute a closure with args — entry point for JIT Lambda calls. *)
let call_closure (cl : vm_closure) (args : value list) (globals : (string, value) Hashtbl.t) =