Skip to content

Commit fef5580

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 fef5580

66 files changed

Lines changed: 3888 additions & 167 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: 123 additions & 17 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,6 +170,12 @@ public class ExportSwift {
104170
var externDecls: [DeclSyntax] = []
105171
let effects: Effects
106172

173+
/// When set, the async thunk settles via `_bjs_makePromise` rather than the `.jsValue` path.
174+
var asyncResolveReturnType: BridgeType?
175+
176+
/// Stack-using parameter lifts hoisted ahead of the deferred async closure.
177+
var asyncHoistedBindings: [CodeBlockItemSyntax] = []
178+
107179
init(effects: Effects) {
108180
self.effects = effects
109181
}
@@ -200,6 +272,9 @@ public class ExportSwift {
200272
}
201273

202274
if effects.isAsync, returnType != .void {
275+
if asyncResolveReturnType != nil {
276+
return CodeBlockItemSyntax(item: .init(StmtSyntax("return \(raw: callExpr)")))
277+
}
203278
return CodeBlockItemSyntax(item: .init(StmtSyntax("return \(raw: callExpr).jsValue")))
204279
}
205280

@@ -244,6 +319,22 @@ public class ExportSwift {
244319
param.type.isStackUsingParameter ? index : nil
245320
}
246321

322+
if effects.isAsync {
323+
// Drain stack parameters before the deferred `Task` or the shared stack is corrupted.
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() {
@@ -293,8 +384,7 @@ public class ExportSwift {
293384
return
294385
}
295386
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.
387+
// The async return value is lowered by the generated `Promise_resolve_*` helper.
298388
return
299389
}
300390

@@ -328,25 +418,25 @@ public class ExportSwift {
328418
}
329419
}
330420

421+
/// A throwing async body needs an explicit closure type, otherwise Swift infers
422+
/// `throws(any Error)` instead of `throws(JSException)`.
423+
/// See: https://github.com/swiftlang/swift/issues/76165
424+
private func asyncThrowsClosureHead(returnSpelling: String?) -> String {
425+
guard effects.isThrows else { return "" }
426+
let returns = returnSpelling.map { " -> \($0)" } ?? ""
427+
return " () async throws(JSException)\(returns) in"
428+
}
429+
331430
func render(abiName: String) -> DeclSyntax {
332431
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-
}
432+
if effects.isAsync, let resolveType = asyncResolveReturnType {
433+
let resolveName = "Promise_resolve_\(resolveType.mangleTypeName)"
434+
let closureHead = asyncThrowsClosureHead(returnSpelling: resolveType.swiftType)
345435
body = """
346-
let ret = JSPromise.async {\(raw: closureHead)
436+
\(CodeBlockItemListSyntax(asyncHoistedBindings))
437+
return _bjs_makePromise(resolve: \(raw: resolveName), reject: Promise_reject) {\(raw: closureHead)
347438
\(CodeBlockItemListSyntax(self.body))
348-
}.jsObject
349-
return ret.bridgeJSLowerReturn()
439+
}
350440
"""
351441
} else if effects.isThrows {
352442
body = """
@@ -506,8 +596,23 @@ public class ExportSwift {
506596
return decls
507597
}
508598

599+
private func configureAsyncResolve(
600+
_ builder: ExportedThunkBuilder,
601+
returnType: BridgeType,
602+
effects: Effects
603+
) throws {
604+
guard effects.isAsync else { return }
605+
guard returnType.isAsyncResolvable else {
606+
throw BridgeJSCoreError(
607+
"Returning '\(returnType.swiftType)' from an async exported function is not yet supported"
608+
)
609+
}
610+
builder.asyncResolveReturnType = returnType
611+
}
612+
509613
func renderSingleExportedFunction(function: ExportedFunction) throws -> DeclSyntax {
510614
let builder = ExportedThunkBuilder(effects: function.effects)
615+
try configureAsyncResolve(builder, returnType: function.returnType, effects: function.effects)
511616
for param in function.parameters {
512617
try builder.liftParameter(param: param)
513618
}
@@ -551,6 +656,7 @@ public class ExportSwift {
551656
instanceSelfType: BridgeType
552657
) throws -> DeclSyntax {
553658
let builder = ExportedThunkBuilder(effects: method.effects)
659+
try configureAsyncResolve(builder, returnType: method.returnType, effects: method.effects)
554660
if !method.effects.isStatic {
555661
try builder.liftParameter(param: Parameter(label: nil, name: "_self", type: instanceSelfType))
556662
}

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)