Skip to content

Commit 54469d7

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. Settle every async exported return through a new _bjs_makePromise intrinsic instead: the thunk creates a JS Promise synchronously, then resolves or rejects it from a Task using generated Promise_resolve_<mangled> / Promise_reject thunks that lower the value through the regular imported-parameter ABI. This replaces the JSPromise.async / .jsValue path entirely, unifying async return codegen on a single path. Covers all bridged types and their compositions: @js struct, raw-value and case enums, their Optionals, Arrays, and Dictionaries, alongside the existing ConvertibleToJSValue types and Void. 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 cannot be lowered through the imported-parameter ABI (associated-value enums, protocols, namespace enums) are diagnosed rather than miscompiled. The resolve/reject settlers are stashed on the Promise under a Symbol to avoid clashing with its fields.
1 parent be3b300 commit 54469d7

66 files changed

Lines changed: 3889 additions & 175 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: 124 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,77 @@ 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+
// `Void` can't cross the bridge as a parameter, so the void helper takes only the promise.
135+
var parameters = [Parameter(label: nil, name: "promise", type: .jsObject(nil))]
136+
if valueType != .void {
137+
parameters.append(Parameter(label: nil, name: "value", type: valueType))
138+
}
139+
let builder = try ImportTS.CallJSEmission(
140+
moduleName: "bjs",
141+
abiName: externName,
142+
effects: effects,
143+
returnType: .void,
144+
context: .importTS
145+
)
146+
for parameter in parameters {
147+
try builder.lowerParameter(param: parameter)
148+
}
149+
try builder.call()
150+
try builder.liftReturnValue()
151+
152+
let valueParam = valueType == .void ? "" : ", _ value: \(valueType.swiftType)"
153+
let macroDecl: DeclSyntax =
154+
"@JSFunction func \(raw: functionName)(_ promise: JSObject\(raw: valueParam)) throws(JSException)"
155+
let glueDecl = builder.renderThunkDecl(
156+
name: "_$\(functionName)",
157+
parameters: parameters,
158+
returnType: .void,
159+
effects: effects
160+
)
161+
return [macroDecl, builder.renderImportDecl(), glueDecl]
162+
}
163+
98164
class ExportedThunkBuilder {
99165
var body: [CodeBlockItemSyntax] = []
100166
var liftedParameterExprs: [ExprSyntax] = []
@@ -104,8 +170,22 @@ public class ExportSwift {
104170
var externDecls: [DeclSyntax] = []
105171
let effects: Effects
106172

107-
init(effects: Effects) {
173+
/// The async return type settled through `_bjs_makePromise`'s `Promise_resolve_<mangled>`
174+
/// helper. Set for every `async` thunk.
175+
var asyncResolveReturnType: BridgeType?
176+
177+
/// Stack-using parameter lifts hoisted ahead of the deferred async closure.
178+
var asyncHoistedBindings: [CodeBlockItemSyntax] = []
179+
180+
init(effects: Effects, returnType: BridgeType) throws {
108181
self.effects = effects
182+
guard effects.isAsync else { return }
183+
guard returnType.isAsyncResolvable else {
184+
throw BridgeJSCoreError(
185+
"Returning '\(returnType.swiftType)' from an async exported function is not yet supported"
186+
)
187+
}
188+
self.asyncResolveReturnType = returnType
109189
}
110190

111191
private func append(_ item: CodeBlockItemSyntax) {
@@ -200,7 +280,7 @@ public class ExportSwift {
200280
}
201281

202282
if effects.isAsync, returnType != .void {
203-
return CodeBlockItemSyntax(item: .init(StmtSyntax("return \(raw: callExpr).jsValue")))
283+
return CodeBlockItemSyntax(item: .init(StmtSyntax("return \(raw: callExpr)")))
204284
}
205285

206286
if returnType == .void {
@@ -244,6 +324,22 @@ public class ExportSwift {
244324
param.type.isStackUsingParameter ? index : nil
245325
}
246326

327+
if effects.isAsync {
328+
// Drain stack parameters before the deferred `Task` or the shared stack is corrupted.
329+
for index in stackParamIndices.reversed() {
330+
let param = parameters[index]
331+
let expr = liftedParameterExprs[index]
332+
let varName = "_tmp_\(param.name)"
333+
var binding: CodeBlockItemSyntax = "let \(raw: varName) = \(expr)"
334+
if !asyncHoistedBindings.isEmpty {
335+
binding = binding.with(\.leadingTrivia, .newline)
336+
}
337+
asyncHoistedBindings.append(binding)
338+
liftedParameterExprs[index] = ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(varName)))
339+
}
340+
return
341+
}
342+
247343
guard stackParamIndices.count > 1 else { return }
248344

249345
for index in stackParamIndices.reversed() {
@@ -293,8 +389,7 @@ public class ExportSwift {
293389
return
294390
}
295391
if effects.isAsync {
296-
// The return value of async function (T of `(...) async -> T`) is
297-
// handled by the JSPromise.async, so we don't need to do anything here.
392+
// The async return value is lowered by the generated `Promise_resolve_*` helper.
298393
return
299394
}
300395

@@ -328,25 +423,25 @@ public class ExportSwift {
328423
}
329424
}
330425

426+
/// A throwing async body needs an explicit closure type, otherwise Swift infers
427+
/// `throws(any Error)` instead of `throws(JSException)`.
428+
/// See: https://github.com/swiftlang/swift/issues/76165
429+
private func asyncThrowsClosureHead(returnSpelling: String?) -> String {
430+
guard effects.isThrows else { return "" }
431+
let returns = returnSpelling.map { " -> \($0)" } ?? ""
432+
return " () async throws(JSException)\(returns) in"
433+
}
434+
331435
func render(abiName: String) -> DeclSyntax {
332436
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-
}
437+
if effects.isAsync, let resolveType = asyncResolveReturnType {
438+
let resolveName = "Promise_resolve_\(resolveType.mangleTypeName)"
439+
let closureHead = asyncThrowsClosureHead(returnSpelling: resolveType.swiftType)
345440
body = """
346-
let ret = JSPromise.async {\(raw: closureHead)
441+
\(CodeBlockItemListSyntax(asyncHoistedBindings))
442+
return _bjs_makePromise(resolve: \(raw: resolveName), reject: Promise_reject) {\(raw: closureHead)
347443
\(CodeBlockItemListSyntax(self.body))
348-
}.jsObject
349-
return ret.bridgeJSLowerReturn()
444+
}
350445
"""
351446
} else if effects.isThrows {
352447
body = """
@@ -457,7 +552,10 @@ public class ExportSwift {
457552
let className = context.className
458553
let isStatic = context.isStatic
459554

460-
let getterBuilder = ExportedThunkBuilder(effects: Effects(isAsync: false, isThrows: false, isStatic: isStatic))
555+
let getterBuilder = try ExportedThunkBuilder(
556+
effects: Effects(isAsync: false, isThrows: false, isStatic: isStatic),
557+
returnType: property.type
558+
)
461559

462560
if !isStatic {
463561
try getterBuilder.liftParameter(
@@ -476,8 +574,9 @@ public class ExportSwift {
476574

477575
// Generate property setter if not readonly
478576
if !property.isReadonly {
479-
let setterBuilder = ExportedThunkBuilder(
480-
effects: Effects(isAsync: false, isThrows: false, isStatic: isStatic)
577+
let setterBuilder = try ExportedThunkBuilder(
578+
effects: Effects(isAsync: false, isThrows: false, isStatic: isStatic),
579+
returnType: .void
481580
)
482581

483582
// Lift parameters based on property type
@@ -507,7 +606,7 @@ public class ExportSwift {
507606
}
508607

509608
func renderSingleExportedFunction(function: ExportedFunction) throws -> DeclSyntax {
510-
let builder = ExportedThunkBuilder(effects: function.effects)
609+
let builder = try ExportedThunkBuilder(effects: function.effects, returnType: function.returnType)
511610
for param in function.parameters {
512611
try builder.liftParameter(param: param)
513612
}
@@ -536,7 +635,7 @@ public class ExportSwift {
536635
callName: String,
537636
returnType: BridgeType
538637
) throws -> DeclSyntax {
539-
let builder = ExportedThunkBuilder(effects: constructor.effects)
638+
let builder = try ExportedThunkBuilder(effects: constructor.effects, returnType: returnType)
540639
for param in constructor.parameters {
541640
try builder.liftParameter(param: param)
542641
}
@@ -550,7 +649,7 @@ public class ExportSwift {
550649
ownerTypeName: String,
551650
instanceSelfType: BridgeType
552651
) throws -> DeclSyntax {
553-
let builder = ExportedThunkBuilder(effects: method.effects)
652+
let builder = try ExportedThunkBuilder(effects: method.effects, returnType: method.returnType)
554653
if !method.effects.isStatic {
555654
try builder.liftParameter(param: Parameter(label: nil, name: "_self", type: instanceSelfType))
556655
}

Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift

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

359+
/// JS const (in the import glue scope) holding the `Symbol` under which a promise's
360+
/// resolve/reject settlers are stashed.
361+
private static let promiseSettlersSymbol = "__bjs_promiseSettlers"
362+
363+
/// Renders a `bjs[...]` settlement handler that lifts `(promise, value)` and calls the
364+
/// promise's stashed `resolve` / `reject` settler.
365+
private func renderPromiseSettleHandler(
366+
externName: String,
367+
valueType: BridgeType,
368+
settle: String,
369+
into printer: CodeFragmentPrinter
370+
) throws {
371+
let builder = ImportedThunkBuilder(
372+
effects: Effects(isAsync: false, isThrows: true),
373+
returnType: .void,
374+
intrinsicRegistry: intrinsicRegistry
375+
)
376+
try builder.liftParameter(param: Parameter(label: nil, name: "promise", type: .jsObject(nil)))
377+
// `Void` can't cross the bridge as a parameter, so the void resolve settles with `undefined`.
378+
let valueArg: String
379+
if valueType == .void {
380+
valueArg = ""
381+
} else {
382+
try builder.liftParameter(param: Parameter(label: nil, name: "value", type: valueType))
383+
valueArg = builder.parameterForwardings[1]
384+
}
385+
builder.body.write(
386+
"\(builder.parameterForwardings[0])[\(Self.promiseSettlersSymbol)].\(settle)(\(valueArg));"
387+
)
388+
var lines = builder.renderFunction(name: nil)
389+
lines[0] = "bjs[\"\(externName)\"] = \(lines[0])"
390+
printer.write(lines: lines)
391+
}
392+
359393
private func generateAddImports(needsImportsObject: Bool) throws -> CodeFragmentPrinter {
360394
let printer = CodeFragmentPrinter()
361395
let allStructs = skeletons.compactMap { $0.exported?.structs }.flatMap { $0 }
@@ -526,6 +560,39 @@ public struct BridgeJSLink {
526560
}
527561
}
528562

563+
// Always provided: the runtime's `_bjs_makePromise` imports it unconditionally.
564+
// The settlers are stored under a Symbol to avoid clashing with promise fields.
565+
printer.write("const \(Self.promiseSettlersSymbol) = Symbol(\"JavaScriptKit.promiseSettlers\");")
566+
printer.write("bjs[\"swift_js_make_promise\"] = function() {")
567+
printer.indent {
568+
printer.write("let resolve, reject;")
569+
printer.write("const promise = new Promise((res, rej) => { resolve = res; reject = rej; });")
570+
printer.write("promise[\(Self.promiseSettlersSymbol)] = { resolve, reject };")
571+
printer.write(
572+
"return \(JSGlueVariableScope.reservedSwift).\(JSGlueVariableScope.reservedMemory).retain(promise);"
573+
)
574+
}
575+
printer.write("}")
576+
for skeleton in skeletons {
577+
guard let exported = skeleton.exported else { continue }
578+
let asyncResolveTypes = exported.asyncPromiseResolveReturnTypes
579+
guard !asyncResolveTypes.isEmpty else { continue }
580+
for type in asyncResolveTypes {
581+
try renderPromiseSettleHandler(
582+
externName: "promise_resolve_\(skeleton.moduleName)_\(type.mangleTypeName)",
583+
valueType: type,
584+
settle: "resolve",
585+
into: printer
586+
)
587+
}
588+
try renderPromiseSettleHandler(
589+
externName: "promise_reject_\(skeleton.moduleName)",
590+
valueType: .jsValue,
591+
settle: "reject",
592+
into: printer
593+
)
594+
}
595+
529596
printer.write("bjs[\"swift_js_return_optional_bool\"] = function(isSome, value) {")
530597
printer.indent {
531598
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,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 `async` return types needing a `Promise_resolve_<mangled>` helper, deduplicated
1032+
/// 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.isAsyncResolvable,
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,25 @@ extension BridgeType {
15841608
return false
15851609
}
15861610

1611+
/// Whether a value of this type can be passed to a generated `Promise_resolve_<mangled>`
1612+
/// settlement helper, i.e. lowered through the imported-parameter ABI. Every `async`
1613+
/// exported return settles through `_bjs_makePromise`; the few types that cannot be lowered
1614+
/// (associated-value enums, protocols, namespace enums, and their compositions) are diagnosed.
1615+
public var isAsyncResolvable: Bool {
1616+
switch self {
1617+
case .associatedValueEnum, .swiftProtocol, .namespaceEnum:
1618+
return false
1619+
case .nullable(let wrapped, _):
1620+
return wrapped.isAsyncResolvable
1621+
case .array(let element):
1622+
return element.isAsyncResolvable
1623+
case .dictionary(let value):
1624+
return value.isAsyncResolvable
1625+
default:
1626+
return true
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)