◐ Shell
clean mode source ↗

BridgeJS: Support non-ConvertibleToJSValue async exported return types by krodak · Pull Request #758 · swiftwasm/JavaScriptKit

Overview

Addresses #753. Async exported functions previously required a return type conforming to ConvertibleToJSValue. The generated thunk wrapped the body in JSPromise.async { ... } and lowered the result with .jsValue, so types that have no .jsValue representation (@JS structs, raw-value and case enums, and their Optional/Array/Dictionary compositions) could not be returned from an async @JS func:

@JS struct Point { var x: Int; var y: Int }
@JS func getPoint() async -> Point { ... }   // did not compile: Point has no `.jsValue`

This PR settles every async exported return through a single new mechanism that lowers the value via the regular imported-parameter ABI, so all bridged types and their nested compositions can be returned.

Approach

1. Create the Promise eagerly, settle it from a Task.
An exported async function must hand a value back to JavaScript synchronously (the Wasm export returns an i32 object id), while the work itself is asynchronous. The thunk therefore creates a JS Promise immediately via a new _bjs_makePromise intrinsic, returns its retained object id, and resolves or rejects it later from a Task:

// generated thunk (struct return)
public func _bjs_getPoint() -> Int32 {
    return _bjs_makePromise(resolve: Promise_resolve_5PointV, reject: Promise_reject) {
        return await getPoint()
    }
}

The resolve/reject settlers are stashed on the Promise under a Symbol (rather than plain string fields) so they cannot clash with anything else on the object.

2. Settle through generated @JSFunction thunks rather than ad-hoc lowering.
_bjs_makePromise lives in the JavaScriptKit library and cannot name the per-module, per-type lowering that the generator produces. So the generator emits a Promise_resolve_<mangled> thunk per distinct return type (deduplicated by mangled name) and a single shared Promise_reject, and injects them into _bjs_makePromise. Each resolve thunk lowers its value through the standard imported-parameter ABI:

@JSFunction func Promise_resolve_5PointV(_ promise: JSObject, _ value: Point) throws(JSException)
@JSFunction func Promise_reject(_ promise: JSObject, _ value: JSValue) throws(JSException)

The benefit of reusing the imported-parameter ABI is that every existing per-type lowering is inherited for free: struct fields pushed on the stack, enum tags, optional isSome discriminators, arrays, and dictionaries all round-trip with no new marshaling code. reject uses the full JSValue ABI so any thrown error is forwarded faithfully.

3. One path for all async returns.
Rather than branch between JSPromise.async + .jsValue for ConvertibleToJSValue returns and _bjs_makePromise for the rest, every async return now settles through _bjs_makePromise. This removes the dual codegen path and the type predicate that used to select between them. Void is the one special case: a Void value can't cross the bridge as a parameter, so its resolve thunk takes only the promise (via a small _bjs_makePromise overload) and settles with undefined.

4. Drain stack parameters synchronously before the deferred Task.
Complex parameters travel via a single shared, mutable bridge stack. Because the async body runs later on a Task, any stack-using parameter must be lifted into a local in the thunk before the Task is scheduled, otherwise an interleaved bridge call would corrupt the shared stack. The thunk hoists these lifts in reverse (LIFO) order to preserve the stack discipline:

public func _bjs_combine() -> Int32 {
    let _tmp_b = AsyncPoint.bridgeJSLiftParameter()
    let _tmp_a = AsyncPoint.bridgeJSLiftParameter()
    return _bjs_makePromise(resolve: ..., reject: ...) {
        return await combine(_: _tmp_a, _: _tmp_b)
    }
}

5. Annotate the throwing async closure explicitly.
A throwing async body needs an explicit () async throws(JSException) -> T in closure type; without it Swift infers throws(any Error) instead of throws(JSException). Non-throwing bodies infer correctly and are left unannotated.

6. Diagnose, rather than miscompile, the unsupported cases.
The few types that cannot be lowered through the imported-parameter ABI (associated-value enums, protocols, namespace enums, including those nested in Optional/Array/Dictionary) are rejected with a clear BridgeJS diagnostic instead of producing uncompilable Swift.

Tests

End-to-end runtime round-trips plus codegen snapshots cover the matrix: @JS struct, case enum, and raw-value enum, each as a bare value, Optional, Array, and Dictionary; an Int64-backed enum (base and optional, exercising the i64 resolve-helper signature); Void and ConvertibleToJSValue returns; the throwing variant, the reject path (asserted via assert.rejects), multi-parameter stack hoisting, a rich nested struct, an async class method, and concurrent calls via Promise.all (the regression guard for shared-stack corruption). Unsupported types are covered by the diagnostic path.