Skip to content

Commit 5d17f95

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 5d17f95

66 files changed

Lines changed: 3460 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: 146 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,9 @@ public class ExportSwift {
200270
}
201271

202272
if effects.isAsync, returnType != .void {
273+
if asyncResolveReturnType != nil {
274+
return CodeBlockItemSyntax(item: .init(StmtSyntax("return \(raw: callExpr)")))
275+
}
203276
return CodeBlockItemSyntax(item: .init(StmtSyntax("return \(raw: callExpr).jsValue")))
204277
}
205278

@@ -244,6 +317,22 @@ public class ExportSwift {
244317
param.type.isStackUsingParameter ? index : nil
245318
}
246319

320+
if effects.isAsync {
321+
// Drain stack parameters before the deferred `Task` or the shared stack is corrupted.
322+
for index in stackParamIndices.reversed() {
323+
let param = parameters[index]
324+
let expr = liftedParameterExprs[index]
325+
let varName = "_tmp_\(param.name)"
326+
var binding: CodeBlockItemSyntax = "let \(raw: varName) = \(expr)"
327+
if !asyncHoistedBindings.isEmpty {
328+
binding = binding.with(\.leadingTrivia, .newline)
329+
}
330+
asyncHoistedBindings.append(binding)
331+
liftedParameterExprs[index] = ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(varName)))
332+
}
333+
return
334+
}
335+
247336
guard stackParamIndices.count > 1 else { return }
248337

249338
for index in stackParamIndices.reversed() {
@@ -328,21 +417,31 @@ public class ExportSwift {
328417
}
329418
}
330419

420+
/// A throwing async body needs an explicit closure type, otherwise Swift infers
421+
/// `throws(any Error)` instead of `throws(JSException)`.
422+
/// See: https://github.com/swiftlang/swift/issues/76165
423+
private func asyncThrowsClosureHead(returnSpelling: String?) -> String {
424+
guard effects.isThrows else { return "" }
425+
let returns = returnSpelling.map { " -> \($0)" } ?? ""
426+
return " () async throws(JSException)\(returns) in"
427+
}
428+
331429
func render(abiName: String) -> DeclSyntax {
332430
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-
}
431+
if effects.isAsync, let resolveType = asyncResolveReturnType {
432+
let resolveName = "Promise_resolve_\(resolveType.mangleTypeName)"
433+
let closureHead = asyncThrowsClosureHead(returnSpelling: resolveType.swiftType)
434+
body = """
435+
\(CodeBlockItemListSyntax(asyncHoistedBindings))
436+
return _bjs_makePromise(resolve: \(raw: resolveName), reject: Promise_reject) {\(raw: closureHead)
437+
\(CodeBlockItemListSyntax(self.body))
438+
}
439+
"""
440+
} else if effects.isAsync {
441+
let hasReturn = self.body.contains { $0.description.contains("return ") }
442+
let closureHead = asyncThrowsClosureHead(returnSpelling: hasReturn ? "JSValue" : nil)
345443
body = """
444+
\(CodeBlockItemListSyntax(asyncHoistedBindings))
346445
let ret = JSPromise.async {\(raw: closureHead)
347446
\(CodeBlockItemListSyntax(self.body))
348447
}.jsObject
@@ -506,8 +605,42 @@ public class ExportSwift {
506605
return decls
507606
}
508607

608+
private func configureAsyncResolve(
609+
_ builder: ExportedThunkBuilder,
610+
returnType: BridgeType,
611+
effects: Effects
612+
) throws {
613+
guard effects.isAsync else { return }
614+
if returnType.usesAsyncPromiseResolve {
615+
builder.asyncResolveReturnType = returnType
616+
return
617+
}
618+
// Diagnose types with neither a `.jsValue` nor a `_bjs_makePromise` representation.
619+
if Self.asyncReturnLacksBridging(returnType) {
620+
throw BridgeJSCoreError(
621+
"Returning '\(returnType.swiftType)' from an async exported function is not yet supported"
622+
)
623+
}
624+
}
625+
626+
private static func asyncReturnLacksBridging(_ type: BridgeType) -> Bool {
627+
switch type {
628+
case .associatedValueEnum, .swiftProtocol, .namespaceEnum:
629+
return true
630+
case .nullable(let wrapped, _):
631+
return asyncReturnLacksBridging(wrapped)
632+
case .array(let element):
633+
return asyncReturnLacksBridging(element)
634+
case .dictionary(let value):
635+
return asyncReturnLacksBridging(value)
636+
default:
637+
return false
638+
}
639+
}
640+
509641
func renderSingleExportedFunction(function: ExportedFunction) throws -> DeclSyntax {
510642
let builder = ExportedThunkBuilder(effects: function.effects)
643+
try configureAsyncResolve(builder, returnType: function.returnType, effects: function.effects)
511644
for param in function.parameters {
512645
try builder.liftParameter(param: param)
513646
}
@@ -551,6 +684,7 @@ public class ExportSwift {
551684
instanceSelfType: BridgeType
552685
) throws -> DeclSyntax {
553686
let builder = ExportedThunkBuilder(effects: method.effects)
687+
try configureAsyncResolve(builder, returnType: method.returnType, effects: method.effects)
554688
if !method.effects.isStatic {
555689
try builder.liftParameter(param: Parameter(label: nil, name: "_self", type: instanceSelfType))
556690
}

Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift

Lines changed: 53 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,38 @@ public struct BridgeJSLink {
526547
}
527548
}
528549

550+
// Always provided: the runtime's `_bjs_makePromise` imports it unconditionally.
551+
printer.write("bjs[\"swift_js_make_promise\"] = function() {")
552+
printer.indent {
553+
printer.write("let resolve, reject;")
554+
printer.write("const promise = new Promise((res, rej) => { resolve = res; reject = rej; });")
555+
printer.write("promise.__bjs_resolve = resolve;")
556+
printer.write("promise.__bjs_reject = reject;")
557+
printer.write(
558+
"return \(JSGlueVariableScope.reservedSwift).\(JSGlueVariableScope.reservedMemory).retain(promise);"
559+
)
560+
}
561+
printer.write("}")
562+
for skeleton in skeletons {
563+
guard let exported = skeleton.exported else { continue }
564+
let asyncResolveTypes = exported.asyncPromiseResolveReturnTypes
565+
guard !asyncResolveTypes.isEmpty else { continue }
566+
for type in asyncResolveTypes {
567+
try renderPromiseSettleHandler(
568+
externName: "promise_resolve_\(skeleton.moduleName)_\(type.mangleTypeName)",
569+
valueType: type,
570+
settle: "__bjs_resolve",
571+
into: printer
572+
)
573+
}
574+
try renderPromiseSettleHandler(
575+
externName: "promise_reject_\(skeleton.moduleName)",
576+
valueType: .jsValue,
577+
settle: "__bjs_reject",
578+
into: printer
579+
)
580+
}
581+
529582
printer.write("bjs[\"swift_js_return_optional_bool\"] = function(isSome, value) {")
530583
printer.indent {
531584
printer.write("if (isSome === 0) {")

Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift

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

10321056
// MARK: - Imported Skeleton
@@ -1584,6 +1608,23 @@ extension BridgeType {
15841608
return false
15851609
}
15861610

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

0 commit comments

Comments
 (0)