diff --git a/hosts/ocaml/bin/mcp_tree.ml b/hosts/ocaml/bin/mcp_tree.ml index 16ecfee4..acc3243e 100644 --- a/hosts/ocaml/bin/mcp_tree.ml +++ b/hosts/ocaml/bin/mcp_tree.ml @@ -107,7 +107,9 @@ let register_mcp_jit_hook () = if !(Sx_vm._jit_compiling) then None else (try Some (Sx_vm.call_closure_reuse cl args) - with e -> + with + | Sx_vm.VmSuspended _ as e -> raise e (* let Fix 1 in jit_try_call handle it *) + | e -> if not (Hashtbl.mem _jit_warned fn_name) then begin Hashtbl.replace _jit_warned fn_name true; Printf.eprintf "[mcp-jit] %s runtime fallback to CEK: %s\n%!" fn_name (Printexc.to_string e) @@ -124,7 +126,9 @@ let register_mcp_jit_hook () = | Some cl -> l.l_compiled <- Some cl; (try Some (Sx_vm.call_closure_reuse cl args) - with e -> + with + | Sx_vm.VmSuspended _ as e -> raise e + | e -> Printf.eprintf "[mcp-jit] %s first-call fallback: %s\n%!" fn_name (Printexc.to_string e); Hashtbl.replace _jit_warned fn_name true; l.l_compiled <- Some Sx_vm.jit_failed_sentinel; @@ -1572,6 +1576,10 @@ let handle_sx_playwright args = let island = args |> member "island" |> to_string_option in let phase = args |> member "phase" |> to_string_option in let filter = args |> member "filter" |> to_string_option in + let setup = args |> member "setup" |> to_string_option in + let stack = args |> member "stack" |> to_string_option in + let bytecode = try args |> member "bytecode" |> to_bool with _ -> false in + let files_json = try args |> member "files" with _ -> `Null in (* Determine whether to run specs or the inspector *) let use_inspector = match mode with | Some m when m <> "run" -> true @@ -1631,6 +1639,12 @@ let handle_sx_playwright args = (match island with Some i -> Some ("island", `String i) | None -> None); (match phase with Some p -> Some ("phase", `String p) | None -> None); (match filter with Some f -> Some ("filter", `String f) | None -> None); + (match setup with Some s -> Some ("setup", `String s) | None -> None); + (match stack with Some s -> Some ("stack", `String s) | None -> None); + (if bytecode then Some ("bytecode", `Bool true) else None); + (match files_json with + | `List items -> Some ("files", `List (List.map (fun j -> `String (Yojson.Safe.Util.to_string j)) items)) + | _ -> None); ]) in let args_json = Yojson.Basic.to_string inspector_args in (* Single-quote shell wrapping — escape any literal single quotes in JSON *) @@ -1690,10 +1704,26 @@ let handle_sx_harness_eval args = let session = Sx_ref.cek_call (env_get e "make-harness") mock_arg in (* Install interceptors *) ignore (call_sx "install-interceptors" [session; Env e]); + (* IO-aware eval: drives cek_step_loop + cek_resume to handle perform *) + let io_log = ref [] in + let eval_with_io expr = + let state = Sx_ref.make_cek_state expr (Env e) (List []) in + let rec drive st = + let final = Sx_ref.cek_step_loop st in + match Sx_ref.cek_suspended_p final with + | Bool true -> + let request = Sx_runtime.get_val final (String "request") in + io_log := request :: !io_log; + let resumed = Sx_ref.cek_resume final Nil in + drive resumed + | _ -> Sx_ref.cek_value final + in + drive state + in (* Evaluate the expression *) let exprs = Sx_parser.parse_all expr_str in let result = List.fold_left (fun _acc expr -> - try Sx_ref.eval_expr expr (Env e) + try eval_with_io expr with exn -> String (Printf.sprintf "Error: %s" (Printexc.to_string exn)) ) Nil exprs in (* Get the IO log *) @@ -1705,7 +1735,13 @@ let handle_sx_harness_eval args = let args_val = call_sx "get" [entry; String "args"] in Printf.sprintf " %s(%s)" op (Sx_types.inspect args_val) ) items) - | _ -> "\n\n(no IO calls)" + | _ -> + if !io_log <> [] then + "\n\nIO suspensions (" ^ string_of_int (List.length !io_log) ^ "):\n" ^ + String.concat "\n" (List.rev_map (fun req -> + " (perform " ^ Sx_types.inspect req ^ ")" + ) !io_log) + else "\n\n(no IO calls)" in let warn_str = if !warnings = [] then "" else "\n\nWarnings:\n" ^ String.concat "\n" (List.rev !warnings) @@ -2767,14 +2803,18 @@ let tool_definitions = `List [ []; tool "sx_playwright" "Run Playwright browser tests or inspect SX pages interactively. Modes: run (spec files), inspect (page/island report with leak detection and handler audit), diff (full SSR vs hydrated DOM), hydrate (lake-focused SSR vs hydrated comparison — detects clobbering), eval (JS expression), interact (action sequence), screenshot, listeners (CDP event listener inspection), trace (click + capture console/network/pushState), cdp (raw CDP command), trace-boot (full console capture during boot — ALL prefixes), hydrate-debug (re-run island hydration with full env/state tracing), eval-at (inject eval at a specific boot phase)." [("spec", `Assoc [("type", `String "string"); ("description", `String "Spec file to run (run mode). e.g. stepper.spec.js")]); - ("mode", `Assoc [("type", `String "string"); ("description", `String "Mode: run, inspect, diff, hydrate, eval, interact, screenshot, listeners, trace, cdp, trace-boot, hydrate-debug, eval-at")]); + ("mode", `Assoc [("type", `String "string"); ("description", `String "Mode: run, inspect, diff, hydrate, eval, interact, screenshot, listeners, trace, cdp, trace-boot, hydrate-debug, eval-at, sandbox (offline WASM kernel — no server needed)")]); ("phase", `Assoc [("type", `String "string"); ("description", `String "Boot phase for eval-at mode: before-modules, after-modules, before-pages, after-pages, before-components, after-components, before-hydrate, after-hydrate, after-boot")]); ("filter", `Assoc [("type", `String "string"); ("description", `String "Filter prefix for trace-boot mode (e.g. '[sx-platform]')")]); ("url", `Assoc [("type", `String "string"); ("description", `String "URL path to navigate to (default: /)")]); ("island", `Assoc [("type", `String "string"); ("description", `String "Filter inspect to a specific island by name (e.g. home/stepper)")]); ("selector", `Assoc [("type", `String "string"); ("description", `String "CSS selector for screenshot/listeners/trace modes")]); ("expr", `Assoc [("type", `String "string"); ("description", `String "JS expression (eval mode), selector (listeners/trace), or CDP command (cdp mode)")]); - ("actions", `Assoc [("type", `String "string"); ("description", `String "Semicolon-separated action sequence (interact mode). Actions: click:sel, fill:sel:val, wait:ms, text:sel, html:sel, attrs:sel, screenshot, screenshot:sel, count:sel, visible:sel")])] + ("actions", `Assoc [("type", `String "string"); ("description", `String "Semicolon-separated action sequence (interact mode). Actions: click:sel, fill:sel:val, wait:ms, text:sel, html:sel, attrs:sel, screenshot, screenshot:sel, count:sel, visible:sel")]); + ("files", `Assoc [("type", `String "array"); ("items", `Assoc [("type", `String "string")]); ("description", `String ".sx files to load (sandbox mode)")]); + ("setup", `Assoc [("type", `String "string"); ("description", `String "SX setup expression (sandbox mode)")]); + ("stack", `Assoc [("type", `String "string"); ("description", `String "Module stack for sandbox: core (default), web (full web stack), hs (web + hyperscript), test (web + test framework)")]); + ("bytecode", `Assoc [("type", `String "boolean"); ("description", `String "Load .sxbc bytecode instead of .sx source in sandbox (default: false)")])] []; ] diff --git a/tests/playwright/sx-inspect.js b/tests/playwright/sx-inspect.js index c0fe7aa7..2159b291 100644 --- a/tests/playwright/sx-inspect.js +++ b/tests/playwright/sx-inspect.js @@ -1331,7 +1331,7 @@ const SANDBOX_STACKS = { ], }; -async function modeSandbox(page, expr, files, setup, stack) { +async function modeSandbox(page, expr, files, setup, stack, bytecode) { const fs = require('fs'); const path = require('path'); const PROJECT_ROOT = path.resolve(__dirname, '../..'); @@ -1489,25 +1489,47 @@ async function modeSandbox(page, expr, files, setup, stack) { 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; + let loaded = false; + // Try bytecode first if requested + if (bytecode) { + const bcPath = path.join(SX_DIR, mod + '.sxbc'); + if (fs.existsSync(bcPath)) { + const bcSrc = fs.readFileSync(bcPath, 'utf8'); + const err = await page.evaluate(src => { + try { + const K = window.SxKernel; + // Use K.load with (load-sxbc ...) wrapper — handles IO suspension in CEK + window.__sxbcText = src; + const r = K.load('(load-sxbc (first (parse (host-global "__sxbcText"))))'); + delete window.__sxbcText; + if (typeof r === 'string' && r.startsWith('Error')) return r; + return null; + } catch(e) { delete window.__sxbcText; return 'CATCH: ' + e.message; } + }, bcSrc); + if (err) loadErrors.push(mod + '.sxbc: ' + err); + else { loaded = true; loadedModules.push(mod + '.sxbc'); } + } + } + // Fall back to .sx source + if (!loaded) { + const sxPath = path.join(SX_DIR, mod + '.sx'); + 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); } - 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(); @@ -1549,11 +1571,22 @@ async function modeSandbox(page, expr, files, setup, stack) { window._asyncPending = 0; let syncResult; try { - K.eval('(define _sandbox_thunk (fn () ' + expr + '))'); - syncResult = K.callFn(K.eval('_sandbox_thunk'), []); + // First try K.eval directly (handles most expressions) + syncResult = K.eval(expr); } catch(e) { - resolve({ result: 'Error: ' + e.message, ioTrace: window._ioTrace }); - return; + // If eval fails with IO suspension, retry via callFn thunk + if (e.message && e.message.includes('suspension')) { + try { + K.eval('(define _sandbox_thunk (fn () ' + expr + '))'); + syncResult = K.callFn(K.eval('_sandbox_thunk'), []); + } catch(e2) { + resolve({ result: 'Error: ' + e2.message, ioTrace: window._ioTrace }); + return; + } + } else { + resolve({ result: 'Error: ' + e.message, ioTrace: window._ioTrace }); + return; + } } // If the thunk itself suspended (direct perform), drive it inline @@ -1688,7 +1721,7 @@ async function main() { 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 || ''); + result = await modeSandbox(page, args.expr || '"hello"', args.files || [], args.setup || '', args.stack || '', !!args.bytecode); break; default: result = { error: `Unknown mode: ${mode}` };