Skip to content

Commit dad4c01

Browse files
committed
BridgeJS: Support throws and async for closures
1 parent 453b841 commit dad4c01

27 files changed

Lines changed: 4057 additions & 86 deletions

Plugins/BridgeJS/Sources/BridgeJSCore/ClosureCodegen.swift

Lines changed: 132 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ public struct ClosureCodegen {
1616
let closureParams = signature.parameters.map { "\(sendingPrefix)\($0.closureSwiftType)" }.joined(
1717
separator: ", "
1818
)
19-
let swiftEffects = (signature.isAsync ? " async" : "") + (signature.isThrows ? " throws" : "")
19+
// BridgeJS only bridges `throws(JSException)` closures, so emit the typed throws clause.
20+
let swiftEffects = (signature.isAsync ? " async" : "") + (signature.isThrows ? " throws(JSException)" : "")
2021
let swiftReturnType = signature.returnType.closureSwiftType
2122
return "(\(closureParams))\(swiftEffects) -> \(swiftReturnType)"
2223
}
@@ -72,8 +73,24 @@ public struct ClosureCodegen {
7273
helperEnumDeclPrinter.write("static func bridgeJSLift(_ callbackId: Int32) -> \(swiftClosureType) {")
7374
helperEnumDeclPrinter.indent {
7475
helperEnumDeclPrinter.write("let callback = JSObject.bridgeJSLiftParameter(callbackId)")
76+
// Throwing/async closures need an explicit signature with a typed effect
77+
// clause: a bare multi-statement closure literal infers its thrown type as
78+
// `any Error` even when assigned to a `throws(JSException)` function type.
79+
// Spelling out parameter types, the effect clause, and return type pins
80+
// inference to `JSException`.
7581
let parameters: String
76-
if signature.parameters.isEmpty {
82+
if signature.isThrows || signature.isAsync {
83+
let sendingPrefix = signature.sendingParameters ? "sending " : ""
84+
let typedParams =
85+
signature.parameters.enumerated().map { index, paramType in
86+
"param\(index): \(sendingPrefix)\(paramType.closureSwiftType)"
87+
}.joined(separator: ", ")
88+
let returnType = signature.returnType.closureSwiftType
89+
// Canonical effect order is `async` before `throws` (Mangling.rst).
90+
let effects =
91+
(signature.isAsync ? " async" : "") + (signature.isThrows ? " throws(JSException)" : "")
92+
parameters = " (\(typedParams))\(effects) -> \(returnType)"
93+
} else if signature.parameters.isEmpty {
7794
parameters = ""
7895
} else if signature.parameters.count == 1 {
7996
parameters = " param0"
@@ -146,22 +163,29 @@ public struct ClosureCodegen {
146163
liftedParams.append("\(paramType.swiftType).bridgeJSLiftParameter(\(argNames.joined(separator: ", ")))")
147164
}
148165

149-
let closureCallExpr = ExprSyntax("closure(\(raw: liftedParams.joined(separator: ", ")))")
166+
let tryPrefix = signature.isThrows ? "try " : ""
167+
let closureCallExpr = ExprSyntax("\(raw: tryPrefix)closure(\(raw: liftedParams.joined(separator: ", ")))")
168+
let asyncTryPrefix = (signature.isThrows ? "try " : "") + "await "
169+
let asyncClosureCallExpr = ExprSyntax(
170+
"\(raw: asyncTryPrefix)closure(\(raw: liftedParams.joined(separator: ", ")))"
171+
)
150172

151-
let abiReturnWasmType = try signature.returnType.loweringReturnInfo().returnType
173+
// An async closure returns a JS `Promise` (a `JSObject`) synchronously and settles
174+
// it later, like an exported `async` Swift function, so the ABI return is always
175+
// the Promise object id (`i32`) regardless of the closure's logical return type.
176+
let abiReturnWasmType =
177+
signature.isAsync
178+
? try BridgeType.jsObject(nil).loweringReturnInfo().returnType
179+
: try signature.returnType.loweringReturnInfo().returnType
152180

153181
// Build signature using SwiftSignatureBuilder
154182
let funcSignature = SwiftSignatureBuilder.buildABIFunctionSignature(
155183
abiParameters: abiParams,
156184
returnType: abiReturnWasmType
157185
)
158186

159-
// Build function declaration using helper
160-
let funcDecl = SwiftCodePattern.buildExposedFunctionDecl(
161-
abiName: abiName,
162-
signature: funcSignature
163-
) { printer in
164-
printer.write("let closure = Unmanaged<\(boxType)>.fromOpaque(boxPtr).takeUnretainedValue().closure")
187+
// Emits the closure call and lowers its return value onto the ABI.
188+
let emitCallAndLower: (CodeFragmentPrinter) -> Void = { printer in
165189
if signature.returnType == .void {
166190
printer.write(closureCallExpr.description)
167191
} else {
@@ -189,6 +213,101 @@ public struct ClosureCodegen {
189213
}
190214
}
191215

216+
// Emits the async closure call wrapped in a `_bjs_makePromise` and lowers the
217+
// resulting Promise object onto the ABI, reusing the mechanism the exported
218+
// async-function thunk uses: `_bjs_makePromise(resolve: Promise_resolve_<mangleR>,
219+
// reject: Promise_reject)` with a `() async throws(JSException) -> R in` body. The
220+
// closure's return type R is collected into the shared
221+
// `asyncPromiseResolveReturnTypes` set, so its concrete `Promise_resolve_<R>` thunk
222+
// is emitted alongside the function ones.
223+
//
224+
// No explicit box pin is needed across suspension: `takeUnretainedValue().closure`
225+
// copies the closure value out of the box, and that copy is captured by the
226+
// escaping `_bjs_makePromise` body, keeping it alive for the whole settling `Task`
227+
// independent of the box's JS-owned refcount.
228+
let emitAsyncCallAndLower: (CodeFragmentPrinter) -> Void = { printer in
229+
printer.write("let closure = Unmanaged<\(boxType)>.fromOpaque(boxPtr).takeUnretainedValue().closure")
230+
let resolveType = signature.returnType
231+
let resolveName = "Promise_resolve_\(resolveType.mangleTypeName)"
232+
let rejectName = "Promise_reject"
233+
// A throwing async body needs an explicit `() async throws(JSException) -> R`
234+
// head, otherwise Swift infers `throws(any Error)` for a multi-statement
235+
// closure literal (see https://github.com/swiftlang/swift/issues/76165).
236+
let closureHead: String
237+
if signature.isThrows {
238+
let returnSpelling = resolveType == .void ? "" : " -> \(resolveType.closureSwiftType)"
239+
closureHead = " () async throws(JSException)\(returnSpelling) in"
240+
} else {
241+
closureHead = ""
242+
}
243+
printer.write("return _bjs_makePromise(resolve: \(resolveName), reject: \(rejectName)) {\(closureHead)")
244+
printer.indent {
245+
if resolveType == .void {
246+
printer.write(asyncClosureCallExpr.description)
247+
} else {
248+
printer.write("return \(asyncClosureCallExpr)")
249+
}
250+
}
251+
printer.write("}")
252+
}
253+
254+
// The placeholder return statement used on the catch path so the exposed
255+
// function still satisfies its ABI return type after routing the thrown
256+
// JSException to JS via `_swift_js_throw`.
257+
let catchPlaceholderStmt = abiReturnWasmType?.swiftReturnPlaceholderStmt
258+
259+
// Build function declaration using helper
260+
let funcDecl = SwiftCodePattern.buildExposedFunctionDecl(
261+
abiName: abiName,
262+
signature: funcSignature
263+
) { printer in
264+
if signature.isAsync {
265+
// `emitAsyncCallAndLower` extracts `closure` from the box itself, so the
266+
// unconditional unretained extraction below is skipped for the async path.
267+
emitAsyncCallAndLower(printer)
268+
} else if signature.isThrows {
269+
printer.write(
270+
"let closure = Unmanaged<\(boxType)>.fromOpaque(boxPtr).takeUnretainedValue().closure"
271+
)
272+
// Route the caught JSException across the ABI like a throwing exported
273+
// function; the JS wrapper checks `tmpRetException` and rethrows.
274+
printer.write("do {")
275+
printer.indent {
276+
emitCallAndLower(printer)
277+
}
278+
printer.write("} catch let error {")
279+
printer.indent {
280+
printer.write("if let error = error.thrownValue.object {")
281+
printer.indent {
282+
printer.write("withExtendedLifetime(error) {")
283+
printer.indent {
284+
printer.write("_swift_js_throw(Int32(bitPattern: $0.id))")
285+
}
286+
printer.write("}")
287+
}
288+
printer.write("} else {")
289+
printer.indent {
290+
printer.write("let jsError = JSError(message: error.description)")
291+
printer.write("withExtendedLifetime(jsError.jsObject) {")
292+
printer.indent {
293+
printer.write("_swift_js_throw(Int32(bitPattern: $0.id))")
294+
}
295+
printer.write("}")
296+
}
297+
printer.write("}")
298+
if let catchPlaceholderStmt {
299+
printer.write(catchPlaceholderStmt)
300+
}
301+
}
302+
printer.write("}")
303+
} else {
304+
printer.write(
305+
"let closure = Unmanaged<\(boxType)>.fromOpaque(boxPtr).takeUnretainedValue().closure"
306+
)
307+
emitCallAndLower(printer)
308+
}
309+
}
310+
192311
return DeclSyntax(funcDecl)
193312
}
194313

@@ -200,6 +319,9 @@ public struct ClosureCodegen {
200319
guard !signatureAccessLevels.isEmpty else { return nil }
201320

202321
var decls: [DeclSyntax] = []
322+
// Async closures settle their JS `Promise` through the shared `_bjs_makePromise`
323+
// + per-type `Promise_resolve_<mangleR>` / `Promise_reject` mechanism emitted by
324+
// `ExportSwift`, so no closure-specific settlement helpers are needed here.
203325
for signature in signatureAccessLevels.keys.sorted(by: { $0.mangleName < $1.mangleName }) {
204326
let accessLevel = signatureAccessLevels[signature] ?? .internal
205327
decls.append(contentsOf: try renderClosureHelpers(signature, accessLevel: accessLevel))

Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -272,9 +272,11 @@ public struct ImportTS {
272272
}
273273
}
274274

275-
// Add exception check for ImportTS context (skipped for async, where
276-
// errors are funneled through the JS-side reject path)
277-
if !effects.isAsync && context == .importTS {
275+
// Rethrow a synchronous JS-side error into Swift (skipped for async, where
276+
// errors are funneled through the JS reject path). `.importTS` thunks are
277+
// always throwing; `.exportSwift` closure lifts only when the bridged
278+
// closure is `throws(JSException)`.
279+
if !effects.isAsync && (context == .importTS || effects.isThrows) {
278280
body.write("if let error = _swift_js_take_exception() { throw error }")
279281
}
280282
}
@@ -323,18 +325,25 @@ public struct ImportTS {
323325
let innerBody = body
324326
body = CodeFragmentPrinter()
325327

328+
// `_bjs_awaitPromise` is `throws(JSException)` (it surfaces a Promise
329+
// rejection as a Swift error). Async imports are always `throws(JSException)`,
330+
// so plain `try` is correct there. A non-throwing `async` closure lift has no
331+
// error channel, so use `try!` to trap on a rejection rather than emit an
332+
// unhandled `try` inside a non-throwing closure literal (which would not
333+
// compile).
334+
let tryKeyword = effects.isThrows ? "try" : "try!"
326335
let rejectFactory = "makeRejectClosure: { JSTypedClosure<(sending JSValue) -> Void>($0) }"
327336
if returnType == .void {
328337
let resolveFactory = "makeResolveClosure: { JSTypedClosure<() -> Void>($0) }"
329338
body.write(
330-
"try await _bjs_awaitPromise(\(resolveFactory), \(rejectFactory)) { resolveRef, rejectRef in"
339+
"\(tryKeyword) await _bjs_awaitPromise(\(resolveFactory), \(rejectFactory)) { resolveRef, rejectRef in"
331340
)
332341
} else {
333342
let resolveSwiftType = returnType.closureSwiftType
334343
let resolveFactory =
335344
"makeResolveClosure: { JSTypedClosure<(sending \(resolveSwiftType)) -> Void>($0) }"
336345
body.write(
337-
"let resolved = try await _bjs_awaitPromise(\(resolveFactory), \(rejectFactory)) { resolveRef, rejectRef in"
346+
"let resolved = \(tryKeyword) await _bjs_awaitPromise(\(resolveFactory), \(rejectFactory)) { resolveRef, rejectRef in"
338347
)
339348
}
340349
body.indent {

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,48 @@ public final class SwiftToSkeleton {
191191
}
192192

193193
let isAsync = functionType.effectSpecifiers?.asyncSpecifier != nil
194-
let isThrows = functionType.effectSpecifiers?.throwsClause != nil
194+
195+
// An async closure settles its JS `Promise` through the same
196+
// `_bjs_makePromise` + per-type `Promise_resolve_<mangled>` mechanism async
197+
// exported functions use, so its return type must be `isAsyncResolvable` too.
198+
// Unsupported types (associated-value enums, protocols, namespace enums, and
199+
// their Optional/Array/Dictionary compositions) are diagnosed with the same
200+
// message async functions use.
201+
if isAsync, !returnType.isAsyncResolvable {
202+
errors.append(
203+
DiagnosticError(
204+
node: functionType,
205+
message:
206+
"Returning '\(returnType.swiftType)' from an async closure is not yet supported",
207+
hint:
208+
"Return a type lowerable through the async resolve ABI "
209+
+ "(String/Int/Bool/Double/Float/raw-value or case-only enum/@JS struct/JSObject/Optional/Array/Dictionary), "
210+
+ "or make the closure non-async."
211+
)
212+
)
213+
return nil
214+
}
215+
216+
var isThrows = false
217+
if let throwsClause = functionType.effectSpecifiers?.throwsClause {
218+
// BridgeJS only bridges `throws(JSException)`; reject any other thrown type,
219+
// mirroring the function-level check in `collectEffects`.
220+
guard let thrownType = throwsClause.type,
221+
thrownType.trimmedDescription == "JSException"
222+
else {
223+
errors.append(
224+
DiagnosticError(
225+
node: throwsClause,
226+
message:
227+
"Only JSException is supported for thrown type of Swift closures, "
228+
+ "got \(throwsClause.type?.trimmedDescription ?? "unspecified")",
229+
hint: "Annotate the closure as `throws(JSException)`"
230+
)
231+
)
232+
return nil
233+
}
234+
isThrows = true
235+
}
195236

196237
return .closure(
197238
ClosureSignature(
@@ -1028,22 +1069,6 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
10281069
guard let type = resolvedType else {
10291070
continue // Skip unsupported types
10301071
}
1031-
if case .closure(let signature, _) = type {
1032-
if signature.isAsync {
1033-
diagnose(
1034-
node: param.type,
1035-
message: "Async is not supported for Swift closures yet."
1036-
)
1037-
continue
1038-
}
1039-
if signature.isThrows {
1040-
diagnose(
1041-
node: param.type,
1042-
message: "Throws is not supported for Swift closures yet."
1043-
)
1044-
continue
1045-
}
1046-
}
10471072
if case .nullable(let wrappedType, _) = type, wrappedType.isOptional {
10481073
diagnoseNestedOptional(node: param.type, type: param.type.trimmedDescription)
10491074
continue

Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -893,8 +893,12 @@ public struct BridgeJSLink {
893893
functionName: String
894894
) throws -> [String] {
895895
let printer = CodeFragmentPrinter()
896+
// Sync closures read the synchronous `tmpRetException` and are always generated
897+
// as throwing on the JS side. Async closures instead return the `Promise`
898+
// produced by `invoke_swift_closure_*` (JS sees a `(a) => Promise<R>` function),
899+
// so rejection propagates through that Promise rather than `tmpRetException`.
896900
let builder = ExportedThunkBuilder(
897-
effects: Effects(isAsync: false, isThrows: true),
901+
effects: Effects(isAsync: signature.isAsync, isThrows: signature.isAsync ? signature.isThrows : true),
898902
hasDirectAccessToSwiftClass: false,
899903
intrinsicRegistry: intrinsicRegistry
900904
)
@@ -3743,7 +3747,11 @@ extension BridgeType {
37433747
let paramTypes = signature.parameters.enumerated().map { index, param in
37443748
"arg\(index): \(param.tsType)"
37453749
}.joined(separator: ", ")
3746-
return "(\(paramTypes)) => \(signature.returnType.tsType)"
3750+
// An async closure crosses the boundary as a JS `Promise`, so its TS type is
3751+
// `(...) => Promise<R>` (a rejection models a throw).
3752+
let returnTS =
3753+
signature.isAsync ? "Promise<\(signature.returnType.tsType)>" : signature.returnType.tsType
3754+
return "(\(paramTypes)) => \(returnTS)"
37473755
case .array(let elementType):
37483756
let inner = elementType.tsType
37493757
if inner.contains("|") || inner.contains("=>") {

0 commit comments

Comments
 (0)