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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 ...))"));
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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} *)
|
||||
|
||||
|
||||
@@ -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) =
|
||||
|
||||
Reference in New Issue
Block a user