diff --git a/crates/js-component-bindgen/src/core.rs b/crates/js-component-bindgen/src/core.rs index fbaf908ca..70a06d09b 100644 --- a/crates/js-component-bindgen/src/core.rs +++ b/crates/js-component-bindgen/src/core.rs @@ -55,6 +55,62 @@ use wasmtime_environ::{ PrimaryMap, }; +/// Dispatch a host import through JSPI only when its caller may block. The +/// dispatcher must itself be Wasm: a JS wrapper would prevent suspension across +/// the intervening JS frame. Preserve the exact canonical lowering signature. +pub(crate) fn conditional_host_import(ty: &wasmtime_environ::WasmFuncType) -> Vec { + use wasm_encoder::{BlockType, Instruction, ValType}; + use wasmtime_environ::WasmValType; + + let encode_type = |ty: &WasmValType| match ty { + WasmValType::I32 => ValType::I32, + WasmValType::I64 => ValType::I64, + WasmValType::F32 => ValType::F32, + WasmValType::F64 => ValType::F64, + _ => unreachable!("host import canonical ABI must use numeric types"), + }; + let mut types = TypeSection::new(); + types.ty().function([], [ValType::I32]); + types.ty().function( + ty.params().iter().map(encode_type), + ty.results().iter().map(encode_type), + ); + + let mut imports = ImportSection::new(); + imports.import("", "mayBlock", EntityType::Function(0)); + imports.import("", "fast", EntityType::Function(1)); + imports.import("", "slow", EntityType::Function(1)); + let mut functions = FunctionSection::new(); + functions.function(1); + let mut exports = ExportSection::new(); + exports.export("run", ExportKind::Func, 3); + + let mut body = Function::new([]); + body.instruction(&Instruction::Call(0)); + body.instruction(&Instruction::If(BlockType::Empty)); + for i in 0..ty.params().len() { + body.instruction(&Instruction::LocalGet(i as u32)); + } + body.instruction(&Instruction::Call(2)); + body.instruction(&Instruction::Return); + body.instruction(&Instruction::End); + for i in 0..ty.params().len() { + body.instruction(&Instruction::LocalGet(i as u32)); + } + body.instruction(&Instruction::Call(1)); + body.instruction(&Instruction::End); + let mut code = CodeSection::new(); + code.function(&body); + + let mut module = Module::new(); + module.section(&types); + module.section(&imports); + module.section(&functions); + module.section(&exports); + module.section(&code); + module.finish() +} + pub enum Translation<'a> { Normal(ModuleTranslation<'a>), Augmented { diff --git a/crates/js-component-bindgen/src/function_bindgen.rs b/crates/js-component-bindgen/src/function_bindgen.rs index b5a7be0ed..7988b26d8 100644 --- a/crates/js-component-bindgen/src/function_bindgen.rs +++ b/crates/js-component-bindgen/src/function_bindgen.rs @@ -229,6 +229,11 @@ pub struct FunctionBindgen<'a> { /// Whether the function is guest async lifted (i.e. WASI P3) pub is_async: bool, + /// Whether the body of an import is generated for a generator function (run by + /// `MaybeSyncGeneratorFn`), which yields the host's answer instead of awaiting it, + /// so that it is synchronous unless the host returns a promise + pub(crate) host_import_generator: bool, + /// Whether the canonical ABI call itself is async. /// /// This can differ from `is_async` when an async component function is @@ -2391,12 +2396,19 @@ impl Bindgen for FunctionBindgen<'_> { // at the CM level -- async functions always return let fn_wasm_result_count = if func.result.is_none() { 0 } else { 1 }; + // A generator yields only the host's answer, and a host task enters at once + let (await_, enter_task) = if self.host_import_generator { + ("yield ", "task.enterHost()") + } else { + ("await ", "await task.enter({ isHost: hostProvided })") + }; + // If the task is async, do an explicit wait for backpressure before the call execution if task_managed && is_async { uwriteln!( self.src, r#" - const started = await task.enter({{ isHost: hostProvided }}); + const started = {enter_task}; if (!started) {{ {debug_log_fn}('[Instruction::CallInterface] failed to enter task', {{ taskID: task.id(), @@ -2424,7 +2436,7 @@ impl Bindgen for FunctionBindgen<'_> { || self.requires_async_porcelain { ( - "await ", + await_, self.task_intrinsic(Intrinsic::WithGlobalCurrentThreadMetaFnAsync), format!( r#" diff --git a/crates/js-component-bindgen/src/intrinsics/mod.rs b/crates/js-component-bindgen/src/intrinsics/mod.rs index 118ff52df..3fad2d943 100644 --- a/crates/js-component-bindgen/src/intrinsics/mod.rs +++ b/crates/js-component-bindgen/src/intrinsics/mod.rs @@ -169,9 +169,16 @@ pub enum Intrinsic { /// Execute a closure with a certain set current thread WithGlobalCurrentThreadMetaFn, - /// Execute an async closure with a certain set current thread + /// Execute a closure with a certain set current thread, which is async only + /// if the closure returns a promise WithGlobalCurrentThreadMetaFnAsync, + /// Run a generator function synchronously, until it yields a promise + MaybeSyncGeneratorFn, + + /// Bypass the JSPI boundary when a host import's caller cannot block. + ConditionalSuspendingHostImportFn, + /// Clear the global thread meta ClearGlobalCurrentThreadMetaFn, @@ -1212,6 +1219,8 @@ impl Intrinsic { // In the future, this mechanism may be replaced with a simple event loop that necessarily executes // all pending work serially, with this intrinsic becoming simply queueing work onto that event loop. // + // A closure that returns synchronously is treated like WithGlobalCurrentThreadMetaFn. + // While the promise of one that returns a promise is pending, the task stays current. Self::WithGlobalCurrentThreadMetaFnAsync => { let debug_log_fn = args.require_intrinsic(Intrinsic::DebugLog); let with_global_current_thread_meta_async_fn = @@ -1221,7 +1230,7 @@ impl Intrinsic { output.push_str(&format!( r#" - async function {with_global_current_thread_meta_async_fn}(args) {{ + function {with_global_current_thread_meta_async_fn}(args) {{ {debug_log_fn}('[{with_global_current_thread_meta_async_fn}()] args', args); if (!args) {{ throw new TypeError('args missing'); }} if (args.taskID === undefined) {{ throw new TypeError('missing task ID'); }} @@ -1230,27 +1239,88 @@ impl Intrinsic { if (!args.fn) {{ throw new TypeError('missing fn'); }} const {{ taskID, threadID, componentIdx, fn }} = args; + const previous = {global_current_thread_meta_obj}[componentIdx] ?? null; + const previousCurrent = {global_current_thread_meta_obj}.current ?? null; + let result; try {{ {global_current_thread_meta_obj}.current = - {global_current_thread_meta_obj}[componentIdx] = {{ threadID, taskID, componentIdx }}; - return await fn(); + {global_current_thread_meta_obj}[componentIdx] = {{ taskID, threadID, componentIdx }}; + result = fn(); }} catch (err) {{ - {debug_log_fn}("error while executing async callee/callback", {{ + {debug_log_fn}("error while executing sync callee/callback", {{ ...args, err, }}); + {global_current_thread_meta_obj}[componentIdx] = previous; + {global_current_thread_meta_obj}.current = previousCurrent; throw err; - }} finally {{ - if ({global_current_thread_meta_obj}[componentIdx]?.threadID === threadID && - {global_current_thread_meta_obj}[componentIdx]?.taskID === taskID) {{ - {global_current_thread_meta_obj}[componentIdx] = null; - }} - if ({global_current_thread_meta_obj}.current?.threadID === threadID && - {global_current_thread_meta_obj}.current?.taskID === taskID) {{ - {global_current_thread_meta_obj}.current = null; - }} }} + + if (result === null || + (typeof result !== 'object' && typeof result !== 'function') || + typeof result.then !== 'function') {{ + {global_current_thread_meta_obj}[componentIdx] = previous; + {global_current_thread_meta_obj}.current = previousCurrent; + return result; + }} + + return (async () => {{ + try {{ + return await result; + }} catch (err) {{ + {debug_log_fn}("error while executing async callee/callback", {{ + ...args, + err, + }}); + throw err; + }} finally {{ + if ({global_current_thread_meta_obj}[componentIdx]?.threadID === threadID && + {global_current_thread_meta_obj}[componentIdx]?.taskID === taskID) {{ + {global_current_thread_meta_obj}[componentIdx] = null; + }} + if ({global_current_thread_meta_obj}.current?.threadID === threadID && + {global_current_thread_meta_obj}.current?.taskID === taskID) {{ + {global_current_thread_meta_obj}.current = null; + }} + }} + }})(); + }} + "#, + )); + } + + // Wrap a generator function, which yields where an async function would await, + // so that it runs synchronously until it yields a promise. A host import that + // answers synchronously then returns synchronously, which a task that cannot + // block (e.g. a resource destructor) requires. + Self::MaybeSyncGeneratorFn => { + let maybe_sync_generator_fn = args.require_intrinsic(Self::MaybeSyncGeneratorFn); + + output.push_str(&format!( + r#" + function {maybe_sync_generator_fn}(genFn) {{ + return function (...args) {{ + const gen = genFn.apply(this, args); + const step = (method, value) => {{ + while (true) {{ + const {{ value: yielded, done }} = gen[method](value); + if (done) {{ return yielded; }} + if (yielded === null || + (typeof yielded !== 'object' && typeof yielded !== 'function') || + typeof yielded.then !== 'function') {{ + method = 'next'; + value = yielded; + continue; + }} + return Promise.resolve(yielded).then( + res => step('next', res), + err => step('throw', err), + ); + }} + }}; + return step('next', undefined); + }}; }} "#, )); @@ -1300,6 +1370,8 @@ impl Intrinsic { // can abandon an unobserved promise. A nonblocking task may still // call a suspending import when the helper completes synchronously, // so preserve its raw result and reject only a returned promise. + // A host import (`callsHost`) handles a call without a current task, + // such as a destructor run outside of any task, in the same way. // // Run blocking helpers entirely inside an async function. Moving // the helper invocation outside that function changes JSPI @@ -1327,7 +1399,7 @@ impl Intrinsic { output.push_str(&format!( r#" - function {suspending_import_wrapper_fn}(componentIdx, fn, syncOnly = false, switchesTask = false, convertsRangeErrors = true) {{ + function {suspending_import_wrapper_fn}(componentIdx, fn, syncOnly = false, switchesTask = false, convertsRangeErrors = true, callsHost = false) {{ return function (...args) {{ {check_may_leave_fn}(componentIdx); const saved = {global_current_thread_meta_obj}[componentIdx] ?? null; @@ -1338,7 +1410,7 @@ impl Intrinsic { const mayBlock = savedThread ? (savedThread?.task.mayBlock() ?? ({current_task_may_block}.value !== 0)) : false; - if (!saved && !mayBlock) {{ + if (!saved && !mayBlock && !callsHost) {{ throw new {runtime_error_class}('cannot block a synchronous task before returning'); }} @@ -1389,6 +1461,30 @@ impl Intrinsic { )); } + Self::ConditionalSuspendingHostImportFn => { + let wrapper = args.require_intrinsic(*self); + let current_meta = args.require_intrinsic(Self::GlobalCurrentThreadMeta); + let get_thread = args.require_intrinsic(AsyncTaskIntrinsic::GetCurrentThread); + uwriteln!( + output, + r#" + function {wrapper}(module, componentIdx, fn) {{ + return new WebAssembly.Instance(module, {{ '': {{ + mayBlock: () => {{ + const saved = {current_meta}[componentIdx]; + const thread = saved + ? {get_thread}(saved.componentIdx, saved.threadID) + : null; + return thread?.task.mayBlock() ?? false; + }}, + fast: fn, + slow: new WebAssembly.Suspending(fn), + }} }}).exports.run; + }} + "#, + ); + } + Self::ConditionalSuspending1I32ToI32Fn | Self::ConditionalSuspending2I32ToI32Fn | Self::ConditionalSuspending3I32ToI32Fn @@ -1661,7 +1757,7 @@ mod tests { assert!(source.contains("if (syncOnly || !mayBlock) {")); assert!(!source.contains("return async function (...args) {")); - assert!(source.contains("if (!saved && !mayBlock) {")); + assert!(source.contains("if (!saved && !mayBlock && !callsHost) {")); assert!(source.contains("result = fn.apply(null, args);")); assert!(source.contains("typeof result.then === 'function'")); @@ -1675,6 +1771,26 @@ mod tests { assert!(source.contains("CURRENT_TASK_META[componentIdx] = saved;")); } + #[test] + fn maybe_sync_generator_awaits_only_promises() { + let source = render_intrinsic_body(Intrinsic::MaybeSyncGeneratorFn); + + assert!(source.contains("const gen = genFn.apply(this, args);")); + assert!(source.contains("typeof yielded.then !== 'function'")); + assert!(source.contains("return Promise.resolve(yielded).then(")); + assert!(source.contains("err => step('throw', err),")); + } + + #[test] + fn async_current_task_wrapper_restores_sync_results() { + let source = render_intrinsic_body(Intrinsic::WithGlobalCurrentThreadMetaFnAsync); + + assert!(source.contains("result = fn();")); + assert!(source.contains("CURRENT_TASK_META[componentIdx] = previous;")); + assert!(source.contains("return await result;")); + assert!(source.contains("CURRENT_TASK_META[componentIdx] = null;")); + } + #[test] fn manually_async_tasks_may_block() { let source = @@ -2936,6 +3052,8 @@ impl Intrinsic { Self::SetGlobalCurrentThreadMetaFn => "_setGlobalCurrentThreadMeta", Self::WithGlobalCurrentThreadMetaFn => "_withGlobalCurrentThreadMeta", Self::WithGlobalCurrentThreadMetaFnAsync => "_withGlobalCurrentThreadMetaAsync", + Self::MaybeSyncGeneratorFn => "_maybeSyncGenerator", + Self::ConditionalSuspendingHostImportFn => "_conditionalSuspendingHostImport", Self::ClearGlobalCurrentThreadMetaFn => "_clearCurrentTask", Self::SuspendingImportWrapperFn => "_suspendingImport", Self::ConditionalSuspending1I32ToI32Fn => "_conditionalSuspending1I32ToI32", diff --git a/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs b/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs index 2b2bac03d..640b0fb78 100644 --- a/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs +++ b/crates/js-component-bindgen/src/intrinsics/p3/async_task.rs @@ -1817,6 +1817,35 @@ impl AsyncTaskIntrinsic { return true; }} + enterHost() {{ + if (this.#entered) {{ + throw new Error(`task with ID [${{this.#id}}] should not be entered twice`); + }} + + // If cancellation was requested before the task was entered, resolve + // as cancelled without ever running host code + if (this.deliverPendingCancel({{ cancellable: true }})) {{ + this.cancel(); + return false; + }} + + {get_or_create_async_state_fn}(this.#componentIdx); + + this.#entered = true; + // A guest task may be synchronously blocked in this + // host entry. Propagate that boundary so an async + // cancellation driver can yield BLOCKED while the host + // operation is outstanding. + const parentTask = this.#parentSubtask?.getParentTask(); + if ( + parentTask?.taskState() === {task_class}.State.CANCEL_DELIVERED + || (parentTask && !parentTask.hasCallback()) + ) {{ + parentTask.notifyProgress(); + }} + return this.#entered; + }} + async enter(opts) {{ {debug_log_fn}('[{task_class}#enter()] args', {{ taskID: this.#id, @@ -1826,6 +1855,8 @@ impl AsyncTaskIntrinsic { entryFnName: this.#entryFnName, }}); + if (opts?.isHost) {{ return this.enterHost(); }} + if (this.#entered) {{ throw new Error(`task with ID [${{this.#id}}] should not be entered twice`); }} @@ -1839,22 +1870,6 @@ impl AsyncTaskIntrinsic { const cstate = {get_or_create_async_state_fn}(this.#componentIdx); - if (opts?.isHost) {{ - this.#entered = true; - // A guest task may be synchronously blocked in this - // host entry. Propagate that boundary so an async - // cancellation driver can yield BLOCKED while the host - // operation is outstanding. - const parentTask = this.#parentSubtask?.getParentTask(); - if ( - parentTask?.taskState() === {task_class}.State.CANCEL_DELIVERED - || (parentTask && !parentTask.hasCallback()) - ) {{ - parentTask.notifyProgress(); - }} - return this.#entered; - }} - // NOTE: concurrent task lifetimes within one component instance are // permitted by the Component Model: entry is governed by the // backpressure and exclusive-lock checks below (the lock is held per @@ -3839,8 +3854,8 @@ impl AsyncTaskIntrinsic { // not actually async (but are specified as so). // // This is not normal p3 sync behavior but instead anticipating that - // the caller that is doing manual async will be waiting for a promise that - // resolves to the *actual* result. + // the caller that is doing manual async will be given the *actual* result, + // or a promise that resolves to it. // // TODO(breaking): remove once manually specified async is removed // @@ -3855,10 +3870,30 @@ impl AsyncTaskIntrinsic { // lifting/lowering (async ABI did not exist when JSPI integratiton was // initially merged to enable asynchronously returning values from the host) // - const requiresManualAsyncResult = !isAsync && !funcTypeIsAsync && isManualAsync; - let manualAsyncResult; - if (requiresManualAsyncResult) {{ - manualAsyncResult = {promise_with_resolvers_fn}(); + // The host is called now rather than in a microtask, so that a host which + // answers synchronously is answered synchronously: a task that cannot block + // (e.g. a resource destructor) traps on a promise. + if (!isAsync && !funcTypeIsAsync && isManualAsync) {{ + {debug_log_fn}('[{lower_import_backwards_compat_fn}()] calling lowered import', {{ importFn, params }}); + if (createdTask) {{ createdTask.enterSync(); }} + try {{ + const res = importFn(...params); + const isPromise = res !== null && + (typeof res === 'object' || typeof res === 'function') && + typeof res.then === 'function'; + // Without a current task the call cannot block, and its caller + // traps on a promise, so a task created for it ends now. + if (createdTask) {{ createdTask.resolve([isPromise ? undefined : res]); }} + if (!isPromise) {{ return subtask.getResult(); }} + return Promise.resolve(res).then(() => subtask.getResult()); + }} catch (err) {{ + if (createdTask) {{ createdTask.reject(err); }} + throw err; + }} finally {{ + // Resolution delivers the result; exit retires the implicit + // thread and removes the task from the component's registry. + if (createdTask) {{ createdTask.exit(); }} + }} }} queueMicrotask(async () => {{ @@ -3867,26 +3902,14 @@ impl AsyncTaskIntrinsic { if (createdTask) {{ await createdTask.enter(); }} const asyncRes = await importFn(...params); - if (requiresManualAsyncResult) {{ - manualAsyncResult.resolve(subtask.getResult()); - }} - if (createdTask) {{ createdTask.resolve([asyncRes]); }} - - }} catch (err) {{ {debug_log_fn}("[{lower_import_backwards_compat_fn}()] import fn error:", err); - if (requiresManualAsyncResult) {{ - manualAsyncResult.reject(err); - return; - }} task.setErrored(err); task.reject(err); }} }}); - if (requiresManualAsyncResult) {{ return manualAsyncResult.promise; }} - {debug_log_fn}('[{lower_import_backwards_compat_fn}()] async-lowered import return', {{ fnName: importFn.fnName, componentIdx, diff --git a/crates/js-component-bindgen/src/transpile_bindgen.rs b/crates/js-component-bindgen/src/transpile_bindgen.rs index 8ad41b8ce..59c9ed320 100644 --- a/crates/js-component-bindgen/src/transpile_bindgen.rs +++ b/crates/js-component-bindgen/src/transpile_bindgen.rs @@ -20,7 +20,7 @@ use wasmtime_environ::component::{ ExtractCallback, ImportIndex, NameMapNoIntern, Transcode, TypeComponentLocalErrorContextTableIndex, }; -use wasmtime_environ::{EntityIndex, FuncIndex, PrimaryMap}; +use wasmtime_environ::{EntityIndex, FuncIndex, ModuleInternedTypeIndex, PrimaryMap}; use wit_bindgen_core::abi::{self, LiftLower}; use wit_component::StringEncoding; use wit_parser::abi::{AbiVariant, WasmType}; @@ -224,6 +224,12 @@ pub enum BindingsMode { DirectOptimized, } +struct HostImportModule { + bytes: Vec, + local_name: String, + file_name: String, +} + struct JsBindgen<'a> { local_names: LocalNames, @@ -238,6 +244,9 @@ struct JsBindgen<'a> { /// Core module count core_module_cnt: usize, + /// Typed JSPI dispatchers, shared by host imports with the same core signature. + host_import_modules: BTreeMap, + /// Various options for code generation. opts: &'a TranspileOpts, @@ -273,6 +282,9 @@ struct JsFunctionBindgenArgs<'a> { requires_async_porcelain: bool, /// Whether the function in question is a guest async function (i.e. WASI P3) is_async: bool, + /// Whether the function in question is an import generated as a generator function + /// (see `FunctionBindgen::host_import_generator`) + host_import_generator: bool, /// Whether an async export must preserve its future result as an awaitable layer. wrap_async_future_result: bool, /// Whether the function in question is being generated for an import @@ -327,6 +339,7 @@ pub fn transpile_bindgen( src: Source::default(), esm_bindgen: EsmBindgen::default(), core_module_cnt: 0, + host_import_modules: BTreeMap::new(), opts: &opts, all_intrinsics: BTreeSet::new(), all_core_exported_funcs: Vec::new(), @@ -494,6 +507,50 @@ struct InstantiationHelperNames { } impl JsBindgen<'_> { + /// Return the JS binding for a host import's typed Wasm dispatcher. + /// Generate its bytes and emit module loading/compilation code once per interned + /// core type; the dispatcher bypasses JSPI when the caller cannot block. + fn generate_host_import_module( + &mut self, + core_type: ModuleInternedTypeIndex, + types: &ComponentTypes, + ) -> String { + if let Some(module) = self.host_import_modules.get(&core_type) { + return module.local_name.clone(); + } + + let bytes = core::conditional_host_import(types[core_type].unwrap_func()); + let local_name = self.local_names.create_once("hostImportModule").to_string(); + let file_name = format!( + "{}.conditional-host-import-{}.wasm", + self.opts.name, + self.host_import_modules.len(), + ); + let bytes_js = bytes + .iter() + .map(|b| format!("0x{b:02x}")) + .collect::>() + .join(", "); + let compile = format!("new WebAssembly.Module(new Uint8Array([{bytes_js}]))"); + if self.opts.loads_conditional_suspending_wasm() { + uwriteln!( + self.src.js, + "const {local_name} = (yield getCoreModule('{file_name}')) ?? {compile};" + ); + } else { + uwriteln!(self.src.js, "const {local_name} = {compile};"); + } + self.host_import_modules.insert( + core_type, + HostImportModule { + bytes, + local_name: local_name.clone(), + file_name, + }, + ); + local_name + } + /// Allocate deconflicted local names for the instantiation helper fn instantiation_helper_names(&mut self) -> InstantiationHelperNames { InstantiationHelperNames { @@ -596,6 +653,9 @@ impl JsBindgen<'_> { // Emit conditional-suspending trampolines as files so hosts can precompile them let mut conditional_suspending_compilations = source::Source::default(); if self.opts.loads_conditional_suspending_wasm() { + for module in self.host_import_modules.values() { + files.push(&module.file_name, &module.bytes); + } let wasm = Intrinsic::ConditionalSuspendingWasm.name(); for intrinsic in self.all_intrinsics.iter() { let Some((suffix, bytes)) = intrinsic.conditional_suspending_wasm() else { @@ -3102,13 +3162,25 @@ impl<'a> Instantiator<'a, '_> { self.src.js, "let trampoline{i} = new WebAssembly.Suspending({suspending_wrap_fn}({component_idx}, {call}, false, false, false));" ); - } else { + } else if self + .async_analysis + .suspending_trampolines + .contains(&TrampolineIndex::from_u32(i)) + { + let conditional_host_import = self + .bindgen + .intrinsic(Intrinsic::ConditionalSuspendingHostImportFn); + let module = self + .bindgen + .generate_host_import_module(canon_opts.core_type, self.types); // TODO(breaking): once manually specifying async imports is removed, // we can avoid the second check below. uwriteln!( self.src.js, - "let trampoline{i} = _trampoline{i}.manuallyAsync ? new WebAssembly.Suspending({suspending_wrap_fn}({component_idx}, {call}, false, false, false)) : {call};" + "let trampoline{i} = _trampoline{i}.manuallyAsync ? {conditional_host_import}({module}, {component_idx}, {suspending_wrap_fn}({component_idx}, {call}, false, false, false, true)) : {call};" ); + } else { + uwriteln!(self.src.js, "let trampoline{i} = {call};"); } } @@ -4350,6 +4422,7 @@ impl<'a> Instantiator<'a, '_> { import_name, &self.async_imports, ); + let host_import_generator = requires_async_porcelain && !is_async; if is_async || self.types[func_ty].async_ || requires_async_porcelain { self.async_analysis .suspending_trampolines @@ -4499,8 +4572,18 @@ impl<'a> Instantiator<'a, '_> { let trampoline_idx = trampoline.as_u32(); match self.bindgen.opts.import_bindings { None | Some(BindingsMode::Js) | Some(BindingsMode::Hybrid) => { + // A JSPI import of a sync function returns synchronously when the host does, + // as a task that cannot block (e.g. a resource destructor) may be calling it. + // // TODO(breaking): remove as we do not not need to manually specify async imports anymore in P3 w/ native coloring - if is_async | requires_async_porcelain { + if host_import_generator { + let maybe_sync_generator_fn = + self.bindgen.intrinsic(Intrinsic::MaybeSyncGeneratorFn); + uwrite!( + self.src.js, + "\nconst _trampoline{trampoline_idx} = {maybe_sync_generator_fn}(function*" + ); + } else if is_async | requires_async_porcelain { // NOTE: for async imports that will go through Trampoline::LowerImport, // we prefix the raw import with '_' as it will later be used in the // definition of trampoline{i} which will actually be fed into @@ -4534,11 +4617,16 @@ impl<'a> Instantiator<'a, '_> { abi, requires_async_porcelain, is_async, + host_import_generator, wrap_async_future_result: false, for_import: true, result_type_idx: None, }); - uwriteln!(self.src.js, ""); + uwriteln!( + self.src.js, + "{}", + if host_import_generator { ");" } else { "" } + ); uwriteln!( self.src.js, @@ -5329,6 +5417,7 @@ impl<'a> Instantiator<'a, '_> { abi, requires_async_porcelain, is_async, + host_import_generator, wrap_async_future_result, for_import, result_type_idx, @@ -5536,6 +5625,7 @@ impl<'a> Instantiator<'a, '_> { resolve: self.resolve, requires_async_porcelain, is_async, + host_import_generator, canonical_abi_async: opts.async_, return_result_type_idx: result_type_idx, func_type_async: matches!( @@ -6352,6 +6442,7 @@ impl<'a> Instantiator<'a, '_> { abi: AbiVariant::GuestExport, requires_async_porcelain, is_async, + host_import_generator: false, wrap_async_future_result, for_import: false, result_type_idx: Some(self.types[*func_ty_idx].results.as_u32()), diff --git a/crates/test-components/src/bin/resource_drop_host_import.rs b/crates/test-components/src/bin/resource_drop_host_import.rs new file mode 100644 index 000000000..76efd7ea9 --- /dev/null +++ b/crates/test-components/src/bin/resource_drop_host_import.rs @@ -0,0 +1,65 @@ +mod bindings { + use super::Component; + wit_bindgen::generate!({ + world: "resource-drop-host-import", + }); + export!(Component); +} + +use bindings::exports::jco::test_components::dropped_resource; +use bindings::jco::test_components::tick; +use std::sync::OnceLock; + +struct Component; + +static INITIALIZED: OnceLock = OnceLock::new(); + +// Exercise every numeric core parameter type and an i64 result. +fn calculate() -> u64 { + bindings::calculate(1, 2, 3.5, 4.5) +} + +/// Initialization cannot suspend, even when the host import is marked async. +#[unsafe(no_mangle)] +pub extern "C" fn _initialize() { + INITIALIZED.get_or_init(calculate); +} + +impl dropped_resource::Guest for Component { + type Thing = Thing; + + fn initialized() -> u64 { + *INITIALIZED.get().expect("fixture was not initialized") + } + + fn call_sync() -> u64 { + calculate() + } + + // The JS test marks this sync-typed export async to permit JSPI suspension. + // A distinct core signature prevents merging with call_sync in optimized builds. + fn call_async(a: u32) -> u64 { + bindings::calculate(a, 2, 3.5, 4.5) + } + + fn drop_in_guest() { + drop(Thing); + } +} + +struct Thing; + +impl dropped_resource::GuestThing for Thing { + fn new() -> Self { + Thing + } +} + +impl Drop for Thing { + fn drop(&mut self) { + tick::tick(); + } +} + +// Stub only to ensure this works as a binary +fn main() {} diff --git a/crates/test-components/wit/all.wit b/crates/test-components/wit/all.wit index d01aea574..2300b8d76 100644 --- a/crates/test-components/wit/all.wit +++ b/crates/test-components/wit/all.wit @@ -1073,3 +1073,23 @@ world host-value-disposal { import host-value-disposal-host; export host-value-disposal-guest; } + +/// A guest resource whose destructor calls back into the host +interface dropped-resource { + resource thing { + constructor(); + } + initialized: func() -> u64; + call-sync: func() -> u64; + call-async: func(a: u32) -> u64; + drop-in-guest: func(); +} + +/// Host imports called during initialization, ordinary exports, and resource drops. +/// Initialization and resource destructors cannot block, even when an import is +/// marked async by the transpiler. +world resource-drop-host-import { + import tick; + import calculate: func(a: u32, b: u64, c: f32, d: f64) -> u64; + export dropped-resource; +} diff --git a/packages/jco-transpile/test/initialization.ts b/packages/jco-transpile/test/initialization.ts index fbea34a19..fc5ed68ad 100644 --- a/packages/jco-transpile/test/initialization.ts +++ b/packages/jco-transpile/test/initialization.ts @@ -78,4 +78,40 @@ suite('non-wizered raw initialize export', () => { await cleanup(); }); + + // Initialization runs as a synchronous task, which cannot block: a JSPI import + // it calls must return synchronously when the host does + test('legacy async, initialization calls an async import', async () => { + if (typeof WebAssembly?.Suspending !== 'function') { + return; + } + + const name = 'non-wizered-init'; + const { instance, cleanup } = await setupAsyncTest({ + component: { + name, + path: join(LOCAL_TEST_COMPONENTS_DIR, `${name}.wasm`), + imports: { + ...new WASIShim({ sandbox: { env: { TEST: 'YES' } } }).getImportObject(), + 'jco:test-components/get-string': { + getString() { + return 'FROM IMPORT'; + }, + }, + }, + }, + jco: { + transpile: { + extraArgs: { + asyncMode: 'jspi', + asyncImports: ['wasi:cli/environment@0.2.6#get-environment'], + }, + }, + }, + }); + + assert.strictEqual(instance['jco:test-components/local-run-string'].run(), 'YES'); + + await cleanup(); + }); }); diff --git a/packages/jco-transpile/test/jspi.ts b/packages/jco-transpile/test/jspi.ts index d0dad609f..d1663a0a1 100644 --- a/packages/jco-transpile/test/jspi.ts +++ b/packages/jco-transpile/test/jspi.ts @@ -1,16 +1,143 @@ -import { resolve } from 'node:path'; -import { mkdir, rm, symlink } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { suite, test, assert, expect } from 'vitest'; +import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation'; -import { transpile } from '../src/index.js'; +import { transpile, writeFiles } from '../src/index.js'; import { getTmpDir, setupAsyncTest } from './helpers.js'; -import { AsyncFunction } from './common.js'; +import { AsyncFunction, LOCAL_TEST_COMPONENTS_DIR } from './common.js'; suite('Host Import Async (JSPI)', () => { + test('synchronous host imports work in direct ESM output', async () => { + if (typeof WebAssembly?.Suspending !== 'function') { + return; + } + + const outDir = await getTmpDir(); + try { + const { files } = await transpile(join(LOCAL_TEST_COMPONENTS_DIR, 'resource-drop-host-import.wasm'), { + name: 'conditional-host-import', + asyncMode: 'jspi', + asyncImports: ['calculate', 'jco:test-components/tick#tick'], + asyncExports: ['jco:test-components/dropped-resource#call-async'], + map: { calculate: './host.js#calculate', 'jco:test-components/tick': './host.js#tick' }, + }); + // Observe task lifetime through the unminified runtime: initialization + // must retire its auto-created task as well as its implicit thread. + const source = Buffer.from(files['conditional-host-import.js']).toString(); + files['conditional-host-import.js'] = new TextEncoder().encode(`${source} + export const liveTaskCount = () => [...ASYNC_TASKS_BY_COMPONENT_IDX.values()] + .reduce((count, tasks) => count + tasks.length, 0); + `); + await writeFiles(files, { baseDir: outDir }); + await writeFile(join(outDir, 'package.json'), JSON.stringify({ type: 'module' })); + const modulesDir = join(outDir, 'node_modules', '@bytecodealliance'); + await mkdir(modulesDir, { recursive: true }); + await symlink( + fileURLToPath( + new URL('../../../', import.meta.resolve('@bytecodealliance/preview2-shim/instantiation')), + ), + join(modulesDir, 'preview2-shim'), + 'dir', + ); + await writeFile( + join(outDir, 'host.js'), + 'export const calculate = () => 42n; export const tick = { tick: () => {} };', + ); + const instance = await import(pathToFileURL(join(outDir, 'conditional-host-import.js')).href); + await instance.$init; + assert.strictEqual(instance.liveTaskCount(), 0, 'initialization tasks must exit'); + const api = instance['jco:test-components/dropped-resource']; + assert.strictEqual(api.initialized(), 42n); + assert.strictEqual(api.callSync(), 42n); + assert.strictEqual(await api.callAsync(1), 42n); + api.dropInGuest(); + new api.Thing()[Symbol.dispose](); + assert.strictEqual(instance.liveTaskCount(), 0, 'completed calls must retire their tasks'); + } finally { + await rm(outDir, { recursive: true, force: true }); + } + }); + + test.concurrent.each([false, true])( + 'host imports bypass JSPI when the caller cannot block (minify=%s)', + async (minify) => { + if (typeof WebAssembly?.Suspending !== 'function') { + return; + } + + const { esModule, cleanup } = await setupAsyncTest({ + asyncMode: 'jspi', + component: { + path: join(LOCAL_TEST_COMPONENTS_DIR, 'resource-drop-host-import.wasm'), + skipInstantiation: true, + }, + jco: { + transpile: { + extraArgs: { + minify, + asyncImports: ['calculate', 'jco:test-components/tick#tick'], + asyncExports: ['jco:test-components/dropped-resource#call-async'], + }, + }, + }, + }); + + try { + let ticks = 0; + let calculateAsync = false; + let tickAsync = false; + const imports = { + ...new WASIShim().getImportObject(), + calculate: { + default: (...args) => { + assert.deepEqual(args, [1, 2n, 3.5, 4.5]); + return calculateAsync ? Promise.resolve(42n) : 42n; + }, + }, + 'jco:test-components/tick': { + tick: () => { + ticks++; + return tickAsync ? Promise.resolve() : undefined; + }, + }, + }; + + const instance = await esModule.instantiate(undefined, imports); + const api = instance['jco:test-components/dropped-resource']; + assert.strictEqual(api.initialized(), 42n); + assert.strictEqual(api.callSync(), 42n); + assert.strictEqual(await api.callAsync(1), 42n); + calculateAsync = true; + assert.strictEqual(await api.callAsync(1), 42n); + + api.dropInGuest(); + new api.Thing()[Symbol.dispose](); + assert.strictEqual(ticks, 2); + tickAsync = true; + assert.throws( + () => new api.Thing()[Symbol.dispose](), + /cannot block a synchronous task before returning/, + ); + assert.strictEqual(ticks, 3); + + // A promise returned during initialization must still trap, and must + // not be left as an unhandled rejection. + for (const calculate of [() => Promise.resolve(42n), () => Promise.reject(new Error('host failure'))]) { + await expect( + esModule.instantiate(undefined, { ...imports, calculate: { default: calculate } }), + ).rejects.toThrow('cannot block a synchronous task before returning'); + } + } finally { + await cleanup(); + } + }, + ); + test('Transpile async', async () => { const tmpDir = await getTmpDir(); const outDir = resolve(tmpDir, 'out-component-dir'); @@ -254,50 +381,114 @@ suite('Host Import Async (JSPI)', () => { // A failing JSPI-suspended import must trap the guest and reject the // export, rather than leave the guest suspended forever. - test.concurrent('Transpile async, import rejects (NodeJS, JSPI)', async () => { + test.concurrent.each(['throw', 'reject', 'throwing then getter'])( + 'Transpile async, import fails with %s (NodeJS, JSPI)', + async (failure) => { + if (typeof WebAssembly?.Suspending !== 'function') { + return; + } + const tmpDir = await getTmpDir(); + const modulesDir = resolve(tmpDir, 'node_modules', '@bytecodealliance'); + await mkdir(modulesDir, { recursive: true }); + await symlink( + fileURLToPath(new URL('../packages/preview2-shim', import.meta.url)), + resolve(modulesDir, 'preview2-shim'), + 'dir', + ); + + const inducedError = new Error('induced host rejection'); + const { instance, cleanup } = await setupAsyncTest({ + asyncMode: 'jspi', + component: { + name: 'async_call', + path: resolve('test/fixtures/components/runtime/async_call.component.wasm'), + imports: { + 'something:test/test-interface': { + callAsync: () => { + if (failure === 'reject') { + return Promise.reject(inducedError); + } + if (failure === 'throwing then getter') { + return { + get then() { + throw inducedError; + }, + }; + } + throw inducedError; + }, + callSync: () => 'called sync', + }, + }, + }, + jco: { + transpile: { + extraArgs: { + asyncImports: ['something:test/test-interface#call-async'], + asyncExports: ['run-async'], + }, + }, + }, + }); + + try { + const exportCall = instance.runAsync(); + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('export call timed out')), 5_000), + ); + await expect(Promise.race([exportCall, timeout])).rejects.toBe(inducedError); + } finally { + await cleanup(); + } + }, + ); + + // A resource destructor runs as a synchronous task, which cannot block: a JSPI + // import called from it must return synchronously when the host does, and trap + // only when the host returns a promise. + test.concurrent('Transpile async, import called from resource destructor (NodeJS, JSPI)', async () => { if (typeof WebAssembly?.Suspending !== 'function') { return; } - const tmpDir = await getTmpDir(); - const modulesDir = resolve(tmpDir, 'node_modules', '@bytecodealliance'); - await mkdir(modulesDir, { recursive: true }); - await symlink( - fileURLToPath(new URL('../packages/preview2-shim', import.meta.url)), - resolve(modulesDir, 'preview2-shim'), - 'dir', - ); - const inducedError = new Error('induced host rejection'); + let ticks = 0; + let hostIsAsync = false; const { instance, cleanup } = await setupAsyncTest({ asyncMode: 'jspi', component: { - name: 'async_call', - path: resolve('test/fixtures/components/runtime/async_call.component.wasm'), + path: join(LOCAL_TEST_COMPONENTS_DIR, 'resource-drop-host-import.wasm'), imports: { - 'something:test/test-interface': { - callAsync: async () => { - throw inducedError; + ...new WASIShim().getImportObject(), + calculate: { default: () => 42n }, + 'jco:test-components/tick': { + tick: () => { + ticks += 1; + if (hostIsAsync) { + return Promise.resolve(); + } }, - callSync: () => 'called sync', }, }, }, jco: { transpile: { extraArgs: { - asyncImports: ['something:test/test-interface#call-async'], - asyncExports: ['run-async'], + asyncImports: ['jco:test-components/tick#tick'], }, }, }, }); try { - const exportCall = instance.runAsync(); - const timeout = new Promise((_, reject) => - setTimeout(() => reject(new Error('export call timed out')), 5_000), - ); - await expect(Promise.race([exportCall, timeout])).rejects.toBe(inducedError); + const { Thing } = instance['jco:test-components/dropped-resource']; + + new Thing()[Symbol.dispose](); + new Thing()[Symbol.dispose](); + assert.strictEqual(ticks, 2); + + hostIsAsync = true; + assert.throws(() => new Thing()[Symbol.dispose](), 'cannot block a synchronous task before returning'); + assert.strictEqual(ticks, 3); } finally { await cleanup(); } diff --git a/packages/jco-transpile/test/runtime-compilation.ts b/packages/jco-transpile/test/runtime-compilation.ts index f8fda620d..e81c42bfc 100644 --- a/packages/jco-transpile/test/runtime-compilation.ts +++ b/packages/jco-transpile/test/runtime-compilation.ts @@ -3,8 +3,10 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { assert, suite, test, vi } from 'vitest'; +import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation'; import { nodeExec, setupAsyncTest } from './helpers.js'; +import { LOCAL_TEST_COMPONENTS_DIR } from './common.js'; const componentPath = fileURLToPath( new URL('./fixtures/components/runtime/waitable-set-wait-unused.component.wat', import.meta.url), @@ -30,6 +32,65 @@ function withoutRuntimeCompilation(): typeof WebAssembly { suite.skipIf(typeof WebAssembly.Suspending !== 'function')('runtime compilation', () => { for (const instantiation of ['async', 'sync'] as const) { + test.concurrent(`--instantiation ${instantiation} precompiles typed host-import dispatchers`, async () => { + const { esModule, esModuleOutputDir, cleanup } = await setupAsyncTest({ + asyncMode: 'jspi', + component: { + name: `host-import-compilation-${instantiation}`, + path: join(LOCAL_TEST_COMPONENTS_DIR, 'resource-drop-host-import.wasm'), + skipInstantiation: true, + }, + jco: { + transpile: { + extraArgs: { + instantiation, + asyncImports: ['calculate', 'jco:test-components/tick#tick'], + asyncExports: ['jco:test-components/dropped-resource#call-async'], + }, + }, + }, + }); + + try { + const modules = new Map(); + for (const file of await readdir(esModuleOutputDir)) { + if (file.endsWith('.wasm')) { + modules.set(file, new WebAssembly.Module(await readFile(join(esModuleOutputDir, file)))); + } + } + assert.lengthOf( + [...modules.keys()].filter((name) => name.includes('.conditional-host-import-')), + 2, + ); + let hostAsync = false; + const requested: string[] = []; + const instance = await esModule.instantiate( + (name: string) => { + requested.push(name); + const module = modules.get(name); + assert.ok(module, `getCoreModule('${name}') names an emitted file`); + return module; + }, + { + ...new WASIShim().getImportObject(), + calculate: { default: () => (hostAsync ? Promise.resolve(42n) : 42n) }, + 'jco:test-components/tick': { tick: () => {} }, + }, + undefined, + { shim: { globals: { WebAssembly: withoutRuntimeCompilation() } } }, + ); + const api = instance['jco:test-components/dropped-resource']; + assert.strictEqual(api.initialized(), 42n); + assert.strictEqual(api.callSync(), 42n); + hostAsync = true; + assert.strictEqual(await api.callAsync(1), 42n); + new api.Thing()[Symbol.dispose](); + assert.deepStrictEqual(requested.toSorted(), [...modules.keys()].toSorted()); + } finally { + await cleanup(); + } + }); + test.concurrent(`--instantiation ${instantiation} compiles no Wasm at runtime`, async () => { const { esModule, esModuleOutputDir, cleanup } = await setupAsyncTest({ component: {