Skip to content

BridgeJS: Support non-ConvertibleToJSValue async exported return types#758

Open
krodak wants to merge 1 commit into
swiftwasm:mainfrom
PassiveLogic:kr/async-stack-types
Open

BridgeJS: Support non-ConvertibleToJSValue async exported return types#758
krodak wants to merge 1 commit into
swiftwasm:mainfrom
PassiveLogic:kr/async-stack-types

Conversation

@krodak

@krodak krodak commented Jun 8, 2026

Copy link
Copy Markdown
Member

Overview

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 adds a settlement mechanism that returns such values through the regular bridged stack ABI, covering every non-ConvertibleToJSValue stack type and their nested compositions. It implements the @JS struct async return requested in #753, generalized to all bridged stack types.

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()
    }
}

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. Keep ConvertibleToJSValue returns on the existing path.
Convertible returns (Int, String, Bool, Int?, [String], ...) still use JSPromise.async + .jsValue. That path already works and is simpler, so only non-convertible returns are routed through _bjs_makePromise. The split is decided by a single recursive predicate, BridgeType.usesAsyncPromiseResolve, which returns true for @JS struct, raw-value/case enums, and Optional/Array/Dictionary whose element ultimately resolves to one of those.

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) (swiftlang/swift#76165). Non-throwing bodies infer correctly and are left unannotated.

6. Diagnose, rather than miscompile, the unsupported cases.
Types that have neither a .jsValue nor a stack representation (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.

Foundation

This builds on three small ABI fixes that landed first and are what make the full type matrix work end to end: optional @JS struct imports, case-enum imports, and the Wasm i64 BigInt zero placeholder. With those in place, the resolve thunks can lower any supported value, including optional structs, optional Int64-backed enums, and dictionaries of structs.

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); 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.

Async exported functions previously required a return type conforming to
ConvertibleToJSValue, because the thunk wrapped the body in JSPromise.async
and lowered the result via .jsValue. Types like @js structs and enums have no
.jsValue, so async functions could not return them.

Return such values through a new _bjs_makePromise intrinsic: the thunk creates
a JS Promise synchronously, then settles it from a Task using generated
Promise_resolve_<mangled> / Promise_reject thunks that lower the value through
the regular imported-parameter ABI. ConvertibleToJSValue returns keep using the
existing JSPromise.async path.

Covers all bridged stack types and their compositions: @js struct, raw-value
and case enums, their Optionals, Arrays, and Dictionaries. Stack-using
parameters are hoisted and lifted in the thunk before the deferred Task runs,
so the shared bridge stack is drained synchronously. Async returns of types
that have neither a .jsValue nor a stack representation (associated-value
enums, protocols, namespace enums) are diagnosed rather than miscompiled.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant