diff --git a/packages/quickjs-emscripten-core/src/runtime-asyncify.ts b/packages/quickjs-emscripten-core/src/runtime-asyncify.ts index 8d99bd75..fa315dca 100644 --- a/packages/quickjs-emscripten-core/src/runtime-asyncify.ts +++ b/packages/quickjs-emscripten-core/src/runtime-asyncify.ts @@ -2,6 +2,7 @@ import type { QuickJSAsyncEmscriptenModule, QuickJSAsyncFFI, JSContextPointer, + JSContextPointerPointer, JSRuntimePointer, } from "@jitl/quickjs-ffi-types" import { QuickJSAsyncContext } from "./context-asyncify" @@ -21,6 +22,7 @@ import type { } from "./types" import { intrinsicsToFlags } from "./types" import { Lifetime } from "./lifetime" +import type { ExecutePendingJobsResult } from "./runtime" export class QuickJSAsyncRuntime extends QuickJSRuntime { declare public context: QuickJSAsyncContext | undefined @@ -92,4 +94,27 @@ export class QuickJSAsyncRuntime extends QuickJSRuntime { public override setMaxStackSize(stackSize: number): void { return super.setMaxStackSize(stackSize) } + + /** + * Asyncified version of {@link QuickJSRuntime.executePendingJobs}. + * + * Pending jobs may call an asyncified host function, so the synchronous + * version cannot safely drive a runtime created from an Asyncify build. + */ + async executePendingJobsAsync( + maxJobsToExecute: number | void = -1, + ): Promise { + const ctxPtrOut = this.memory.newMutablePointerArray(1) + try { + const valuePtr = await this.ffi.QTS_ExecutePendingJob_MaybeAsync( + this.rt.value, + maxJobsToExecute ?? -1, + ctxPtrOut.value.ptr, + ) + const ctxPtr = ctxPtrOut.value.typedArray[0] as JSContextPointer + return this.resolveExecutePendingJobsResult(valuePtr, ctxPtr) + } finally { + ctxPtrOut.dispose() + } + } } diff --git a/packages/quickjs-emscripten-core/src/runtime.ts b/packages/quickjs-emscripten-core/src/runtime.ts index e404f064..da1acdfe 100644 --- a/packages/quickjs-emscripten-core/src/runtime.ts +++ b/packages/quickjs-emscripten-core/src/runtime.ts @@ -3,6 +3,7 @@ import type { JSContextPointer, JSContextPointerPointer, JSRuntimePointer, + JSValuePointer, EitherFFI, EitherModule, } from "@jitl/quickjs-ffi-types" @@ -253,6 +254,14 @@ export class QuickJSRuntime extends UsingDisposable implements Disposable { const ctxPtr = ctxPtrOut.value.typedArray[0] as JSContextPointer ctxPtrOut.dispose() + return this.resolveExecutePendingJobsResult(valuePtr, ctxPtr) + } + + /** @private */ + protected resolveExecutePendingJobsResult( + valuePtr: JSValuePointer, + ctxPtr: JSContextPointer, + ): ExecutePendingJobsResult { if (ctxPtr === 0) { // No jobs executed. this.ffi.QTS_FreeValuePointerRuntime(this.rt.value, valuePtr) diff --git a/packages/quickjs-emscripten/src/quickjs.test.ts b/packages/quickjs-emscripten/src/quickjs.test.ts index f603c256..1a7f85de 100644 --- a/packages/quickjs-emscripten/src/quickjs.test.ts +++ b/packages/quickjs-emscripten/src/quickjs.test.ts @@ -1224,6 +1224,34 @@ function asyncContextTests( }) describe("asyncify functions", () => { + it("supports sequential awaited host calls in one evaluation", async () => { + let asyncFunctionCalls = 0 + vm.newAsyncifiedFunction("get", async (pathHandle) => { + asyncFunctionCalls++ + const path = vm.getString(pathHandle) + return vm.newString(JSON.stringify({ path })) + }).consume((fn) => vm.setProp(vm.global, "get", fn)) + + const result = await vm.evalCodeAsync(` + (async () => { + const a = JSON.parse(await get("/a")) + const b = JSON.parse(await get("/b")) + const c = JSON.parse(await get("/c")) + return [a, b, c] + })() + `) + + const promise = vm.unwrapResult(result).consume((handle) => vm.resolvePromise(handle)) + vm.unwrapResult(await vm.runtime.executePendingJobsAsync()) + const resolved = vm.unwrapResult(await promise) + assert.deepEqual(vm.dump(resolved), [{ path: "/a" }, { path: "/b" }, { path: "/c" }]) + resolved.dispose() + assert.equal(asyncFunctionCalls, 3) + + const reuseResult = await vm.evalCodeAsync("1 + 1") + assert.equal(vm.unwrapResult(reuseResult).consume(vm.dump), 2) + }) + it("sees Promise as synchronous", async () => { let asyncFunctionCalls = 0 const asyncFn = async () => { diff --git a/scripts/generate.ts b/scripts/generate.ts index aac0ddaf..b2ccc4b3 100755 --- a/scripts/generate.ts +++ b/scripts/generate.ts @@ -223,21 +223,15 @@ function renderFunction(args: { const ffiParams = JSON.stringify(params.map((param) => param.type.ffi)) const cwrapArgs = [JSON.stringify(functionName), JSON.stringify(returnType.ffi), ffiParams] - if (context.DEBUG && async) { + if (async) { // https://emscripten.org/docs/porting/asyncify.html#usage-with-ccall // Passing {async:true} to cwrap/ccall will wrap all return values in // Promise.resolve(...), even if the c code doesn't suspend and returns a // primitive value. // - // When compiled with -s ASSERTIONS=1, Emscripten will throw if the - // function suspends and {async: true} wasn't passed. - // - // However, we'd like to avoid Promise/async overhead if the call can - // return a primitive value directly. So, we compile in {async:true} - // only in DEBUG mode, where assertions are enabled. - // - // Then we rely on our type system to ensure our code supports both - // primitive and promise-wrapped return values in production mode. + // Asyncify requires this option whenever the function may suspend. Debug + // builds assert when it is missing; release builds otherwise continue in + // a corrupted state. cwrapArgs.push("{ async: true }") }