diff --git a/Cargo.lock b/Cargo.lock index 8f9b83e..96ddf27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1410,6 +1410,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -1771,9 +1777,11 @@ version = "0.1.0" dependencies = [ "anyhow", "js-sys", + "send_wrapper", "wafer-core", "wasm-bindgen", "wasm-bindgen-test", + "wasm-encoder 0.246.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c6fa37a..459aa29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,3 +50,4 @@ proptest = "1" insta = "1" sha1 = "0.11" sha2 = "0.11" +send_wrapper = "0.6" diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 1bb212a..49a0bd5 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -15,6 +15,8 @@ crate-type = ["cdylib", "rlib"] wafer-core = { path = "../core", version = "0.1.0", default-features = false, features = ["crypto"] } wasm-bindgen = "0.2" js-sys = "0.3" +send_wrapper = { workspace = true } +wasm-encoder = { workspace = true } anyhow = { workspace = true } [dev-dependencies] diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs index 2139dbb..226919f 100644 --- a/crates/web/src/lib.rs +++ b/crates/web/src/lib.rs @@ -2,9 +2,13 @@ mod runtime_web; +use send_wrapper::SendWrapper; use wasm_bindgen::prelude::*; +use wafer_core::config::WaferConfig; +use wafer_core::memory::{CELL_SIZE, PAD_BASE, PAD_SIZE}; use wafer_core::outer::ForthVM; +use wafer_core::runtime::{HostAccess, HostFn}; use crate::runtime_web::WebRuntime; @@ -19,7 +23,13 @@ impl WaferRepl { /// Create a new WAFER REPL instance with all built-in words. #[wasm_bindgen(constructor)] pub fn new() -> Result { - let vm = ForthVM::::new().map_err(|e| JsError::new(&e.to_string()))?; + // Disable stack-to-local promotion: it currently mis-models host- + // function calls in the web runtime, leaving a ghost copy of the + // pre-call args on the Forth data stack after the host word returns. + let mut cfg = WaferConfig::all(); + cfg.codegen.stack_to_local_promotion = false; + let vm = ForthVM::::new_with_config(cfg) + .map_err(|e| JsError::new(&e.to_string()))?; Ok(WaferRepl { vm }) } @@ -50,7 +60,75 @@ impl WaferRepl { /// Reset the VM to initial state. pub fn reset(&mut self) -> Result<(), JsError> { - self.vm = ForthVM::::new().map_err(|e| JsError::new(&e.to_string()))?; + let mut cfg = WaferConfig::all(); + cfg.codegen.stack_to_local_promotion = false; + self.vm = ForthVM::::new_with_config(cfg) + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(()) + } + + /// Register a JavaScript function as a Forth word with stack effect + /// `( prompt-a prompt-u -- pw-a pw-u )`. + /// + /// The JS function receives one argument — the prompt string — and must + /// return the password as a string (synchronously; `window.prompt` is a + /// reasonable baseline, a masked DOM overlay is a strict improvement). + /// The returned bytes are written into WAFER's `PAD` region; callers + /// must consume them before invoking any other word that also writes + /// to `PAD`. + /// + /// Registering under a dedicated name (e.g. `"JS-PROMPT"`) and then + /// retargeting an existing DEFER with `' JS-PROMPT IS READ-PASSWORD` + /// is the usual pattern — it lets late-binding downstream words like + /// kelvar's `PASS` pick up the host implementation without recompiling. + pub fn set_prompter(&mut self, name: &str, js_fn: js_sys::Function) -> Result<(), JsError> { + let holder = SendWrapper::new(js_fn); + let max = (PAD_SIZE - 1) as usize; + + let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| { + // Pop ( prompt-a prompt-u ): advance dsp by 2 cells. + let mut sp = ctx.get_dsp(); + let u = ctx.mem_read_i32(sp) as u32; + sp += CELL_SIZE; + let a = ctx.mem_read_i32(sp) as u32; + sp += CELL_SIZE; + ctx.set_dsp(sp); + + let prompt_bytes = ctx.mem_read_slice(a, u as usize); + let prompt = String::from_utf8_lossy(&prompt_bytes).to_string(); + + let result = holder + .call1(&JsValue::NULL, &JsValue::from_str(&prompt)) + .map_err(|e| { + anyhow::anyhow!( + "prompter threw: {}", + e.as_string().unwrap_or_else(|| "".into()) + ) + })?; + let pw = result.as_string().unwrap_or_default(); + + let bytes = pw.as_bytes(); + if bytes.len() > max { + anyhow::bail!( + "READ-PASSWORD: master too long ({} > {} bytes)", + bytes.len(), + max + ); + } + + ctx.mem_write_slice(PAD_BASE, bytes); + // Push ( PAD bytes.len() ) + sp -= CELL_SIZE; + ctx.mem_write_i32(sp, PAD_BASE as i32); + sp -= CELL_SIZE; + ctx.mem_write_i32(sp, bytes.len() as i32); + ctx.set_dsp(sp); + Ok(()) + }); + + self.vm + .register_host_primitive(name, false, func) + .map_err(|e| JsError::new(&e.to_string()))?; Ok(()) } } diff --git a/crates/web/src/runtime_web.rs b/crates/web/src/runtime_web.rs index 363be88..a25ce73 100644 --- a/crates/web/src/runtime_web.rs +++ b/crates/web/src/runtime_web.rs @@ -18,7 +18,7 @@ pub(crate) struct WebRuntime { emit_func: JsValue, #[allow(dead_code)] output: Arc>, - /// Keep closures alive to prevent GC. + /// Keep closures and wrapper-module Instances alive to prevent GC. _closures: Vec, } @@ -209,9 +209,12 @@ impl Runtime for WebRuntime { out_ref.lock().unwrap().push(ch); }) as Box); - // Use WebAssembly.Function if available, else wrap in a tiny module - let emit_func = make_wasm_function_i32(&emit_closure.as_ref().into()); - let mut closures = vec![emit_closure.into_js_value()]; + // Use WebAssembly.Function if available, else wrap in a tiny module. + // The wrapper-module path also returns the Instance so we can keep it + // alive for the lifetime of the runtime (browsers can otherwise GC + // the instance and orphan the funcref's body). + let (emit_func, emit_keep) = make_wasm_function_i32(&emit_closure.as_ref().into()); + let mut closures = vec![emit_closure.into_js_value(), emit_keep]; let _ = &mut closures; // keep alive Ok(WebRuntime { @@ -432,7 +435,7 @@ impl Runtime for WebRuntime { } }) as Box); - let wasm_func = make_wasm_function_void(&closure.as_ref().into()); + let (wasm_func, keep_alive) = make_wasm_function_void(&closure.as_ref().into()); let set_fn: Function = Reflect::get(&self.table, &"set".into()) .unwrap() @@ -441,14 +444,24 @@ impl Runtime for WebRuntime { .call2(&self.table, &JsValue::from(fn_index), &wasm_func) .map_err(|e| anyhow::anyhow!("table.set({fn_index}) failed: {e:?}"))?; + // Stash both the closure AND the wrapper-module instance so neither + // is garbage-collected while the funcref is still in WAFER's table. + // Without keeping the Instance alive, browsers can orphan the funcref's + // body and cross-module `call_indirect` silently fails to dispatch. self._closures.push(closure.into_js_value()); + self._closures.push(keep_alive); Ok(()) } } -/// Create a `WebAssembly.Function({parameters:['i32'],results:[]}, jsFn)`. -/// Falls back to a wrapper module if `WebAssembly.Function` is unavailable. -fn make_wasm_function_i32(js_fn: &JsValue) -> JsValue { +/// Create a wasm-callable funcref of type `(i32) -> ()` from a JS callback. +/// +/// Returns `(funcref, keep_alive)`. Callers MUST stash `keep_alive` somewhere +/// the GC can see — for the wrapper-module path it's the underlying +/// `WebAssembly.Instance`, and the funcref is only valid as long as the +/// instance lives. The `WebAssembly.Function` constructor path returns +/// `JsValue::NULL` for `keep_alive`. +fn make_wasm_function_i32(js_fn: &JsValue) -> (JsValue, JsValue) { if let Ok(wasm_func_ctor) = get_wasm_function_ctor() { let desc = Object::new(); let params = js_sys::Array::new(); @@ -458,15 +471,15 @@ fn make_wasm_function_i32(js_fn: &JsValue) -> JsValue { let args = js_sys::Array::new(); args.push(&desc); args.push(js_fn); - Reflect::construct(&wasm_func_ctor.unchecked_into::(), &args).unwrap() + let f = Reflect::construct(&wasm_func_ctor.unchecked_into::(), &args).unwrap(); + (f, JsValue::NULL) } else { - // Fallback: create a tiny WASM module that wraps the JS function make_wrapper_module_i32(js_fn) } } -/// Create a `WebAssembly.Function({parameters:[],results:[]}, jsFn)`. -fn make_wasm_function_void(js_fn: &JsValue) -> JsValue { +/// Create a wasm-callable funcref of type `() -> ()`. See [`make_wasm_function_i32`]. +fn make_wasm_function_void(js_fn: &JsValue) -> (JsValue, JsValue) { if let Ok(wasm_func_ctor) = get_wasm_function_ctor() { let desc = Object::new(); Reflect::set(&desc, &"parameters".into(), &js_sys::Array::new()).unwrap(); @@ -474,7 +487,8 @@ fn make_wasm_function_void(js_fn: &JsValue) -> JsValue { let args = js_sys::Array::new(); args.push(&desc); args.push(js_fn); - Reflect::construct(&wasm_func_ctor.unchecked_into::(), &args).unwrap() + let f = Reflect::construct(&wasm_func_ctor.unchecked_into::(), &args).unwrap(); + (f, JsValue::NULL) } else { make_wrapper_module_void(js_fn) } @@ -491,45 +505,66 @@ fn get_wasm_function_ctor() -> Result { } } -/// Fallback: create a minimal WASM module that imports and re-exports a void→void function. -fn make_wrapper_module_void(js_fn: &JsValue) -> JsValue { - // (module (import "e" "f" (func)) (export "f" (func 0))) +/// Fallback: create a minimal WASM module that imports a JS function and +/// exports a **local** trampoline which calls it. +/// +/// Exporting the imported funcref directly (the previous approach) produces a +/// funcref that works when invoked via JS `.call()`, but v8 and other engines +/// do not always dispatch to the underlying JS body when that funcref is +/// stored in a different module's table and invoked via `call_indirect`. A +/// local trampoline (a real WASM function that `call`s the import) gives a +/// stable, spec-correct funcref usable from any caller. +fn make_wrapper_module_void(js_fn: &JsValue) -> (JsValue, JsValue) { + // (module + // (import "e" "f" (func $imp)) ;; func 0 + // (func (export "f") (call $imp)) ;; func 1 + // ) #[rustfmt::skip] let bytes: &[u8] = &[ - 0x00, 0x61, 0x73, 0x6d, // magic - 0x01, 0x00, 0x00, 0x00, // version - // type section: 1 type, () -> () + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic + version + // type: 1 type, () -> () 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, - // import section: import "e" "f" func type 0 + // import: "e"."f" func type 0 -> imported func index 0 0x02, 0x07, 0x01, 0x01, 0x65, 0x01, 0x66, 0x00, 0x00, - // export section: export "f" func 0 - 0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x00, + // function: 1 local func of type 0 -> local func index 1 + 0x03, 0x02, 0x01, 0x00, + // export: "f" -> func index 1 (the local trampoline) + 0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x01, + // code: 1 body, 4 bytes, 0 locals, `call 0`, `end` + 0x0a, 0x06, 0x01, 0x04, 0x00, 0x10, 0x00, 0x0b, ]; - let u8arr = Uint8Array::from(bytes); - let module = WebAssembly::Module::new(&u8arr.into()).unwrap(); - let env = Object::new(); - Reflect::set(&env, &"f".into(), js_fn).unwrap(); - let imports = Object::new(); - Reflect::set(&imports, &"e".into(), &env).unwrap(); - let instance = WebAssembly::Instance::new(&module, &imports).unwrap(); - let exports = Reflect::get(&instance, &"exports".into()).unwrap(); - Reflect::get(&exports, &"f".into()).unwrap() + instantiate_wrapper(bytes, js_fn) } -/// Fallback: create a minimal WASM module that imports and re-exports an (i32)→() function. -fn make_wrapper_module_i32(js_fn: &JsValue) -> JsValue { - // (module (import "e" "f" (func (param i32))) (export "f" (func 0))) +/// Fallback: same idea as [`make_wrapper_module_void`] but for `(i32) -> ()`. +/// The trampoline forwards its one parameter to the import. +fn make_wrapper_module_i32(js_fn: &JsValue) -> (JsValue, JsValue) { + // (module + // (import "e" "f" (func $imp (param i32))) + // (func (export "f") (param i32) (local.get 0) (call $imp)) + // ) #[rustfmt::skip] let bytes: &[u8] = &[ - 0x00, 0x61, 0x73, 0x6d, - 0x01, 0x00, 0x00, 0x00, - // type section: 1 type, (i32) -> () + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + // type: 1 type, (i32) -> () 0x01, 0x05, 0x01, 0x60, 0x01, 0x7f, 0x00, - // import section: import "e" "f" func type 0 + // import: "e"."f" func type 0 0x02, 0x07, 0x01, 0x01, 0x65, 0x01, 0x66, 0x00, 0x00, - // export section: export "f" func 0 - 0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x00, + // function: 1 local func of type 0 + 0x03, 0x02, 0x01, 0x00, + // export: "f" -> func index 1 + 0x07, 0x05, 0x01, 0x01, 0x66, 0x00, 0x01, + // code: 1 body, 6 bytes, 0 locals, `local.get 0`, `call 0`, `end` + 0x0a, 0x08, 0x01, 0x06, 0x00, 0x20, 0x00, 0x10, 0x00, 0x0b, ]; + instantiate_wrapper(bytes, js_fn) +} + +/// Shared: compile the wrapper module, instantiate with `js_fn` bound to +/// import `"e"."f"`, and return both the exported local trampoline and +/// the underlying `Instance` so callers can keep it alive (browsers can +/// otherwise GC the instance and orphan the funcref's body). +fn instantiate_wrapper(bytes: &[u8], js_fn: &JsValue) -> (JsValue, JsValue) { let u8arr = Uint8Array::from(bytes); let module = WebAssembly::Module::new(&u8arr.into()).unwrap(); let env = Object::new(); @@ -537,6 +572,8 @@ fn make_wrapper_module_i32(js_fn: &JsValue) -> JsValue { let imports = Object::new(); Reflect::set(&imports, &"e".into(), &env).unwrap(); let instance = WebAssembly::Instance::new(&module, &imports).unwrap(); + let instance_jv: JsValue = instance.clone().into(); let exports = Reflect::get(&instance, &"exports".into()).unwrap(); - Reflect::get(&exports, &"f".into()).unwrap() + let func = Reflect::get(&exports, &"f".into()).unwrap(); + (func, instance_jv) }