Skip to content

Commit bb0ed80

Browse files
committed
BridgeJS: Support non-ConvertibleToJSValue async exported return types
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.
1 parent be3b300 commit bb0ed80

66 files changed

Lines changed: 3513 additions & 104 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift

Lines changed: 154 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,75 @@ public class ExportSwift {
9090
decls.append(contentsOf: try renderSingleExportedClass(klass: klass))
9191
}
9292
}
93+
94+
try withSpan("Render Async Promise Helpers") { [self] in
95+
let asyncResolveTypes = skeleton.asyncPromiseResolveReturnTypes
96+
if !asyncResolveTypes.isEmpty {
97+
decls.append(contentsOf: try renderPromiseRejectHelper())
98+
for type in asyncResolveTypes {
99+
decls.append(contentsOf: try renderPromiseResolveHelper(type))
100+
}
101+
}
102+
}
93103
return withSpan("Format Export Glue") {
94104
return decls.map { $0.description }.joined(separator: "\n\n")
95105
}
96106
}
97107

108+
/// Generates the per-type `Promise_resolve_<mangled>` settlement helper.
109+
private func renderPromiseResolveHelper(_ type: BridgeType) throws -> [DeclSyntax] {
110+
try renderPromiseSettleHelper(
111+
functionName: "Promise_resolve_\(type.mangleTypeName)",
112+
externName: "promise_resolve_\(moduleName)_\(type.mangleTypeName)",
113+
valueType: type
114+
)
115+
}
116+
117+
/// Generates the shared `Promise_reject` settlement helper.
118+
private func renderPromiseRejectHelper() throws -> [DeclSyntax] {
119+
try renderPromiseSettleHelper(
120+
functionName: "Promise_reject",
121+
externName: "promise_reject_\(moduleName)",
122+
valueType: .jsValue
123+
)
124+
}
125+
126+
/// Generates a `@JSFunction func <functionName>(_ promise: JSObject, _ value: T)` and its
127+
/// glue, lowering `value` through the standard imported-parameter ABI.
128+
private func renderPromiseSettleHelper(
129+
functionName: String,
130+
externName: String,
131+
valueType: BridgeType
132+
) throws -> [DeclSyntax] {
133+
let effects = Effects(isAsync: false, isThrows: true)
134+
let parameters = [
135+
Parameter(label: nil, name: "promise", type: .jsObject(nil)),
136+
Parameter(label: nil, name: "value", type: valueType),
137+
]
138+
let builder = try ImportTS.CallJSEmission(
139+
moduleName: "bjs",
140+
abiName: externName,
141+
effects: effects,
142+
returnType: .void,
143+
context: .importTS
144+
)
145+
for parameter in parameters {
146+
try builder.lowerParameter(param: parameter)
147+
}
148+
try builder.call()
149+
try builder.liftReturnValue()
150+
151+
let macroDecl: DeclSyntax =
152+
"@JSFunction func \(raw: functionName)(_ promise: JSObject, _ value: \(raw: valueType.swiftType)) throws(JSException)"
153+
let glueDecl = builder.renderThunkDecl(
154+
name: "_$\(functionName)",
155+
parameters: parameters,
156+
returnType: .void,
157+
effects: effects
158+
)
159+
return [macroDecl, builder.renderImportDecl(), glueDecl]
160+
}
161+
98162
class ExportedThunkBuilder {
99163
var body: [CodeBlockItemSyntax] = []
100164
var liftedParameterExprs: [ExprSyntax] = []
@@ -104,6 +168,12 @@ public class ExportSwift {
104168
var externDecls: [DeclSyntax] = []
105169
let effects: Effects
106170

171+
/// When set, the async thunk settles via `_bjs_makePromise` rather than the `.jsValue` path.
172+
var asyncResolveReturnType: BridgeType?
173+
174+
/// Stack-using parameter lifts hoisted ahead of the deferred async closure.
175+
var asyncHoistedBindings: [CodeBlockItemSyntax] = []
176+
107177
init(effects: Effects) {
108178
self.effects = effects
109179
}
@@ -200,6 +270,10 @@ public class ExportSwift {
200270
}
201271

202272
if effects.isAsync, returnType != .void {
273+
if asyncResolveReturnType != nil {
274+
// `_bjs_makePromise` lowers the awaited value via its injected `resolve` closure.
275+
return CodeBlockItemSyntax(item: .init(StmtSyntax("return \(raw: callExpr)")))
276+
}
203277
return CodeBlockItemSyntax(item: .init(StmtSyntax("return \(raw: callExpr).jsValue")))
204278
}
205279

@@ -244,6 +318,23 @@ public class ExportSwift {
244318
param.type.isStackUsingParameter ? index : nil
245319
}
246320

321+
if effects.isAsync {
322+
// The async body runs on a deferred `Task`, so drain stack parameters now and
323+
// capture them; the shared bridge stack would otherwise be corrupted. Reverse for LIFO.
324+
for index in stackParamIndices.reversed() {
325+
let param = parameters[index]
326+
let expr = liftedParameterExprs[index]
327+
let varName = "_tmp_\(param.name)"
328+
var binding: CodeBlockItemSyntax = "let \(raw: varName) = \(expr)"
329+
if !asyncHoistedBindings.isEmpty {
330+
binding = binding.with(\.leadingTrivia, .newline)
331+
}
332+
asyncHoistedBindings.append(binding)
333+
liftedParameterExprs[index] = ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(varName)))
334+
}
335+
return
336+
}
337+
247338
guard stackParamIndices.count > 1 else { return }
248339

