Skip to content

Commit c020734

Browse files
committed
BridgeJS: Warn on async throwing closures passed to JavaScript
1 parent fa13f45 commit c020734

7 files changed

Lines changed: 197 additions & 10 deletions

File tree

Plugins/BridgeJS/Sources/BridgeJSCore/Misc.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,14 +137,21 @@ import SwiftSyntax
137137
import class Foundation.ProcessInfo
138138

139139
public struct DiagnosticError: Error {
140+
public enum Severity: String, Sendable {
141+
case error
142+
case warning
143+
}
144+
140145
public let node: Syntax
141146
public let message: String
142147
public let hint: String?
148+
public let severity: Severity
143149

144-
public init(node: some SyntaxProtocol, message: String, hint: String? = nil) {
150+
public init(node: some SyntaxProtocol, message: String, hint: String? = nil, severity: Severity = .error) {
145151
self.node = Syntax(node)
146152
self.message = message
147153
self.hint = hint
154+
self.severity = severity
148155
}
149156

150157
/// Formats the diagnostic error as a string.
@@ -166,12 +173,14 @@ public struct DiagnosticError: Error {
166173

167174
let lineNumberWidth = max(3, String(lines.count).count)
168175

176+
let severityLabel = severity.rawValue
177+
let severityColor = severity == .warning ? ANSI.boldYellow : ANSI.boldRed
169178
let header: String = {
170179
guard colorize else {
171-
return "\(displayFileName):\(startLocation.line):\(startLocation.column): error: \(message)"
180+
return "\(displayFileName):\(startLocation.line):\(startLocation.column): \(severityLabel): \(message)"
172181
}
173182
return
174-
"\(displayFileName):\(startLocation.line):\(startLocation.column): \(ANSI.boldRed)error: \(ANSI.boldDefault)\(message)\(ANSI.reset)"
183+
"\(displayFileName):\(startLocation.line):\(startLocation.column): \(severityColor)\(severityLabel): \(ANSI.boldDefault)\(message)\(ANSI.reset)"
175184
}()
176185

177186
let highlightStartColumn = min(max(1, startLocation.column), mainLine.utf8.count + 1)
@@ -227,8 +236,8 @@ public struct DiagnosticError: Error {
227236
let pointerSpacing = max(0, highlightStartColumn - 1)
228237
let pointerMessage: String = {
229238
let pointer = String(repeating: " ", count: pointerSpacing) + "`- "
230-
guard colorize else { return pointer + "error: \(message)" }
231-
return pointer + "\(ANSI.boldRed)error: \(ANSI.boldDefault)\(message)\(ANSI.reset)"
239+
guard colorize else { return pointer + "\(severityLabel): \(message)" }
240+
return pointer + "\(severityColor)\(severityLabel): \(ANSI.boldDefault)\(message)\(ANSI.reset)"
232241
}()
233242
descriptionParts.append(
234243
Self.formatSourceLine(
@@ -304,6 +313,7 @@ public struct BridgeJSCoreDiagnosticError: Swift.Error, CustomStringConvertible
304313
private enum ANSI {
305314
static let reset = "\u{001B}[0;0m"
306315
static let boldRed = "\u{001B}[1;31m"
316+
static let boldYellow = "\u{001B}[1;33m"
307317
static let boldDefault = "\u{001B}[1;39m"
308318
static let cyan = "\u{001B}[0;36m"
309319
static let underline = "\u{001B}[4;39m"

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ public final class SwiftToSkeleton {
2424
private var sourceFiles: [(sourceFile: SourceFileSyntax, inputFilePath: String)] = []
2525
private var usedExternalModules = Set<String>()
2626

27+
/// Non-fatal diagnostics collected during `finalize()`. These do not fail the build.
28+
public private(set) var warnings: [(file: String, diagnostic: DiagnosticError)] = []
29+
2730
public init(
2831
progress: ProgressReporting,
2932
moduleName: String,
@@ -87,10 +90,15 @@ public final class SwiftToSkeleton {
8790
)
8891
importCollector.walk(sourceFile)
8992

90-
let importErrorsFatal = importCollector.errors.filter { !$0.message.contains("Unsupported type '") }
91-
if !exportCollector.errors.isEmpty || !importErrorsFatal.isEmpty {
93+
let exportErrors = exportCollector.errors.filter { $0.severity == .error }
94+
let importErrorsFatal = importCollector.errors.filter {
95+
$0.severity == .error && !$0.message.contains("Unsupported type '")
96+
}
97+
let fileWarnings = (exportCollector.errors + importCollector.errors).filter { $0.severity == .warning }
98+
warnings.append(contentsOf: fileWarnings.map { (file: inputFilePath, diagnostic: $0) })
99+
if !exportErrors.isEmpty || !importErrorsFatal.isEmpty {
92100
perSourceErrors.append(
93-
(inputFilePath: inputFilePath, errors: exportCollector.errors + importErrorsFatal)
101+
(inputFilePath: inputFilePath, errors: exportErrors + importErrorsFatal)
94102
)
95103
}
96104

@@ -602,6 +610,37 @@ private enum ExportSwiftConstants {
602610
static let supportedRawTypes = SwiftEnumRawType.supportedTypeNames
603611
}
604612

613+
/// Warns about Swift closures handed to JavaScript with an `async throws(JSException)` signature.
614+
/// Captureless closure values lose their thrown error at runtime due to a Swift compiler bug.
615+
private func asyncThrowsClosureWarning(node: some SyntaxProtocol) -> DiagnosticError {
616+
DiagnosticError(
617+
node: node,
618+
message:
619+
"async throwing closures passed to JavaScript may lose thrown errors due to a Swift compiler bug "
620+
+ "(swiftlang/swift#89320) unless the closure value captures state",
621+
hint:
622+
"Pass a closure that captures state, or see the BridgeJS closure documentation for details",
623+
severity: .warning
624+
)
625+
}
626+
627+
extension BridgeType {
628+
fileprivate var containsAsyncThrowsClosure: Bool {
629+
switch self {
630+
case .closure(let signature, _):
631+
return signature.isAsync && signature.isThrows
632+
case .nullable(let wrapped, _):
633+
return wrapped.containsAsyncThrowsClosure
634+
case .array(let element):
635+
return element.containsAsyncThrowsClosure
636+
case .dictionary(let value):
637+
return value.containsAsyncThrowsClosure
638+
default:
639+
return false
640+
}
641+
}
642+
}
643+
605644
extension AttributeSyntax {
606645
/// The attribute name as text when it is a simple identifier (e.g. "JS", "JSFunction").
607646
/// Prefer this over `attributeName.trimmedDescription` for name checks to avoid unnecessary string work.
@@ -1194,6 +1233,9 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
11941233

11951234
guard let type = resolvedType else { return nil }
11961235
returnType = type
1236+
if returnType.containsAsyncThrowsClosure {
1237+
errors.append(asyncThrowsClosureWarning(node: returnClause.type))
1238+
}
11971239
} else {
11981240
returnType = .void
11991241
}
@@ -2853,6 +2895,11 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
28532895
guard let bridgeType = withLookupErrors({ parent.lookupType(for: type, errors: &$0) }) else {
28542896
return nil
28552897
}
2898+
if case .closure(let signature, useJSTypedClosure: true) = bridgeType,
2899+
signature.isAsync, signature.isThrows
2900+
{
2901+
errors.append(asyncThrowsClosureWarning(node: type))
2902+
}
28562903
let nameToken = param.secondName ?? param.firstName
28572904
let name = SwiftToSkeleton.normalizeIdentifier(nameToken.text)
28582905
let labelToken = param.secondName == nil ? nil : param.firstName

Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,9 @@ import BridgeJSUtilities
201201
let skeleton = try withSpan("SwiftToSkeleton.finalize") {
202202
return try swiftToSkeleton.finalize()
203203
}
204+
for (file, diagnostic) in swiftToSkeleton.warnings {
205+
printStderr(diagnostic.formattedDescription(fileName: file))
206+
}
204207

205208
var exporter: ExportSwift?
206209
if let skeleton = skeleton.exported {

Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ import ArgumentParser
6060
swiftToSkeleton.addSourceFile(sourceFile, inputFilePath: inputFile)
6161
}
6262
let skeleton = try swiftToSkeleton.finalize()
63+
for (file, diagnostic) in swiftToSkeleton.warnings {
64+
FileHandle.standardError.write(
65+
Data((diagnostic.formattedDescription(fileName: file, colorize: false) + "\n").utf8)
66+
)
67+
}
6368
let encoder = JSONEncoder()
6469
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
6570
let skeletonData = try encoder.encode(skeleton)
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import Foundation
2+
import SwiftParser
3+
import SwiftSyntax
4+
import Testing
5+
6+
@testable import BridgeJSCore
7+
@testable import BridgeJSSkeleton
8+
9+
@Suite struct ClosureAsyncThrowsWarningTests {
10+
@Test
11+
func warnsOnTypedAsyncThrowsClosureReturn() throws {
12+
let result = try resolveApp(
13+
source: """
14+
@JS public func makeParser() -> JSTypedClosure<(String) async throws(JSException) -> String> {
15+
fatalError()
16+
}
17+
"""
18+
)
19+
#expect(result.warnings.count == 1)
20+
let warning = try #require(result.warnings.first)
21+
#expect(warning.diagnostic.severity == .warning)
22+
#expect(warning.diagnostic.message.contains("swiftlang/swift#89320"))
23+
}
24+
25+
@Test
26+
func warnsOnPlainAsyncThrowsClosureReturn() throws {
27+
let result = try resolveApp(
28+
source: """
29+
@JS public func makeParser() -> (String) async throws(JSException) -> String {
30+
fatalError()
31+
}
32+
"""
33+
)
34+
#expect(result.warnings.count == 1)
35+
#expect(result.warnings.first?.diagnostic.severity == .warning)
36+
}
37+
38+
@Test
39+
func doesNotWarnOnAsyncThrowsClosureParameter() throws {
40+
let result = try resolveApp(
41+
source: """
42+
@JS public func process(_ cb: (String) async throws(JSException) -> String) {}
43+
"""
44+
)
45+
#expect(result.warnings.isEmpty)
46+
}
47+
48+
@Test
49+
func doesNotWarnOnNonThrowingAsyncClosureReturn() throws {
50+
let result = try resolveApp(
51+
source: """
52+
@JS public func makeParser() -> JSTypedClosure<(String) async -> String> {
53+
fatalError()
54+
}
55+
"""
56+
)
57+
#expect(result.warnings.isEmpty)
58+
}
59+
60+
@Test
61+
func doesNotWarnOnSyncThrowsClosureReturn() throws {
62+
let result = try resolveApp(
63+
source: """
64+
@JS public func makeParser() -> JSTypedClosure<(String) throws(JSException) -> String> {
65+
fatalError()
66+
}
67+
"""
68+
)
69+
#expect(result.warnings.isEmpty)
70+
}
71+
72+
@Test
73+
func warnsOnTypedAsyncThrowsClosureImportParameter() throws {
74+
let result = try resolveApp(
75+
source: """
76+
@JSFunction func register(
77+
_ cb: JSTypedClosure<(String) async throws(JSException) -> String>
78+
) throws(JSException)
79+
"""
80+
)
81+
#expect(result.warnings.count == 1)
82+
#expect(result.warnings.first?.diagnostic.severity == .warning)
83+
}
84+
85+
@Test
86+
func warningDoesNotFailSkeletonResolution() throws {
87+
let result = try resolveApp(
88+
source: """
89+
@JS public func makeParser() -> JSTypedClosure<(String) async throws(JSException) -> String> {
90+
fatalError()
91+
}
92+
"""
93+
)
94+
let function = try #require(result.skeleton.exported?.functions.first(where: { $0.name == "makeParser" }))
95+
guard case .closure(let signature, true) = function.returnType else {
96+
Issue.record("Expected typed closure return type, got \(function.returnType)")
97+
return
98+
}
99+
#expect(signature.isAsync)
100+
#expect(signature.isThrows)
101+
}
102+
103+
// MARK: - Utilities
104+
105+
private struct Resolution {
106+
let skeleton: BridgeJSSkeleton
107+
let warnings: [(file: String, diagnostic: DiagnosticError)]
108+
}
109+
110+
private func resolveApp(source appSource: String) throws -> Resolution {
111+
let swiftAPI = SwiftToSkeleton(
112+
progress: .silent,
113+
moduleName: "App",
114+
exposeToGlobal: false,
115+
externalModuleIndex: ExternalModuleIndex(dependencies: [])
116+
)
117+
let sourceFile = Parser.parse(source: appSource)
118+
swiftAPI.addSourceFile(sourceFile, inputFilePath: "App.swift")
119+
let skeleton = try swiftAPI.finalize()
120+
return Resolution(skeleton: skeleton, warnings: swiftAPI.warnings)
121+
}
122+
}

Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Bringing-Swift-Closures-to-JavaScript.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ const count = await fetchCount("/items"); // Promise<number>
7878
7979
**Cancellation is a non-goal.** There is no propagation between a Swift `Task` and a JavaScript `Promise` in either direction.
8080

81-
> Note: The reject path of async throwing typed closures is affected by a Swift compiler bug ([swiftlang/swift#89320](https://github.com/swiftlang/swift/issues/89320)). See <doc:Exporting-Swift-Closure> for details.
81+
> Note: The reject path of async throwing typed closures is affected by a Swift compiler bug ([swiftlang/swift#89320](https://github.com/swiftlang/swift/issues/89320)); BridgeJS emits a build-time warning for this signature. See <doc:Exporting-Swift-Closure> for details.
8282
8383
## Lifetime and release()
8484

Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Exporting-Swift/Exporting-Swift-Closure.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ Notes:
239239
- The same `JavaScriptEventLoop.installGlobalExecutor()` requirement applies as for async functions; there is no special handling for closures.
240240
- **Cancellation is a non-goal.** There is no propagation between a Swift `Task` and a JavaScript `Promise` in either direction; cancelling one side does not cancel the other.
241241
242-
> Warning: When an async throwing closure handed to JavaScript throws, the error is currently lost instead of rejecting the `Promise` with it, due to a Swift compiler bug on `wasm32` ([swiftlang/swift#89320](https://github.com/swiftlang/swift/issues/89320), fix in progress in [swiftlang/swift#89715](https://github.com/swiftlang/swift/pull/89715)). Closures that capture state are unaffected, as are throwing JavaScript callbacks passed into Swift.
242+
> Warning: When an async throwing closure handed to JavaScript throws, the error is currently lost instead of rejecting the `Promise` with it, due to a Swift compiler bug on `wasm32` ([swiftlang/swift#89320](https://github.com/swiftlang/swift/issues/89320), fix in progress in [swiftlang/swift#89715](https://github.com/swiftlang/swift/pull/89715)). Closures that capture state are unaffected, as are throwing JavaScript callbacks passed into Swift. BridgeJS emits a build-time warning for this signature.
243243
244244
## Supported Features
245245

0 commit comments

Comments
 (0)