diff --git a/crates/js-component-bindgen/src/function_bindgen.rs b/crates/js-component-bindgen/src/function_bindgen.rs index d02844262..d541a28a7 100644 --- a/crates/js-component-bindgen/src/function_bindgen.rs +++ b/crates/js-component-bindgen/src/function_bindgen.rs @@ -3569,6 +3569,10 @@ impl Bindgen for FunctionBindgen<'_> { // then we should start it, given that the task we have recently created (however we got to // the async return) is going to continue to be polled soon (via the driver loop). // + // Host-import tasks may be parented by a canonical ABI subtask. Their + // resolution is propagated to that subtask, and their own completion + // promise is intentionally left unsettled, so return the lifted host + // value directly after resolving and exiting the task. uwriteln!( self.src, r#" @@ -3580,7 +3584,7 @@ impl Bindgen for FunctionBindgen<'_> { }}) task.resolve([ret]); task.exit(); - return {return_awaited_completion_promise}; + return ret; }} const componentState = {get_or_create_async_state_fn}({component_idx_expr}); @@ -3617,14 +3621,6 @@ impl Bindgen for FunctionBindgen<'_> { return {return_task_res}; "#, - // If we are returning the awaited task completion promise directly - // that contains a future, we must wrap the result so we can deal - // with nesting if present - return_awaited_completion_promise = if self.wrap_async_future_result { - "{ value: await task.completionPromise() }" - } else { - "await task.completionPromise()" - }, // If we are returning the task result post-resolution, and it contains a future, // we must wrap the result so we can deal with nesting if present return_task_res = if self.wrap_async_future_result { diff --git a/crates/test-components/src/bin/async_host_import_hang.rs b/crates/test-components/src/bin/async_host_import_hang.rs new file mode 100644 index 000000000..ba3e9189b --- /dev/null +++ b/crates/test-components/src/bin/async_host_import_hang.rs @@ -0,0 +1,18 @@ +mod bindings { + use super::Component; + wit_bindgen::generate!({ + world: "async-host-import-hang", + }); + export!(Component); +} + +struct Component; + +impl bindings::Guest for Component { + async fn run() -> u32 { + bindings::load().await + } +} + +// 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 9f75c3e85..89b64aabd 100644 --- a/crates/test-components/wit/all.wit +++ b/crates/test-components/wit/all.wit @@ -308,6 +308,11 @@ world async-simple-import { export get-string: async func() -> string; } +world async-host-import-hang { + import load: async func() -> u32; + export run: async func() -> u32; +} + world async-error-context { export local-run-async; } diff --git a/packages/jco-transpile/test/p3/async.ts b/packages/jco-transpile/test/p3/async.ts index aab054efe..8a8a661a8 100644 --- a/packages/jco-transpile/test/p3/async.ts +++ b/packages/jco-transpile/test/p3/async.ts @@ -4,6 +4,7 @@ import { suite, test, assert, expect } from 'vitest'; import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation'; +import { transpile } from '../../src/index.js'; import { setupAsyncTest } from '../helpers.js'; import { AsyncFunction, LOCAL_TEST_COMPONENTS_DIR, createReadableStreamFromValues, timeoutMs } from '../common.js'; @@ -190,6 +191,55 @@ suite('Async (WASI P3)', () => { await cleanup(); }); + // https://github.com/bytecodealliance/jco/issues/1898 (bug 4) + test.concurrent('async host import tasks do not hang after resolving their parent subtask', async () => { + const { instance, cleanup } = await setupAsyncTest({ + component: { + path: join(LOCAL_TEST_COMPONENTS_DIR, 'async-host-import-hang.wasm'), + imports: { + ...new WASIShim().getImportObject(), + load: { default: async () => 42 }, + }, + }, + jco: { + transpile: { + extraArgs: { + asyncMode: 'jspi', + asyncImports: ['load'], + asyncExports: ['run'], + }, + }, + }, + }); + + try { + assert.strictEqual( + await Promise.race([instance.run(), timeoutMs(5_000, 'async host import timed out')]), + 42, + ); + } finally { + await cleanup(); + } + }); + + test.concurrent('async host import trampolines return lifted values directly', async () => { + const { files } = await transpile(join(LOCAL_TEST_COMPONENTS_DIR, 'async-simple-import.wasm'), { + name: 'async-simple-import', + minify: false, + asyncMode: 'jspi', + asyncImports: ['load-string', 'load-u32'], + asyncExports: ['get-string', 'get-u32'], + }); + const source = new TextDecoder().decode(files['async-simple-import.js']); + const directHostReturns = source.match(/task\.resolve\(\[ret\]\);\s*task\.exit\(\);\s*return ret;/g); + + assert.isAtLeast(directHostReturns?.length ?? 0, 2); + assert.notMatch( + source, + /task\.resolve\(\[ret\]\);\s*task\.exit\(\);\s*return await task\.completionPromise\(\);/, + ); + }); + test.concurrent('async return of imported owned resource', async () => { class ExampleResource { constructor(id) {