249340
for index in stackParamIndices.reversed() {
@@ -328,21 +419,32 @@ public class ExportSwift {
328419
}
329420
}
330421

422+
/// A throwing async body needs an explicit closure type, otherwise Swift infers
423+
/// `throws(any Error)` instead of `throws(JSException)`.
424+
/// See: https://github.com/swiftlang/swift/issues/76165
425+
private func asyncThrowsClosureHead(returnSpelling: String?) -> String {
426+
guard effects.isThrows else { return "" }
427+
let returns = returnSpelling.map { " -> \($0)" } ?? ""
428+
return " () async throws(JSException)\(returns) in"
429+
}
430+
331431
func render(abiName: String) -> DeclSyntax {
332432
let body: CodeBlockItemListSyntax
333-
if effects.isAsync {
334-
// Explicit closure type annotation needed when throws is present
335-
// so Swift infers throws(JSException) instead of throws(any Error)
336-
// See: https://github.com/swiftlang/swift/issues/76165
337-
let closureHead: String
338-
if effects.isThrows {
339-
let hasReturn = self.body.contains { $0.description.contains("return ") }
340-
let ret = hasReturn ? " -> JSValue" : ""
341-
closureHead = " () async throws(JSException)\(ret) in"
342-
} else {
343-
closureHead = ""
344-
}
433+
if effects.isAsync, let resolveType = asyncResolveReturnType {
434+
// Not `ConvertibleToJSValue`: settle the promise through `_bjs_makePromise`.
435+
let resolveName = "Promise_resolve_\(resolveType.mangleTypeName)"
436+
let closureHead = asyncThrowsClosureHead(returnSpelling: resolveType.swiftType)
437+
body = """
438+
\(CodeBlockItemListSyntax(asyncHoistedBindings))
439+
return _bjs_makePromise(resolve: \(raw: resolveName), reject: Promise_reject) {\(raw: closureHead)
440+
\(CodeBlockItemListSyntax(self.body))
441+
}
442+
"""
443+
} else if effects.isAsync {
444+
let hasReturn = self.body.contains { $0.description.contains("return ") }
445+
let closureHead = asyncThrowsClosureHead(returnSpelling: hasReturn ? "JSValue" : nil)
345446
body = """
447+
\(CodeBlockItemListSyntax(asyncHoistedBindings))
346448
let ret = JSPromise.async {\(raw: closureHead)
347449
\(CodeBlockItemListSyntax(self.body))
348450
}.jsObject
@@ -506,8 +608,47 @@ public class ExportSwift {
506608
return decls
507609
}
508610

611+
private func configureAsyncResolve(
612+
_ builder: ExportedThunkBuilder,
613+
returnType: BridgeType,
614+
effects: Effects
615+
) throws {
616+
guard effects.isAsync else { return }
617+
if returnType.usesAsyncPromiseResolve {
618+
builder.asyncResolveReturnType = returnType
619+
return
620+
}
621+
// Otherwise the `.jsValue` path is used, which only compiles for `ConvertibleToJSValue`
622+
// types. Diagnose the types that have neither a `.jsValue` nor a `_bjs_makePromise`
623+
// representation rather than emitting uncompilable code.
624+
if Self.asyncReturnLacksBridging(returnType) {
625+
throw BridgeJSCoreError(
626+
"Returning '\(returnType.swiftType)' from an async exported function is not yet supported"
627+
)
628+
}
629+
}
630+
631+
/// Whether an `async` return type can be neither lowered through `_bjs_makePromise`
632+
/// (`usesAsyncPromiseResolve`) nor through the `.jsValue` path. Recurses through
633+
/// `Optional`/`Array`/`Dictionary` to catch unsupported element/value types.
634+
private static func asyncReturnLacksBridging(_ type: BridgeType) -> Bool {
635+
switch type {
636+
case .associatedValueEnum, .swiftProtocol, .namespaceEnum:
637+
return true
638+
case .nullable(let wrapped, _):
639+
return asyncReturnLacksBridging(wrapped)
640+
case .array(let element):
641+
return asyncReturnLacksBridging(element)
642+
case .dictionary(let value):
643+
return asyncReturnLacksBridging(value)
644+
default:
645+
return false
646+
}
647+
}
648+
509649
func renderSingleExportedFunction(function: ExportedFunction) throws -> DeclSyntax {
510650
let builder = ExportedThunkBuilder(effects: function.effects)
651+
try configureAsyncResolve(builder, returnType: function.returnType, effects: function.effects)
511652
for param in function.parameters {
512653
try builder.liftParameter(param: param)
513654
}
@@ -551,6 +692,7 @@ public class ExportSwift {
551692
instanceSelfType: BridgeType
552693
) throws -> DeclSyntax {
553694
let builder = ExportedThunkBuilder(effects: method.effects)
695+
try configureAsyncResolve(builder, returnType: method.returnType, effects: method.effects)
554696
if !method.effects.isStatic {
555697
try builder.liftParameter(param: Parameter(label: nil, name: "_self", type: instanceSelfType))
556698
}

Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,27 @@ public struct BridgeJSLink {
356356
]
357357
}
358358

359+
/// Renders a `bjs[...]` settlement handler that lifts `(promise, value)` and calls
360+
/// `promise.__bjs_resolve` / `__bjs_reject`.
361+
private func renderPromiseSettleHandler(
362+
externName: String,
363+
valueType: BridgeType,
364+
settle: String,
365+
into printer: CodeFragmentPrinter
366+
) throws {
367+
let builder = ImportedThunkBuilder(
368+
effects: Effects(isAsync: false, isThrows: true),
369+
returnType: .void,
370+
intrinsicRegistry: intrinsicRegistry
371+
)
372+
try builder.liftParameter(param: Parameter(label: nil, name: "promise", type: .jsObject(nil)))
373+
try builder.liftParameter(param: Parameter(label: nil, name: "value", type: valueType))
374+
builder.body.write("\(builder.parameterForwardings[0]).\(settle)(\(builder.parameterForwardings[1]));")
375+
var lines = builder.renderFunction(name: nil)
376+
lines[0] = "bjs[\"\(externName)\"] = \(lines[0])"
377+
printer.write(lines: lines)
378+
}
379+
359380
private func generateAddImports(needsImportsObject: Bool) throws -> CodeFragmentPrinter {
360381
let printer = CodeFragmentPrinter()
361382
let allStructs = skeletons.compactMap { $0.exported?.structs }.flatMap { $0 }
@@ -526,6 +547,42 @@ public struct BridgeJSLink {
526547
}
527548
}
528549

550+
// `swift_js_make_promise` is imported unconditionally by the runtime's
551+
// `_bjs_makePromise`, so it is always provided (like the other runtime
552+
// intrinsics below). The per-type `promise_resolve_*` / `promise_reject_*`
553+
// settlement handlers are only emitted for modules that actually have async
554+
// non-`ConvertibleToJSValue` returns.
555+
printer.write("bjs[\"swift_js_make_promise\"] = function() {")
556+
printer.indent {
557+
printer.write("let resolve, reject;")
558+
printer.write("const promise = new Promise((res, rej) => { resolve = res; reject = rej; });")
559+
printer.write("promise.__bjs_resolve = resolve;")
560+
printer.write("promise.__bjs_reject = reject;")
561+
printer.write(
562+
"return \(JSGlueVariableScope.reservedSwift).\(JSGlueVariableScope.reservedMemory).retain(promise);"
563+
)
564+
}
565+
printer.write("}")
566+
for skeleton in skeletons {
567+
guard let exported = skeleton.exported else { continue }
568+
let asyncResolveTypes = exported.asyncPromiseResolveReturnTypes
569+
guard !asyncResolveTypes.isEmpty else { continue }
570+
for type in asyncResolveTypes {
571+
try renderPromiseSettleHandler(
572+
externName: "promise_resolve_\(skeleton.moduleName)_\(type.mangleTypeName)",
573+
valueType: type,
574+
settle: "__bjs_resolve",
575+
into: printer
576+
)
577+
}
578+
try renderPromiseSettleHandler(
579+
externName: "promise_reject_\(skeleton.moduleName)",
580+
valueType: .jsValue,
581+
settle: "__bjs_reject",
582+
into: printer
583+
)
584+
}
585+
529586
printer.write("bjs[\"swift_js_return_optional_bool\"] = function(isSome, value) {")
530587
printer.indent {
531588
printer.write("if (isSome === 0) {")

Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,6 +1027,31 @@ public struct ExportedSkeleton: Codable {
10271027
public var isEmpty: Bool {
10281028
functions.isEmpty && classes.isEmpty && enums.isEmpty && structs.isEmpty && protocols.isEmpty
10291029
}
1030+
1031+
/// The distinct non-`ConvertibleToJSValue` return types of `async` exported functions,
1032+
/// each needing a `Promise_resolve_<mangled>` helper. Deduplicated by mangled type name
1033+
/// and shared by the Swift codegen and JS link so they stay in sync.
1034+
public var asyncPromiseResolveReturnTypes: [BridgeType] {
1035+
var seen = Set<String>()
1036+
var result: [BridgeType] = []
1037+
func consider(_ returnType: BridgeType, _ effects: Effects) {
1038+
guard effects.isAsync, returnType.usesAsyncPromiseResolve,
1039+
seen.insert(returnType.mangleTypeName).inserted
1040+
else { return }
1041+
result.append(returnType)
1042+
}
1043+
for function in functions { consider(function.returnType, function.effects) }
1044+
for klass in classes {
1045+
for method in klass.methods { consider(method.returnType, method.effects) }
1046+
}
1047+
for structDef in structs {
1048+
for method in structDef.methods { consider(method.returnType, method.effects) }
1049+
}
1050+
for enumDef in enums {
1051+
for method in enumDef.staticMethods { consider(method.returnType, method.effects) }
1052+
}
1053+
return result
1054+
}
10301055
}
10311056

10321057
// MARK: - Imported Skeleton
@@ -1584,6 +1609,24 @@ extension BridgeType {
15841609
return false
15851610
}
15861611

1612+
/// Whether an `async` exported return of this type settles through `_bjs_makePromise`
1613+
/// rather than the `.jsValue` path. True for the non-`ConvertibleToJSValue` types the
1614+
/// bridged-parameter ABI can transfer; extend as that ABI gains support for more types.
1615+
public var usesAsyncPromiseResolve: Bool {
1616+
switch self {
1617+
case .swiftStruct, .rawValueEnum, .caseEnum:
1618+
return true
1619+
case .nullable(let wrapped, _):
1620+
return wrapped.usesAsyncPromiseResolve
1621+
case .array(let element):
1622+
return element.usesAsyncPromiseResolve
1623+
case .dictionary(let value):
1624+
return value.usesAsyncPromiseResolve
1625+
default:
1626+
return false
1627+
}
1628+
}
1629+
15871630
/// Simplified Swift ABI-style mangled name
15881631
/// https://github.com/swiftlang/swift/blob/main/docs/ABI/Mangling.rst#types
15891632
public var mangleTypeName: String {

0 commit comments

Comments
 (0)