Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 0 additions & 39 deletions Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -610,37 +610,6 @@ private enum ExportSwiftConstants {
static let supportedRawTypes = SwiftEnumRawType.supportedTypeNames
}

/// Warns about Swift closures handed to JavaScript with an `async throws(JSException)` signature.
/// Captureless closure values lose their thrown error at runtime due to a Swift compiler bug.
private func asyncThrowsClosureWarning(node: some SyntaxProtocol) -> DiagnosticError {
DiagnosticError(
node: node,
message:
"async throwing closures passed to JavaScript may lose thrown errors due to a Swift compiler bug "
+ "(swiftlang/swift#89320) unless the closure value captures state",
hint:
"Pass a closure that captures state, or see the BridgeJS closure documentation for details",
severity: .warning
)
}

extension BridgeType {
fileprivate var containsAsyncThrowsClosure: Bool {
switch self {
case .closure(let signature, _):
return signature.isAsync && signature.isThrows
case .nullable(let wrapped, _):
return wrapped.containsAsyncThrowsClosure
case .array(let element):
return element.containsAsyncThrowsClosure
case .dictionary(let value):
return value.containsAsyncThrowsClosure
default:
return false
}
}
}

extension AttributeSyntax {
/// The attribute name as text when it is a simple identifier (e.g. "JS", "JSFunction").
/// Prefer this over `attributeName.trimmedDescription` for name checks to avoid unnecessary string work.
Expand Down Expand Up @@ -1233,9 +1202,6 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {

guard let type = resolvedType else { return nil }
returnType = type
if returnType.containsAsyncThrowsClosure {
errors.append(asyncThrowsClosureWarning(node: returnClause.type))
}
} else {
returnType = .void
}
Expand Down Expand Up @@ -2895,11 +2861,6 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
guard let bridgeType = withLookupErrors({ parent.lookupType(for: type, errors: &$0) }) else {
return nil
}
if case .closure(let signature, useJSTypedClosure: true) = bridgeType,
signature.isAsync, signature.isThrows
{
errors.append(asyncThrowsClosureWarning(node: type))
}
let nameToken = param.secondName ?? param.firstName
let name = SwiftToSkeleton.normalizeIdentifier(nameToken.text)
let labelToken = param.secondName == nil ? nil : param.firstName
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,6 @@ const count = await fetchCount("/items"); // Promise<number>

**Cancellation is a non-goal.** There is no propagation between a Swift `Task` and a JavaScript `Promise` in either direction.

> 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.

## Lifetime and release()

A ``JSTypedClosure`` keeps the Swift closure alive and exposes a JavaScript function that calls into it. To avoid leaks and use-after-free:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,6 @@ Notes:
- The same `JavaScriptEventLoop.installGlobalExecutor()` requirement applies as for async functions; there is no special handling for closures.
- **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.

> 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.

## Supported Features

| Swift Feature | Status |
Expand All @@ -251,7 +249,7 @@ Notes:
| Optional types in closures | ✅ |
| Closure-typed `@JS` properties | ❌ |
| Async closures `(A) async -> B` | ✅ |
| Async throwing closures `(A) async throws(JSException) -> B` | ✅ (reject path of closures handed to JS pending [swiftlang/swift#89320](https://github.com/swiftlang/swift/issues/89320)) |
| Async throwing closures `(A) async throws(JSException) -> B` | ✅ (rejecting into JS requires Swift 6.3 or later) |
| Throwing closures `(A) throws(JSException) -> B` | ✅ |

## See Also
Expand Down
67 changes: 67 additions & 0 deletions Sources/JavaScriptKit/JSException.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,72 @@
/// }
/// ```
public struct JSException: Error, Equatable, CustomStringConvertible {
#if compiler(>=6.3)
/// Boxes the exception payload in a class so `JSException` stays within the direct
/// typed-error convention on wasm32. The boxed layout crashes the Swift 6.1 wasm IRGen and is unverified on 6.2,
/// so older compilers keep the original stored properties.
private final class Storage {
let thrownValue: JSValue
let description: String
let stack: String?

init(thrownValue: JSValue, description: String, stack: String?) {
self.thrownValue = thrownValue
self.description = description
self.stack = stack
}
}

/// The boxed payload of the exception.
///
/// Marked as `nonisolated(unsafe)` to satisfy `Sendable` requirement
/// from `Error` protocol.
private nonisolated(unsafe) let storage: Storage

/// The value thrown from JavaScript.
/// This can be any JavaScript value (error object, string, number, etc.).
public var thrownValue: JSValue {
return storage.thrownValue
}

/// A description of the exception.
public var description: String {
return storage.description
}

/// The stack trace of the exception.
public var stack: String? {
return storage.stack
}

/// Initializes a new JSException instance with a value thrown from JavaScript.
///
/// Only available within the package. This must be called on the thread where the exception object created.
/// The stringified representation is captured on the object owner thread to bring useful info
/// to the catching thread even if they are different threads.
@usableFromInline
package init(_ thrownValue: JSValue) {
if let errorObject = thrownValue.object, let stack = errorObject.stack.string {
self.storage = Storage(
thrownValue: thrownValue,
description: "JSException(\(stack))",
stack: stack
)
} else {
self.storage = Storage(
thrownValue: thrownValue,
description: "JSException(\(thrownValue))",
stack: nil
)
}
}

public static func == (lhs: JSException, rhs: JSException) -> Bool {
return lhs.storage.thrownValue == rhs.storage.thrownValue
&& lhs.storage.description == rhs.storage.description
&& lhs.storage.stack == rhs.storage.stack
}
#else
/// The value thrown from JavaScript.
/// This can be any JavaScript value (error object, string, number, etc.).
public var thrownValue: JSValue {
Expand Down Expand Up @@ -47,6 +113,7 @@ public struct JSException: Error, Equatable, CustomStringConvertible {
self.stack = nil
}
}
#endif

/// Initializes a new JavaScript `Error` instance with a message and prepare it to be thrown.
///
Expand Down
8 changes: 8 additions & 0 deletions Tests/BridgeJSRuntimeTests/ClosureAsyncAPIs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ import JavaScriptEventLoop
return AsyncRecorderState.lastRecorded
}

@JS func asyncThrowsClosureRejectSupported() -> Bool {
#if compiler(>=6.3)
return true
#else
return false
#endif
}

@JS func makeAsyncPointMaker() -> JSTypedClosure<(Double) async -> DataPoint> {
return JSTypedClosure { (seed: Double) async -> DataPoint in
await Task.yield()
Expand Down
11 changes: 11 additions & 0 deletions Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7333,6 +7333,17 @@ public func _bjs_lastRecordedValue() -> Void {
#endif
}

@_expose(wasm, "bjs_asyncThrowsClosureRejectSupported")
@_cdecl("bjs_asyncThrowsClosureRejectSupported")
public func _bjs_asyncThrowsClosureRejectSupported() -> Int32 {
#if arch(wasm32)
let ret = asyncThrowsClosureRejectSupported()
return ret.bridgeJSLowerReturn()
#else
fatalError("Only available on WebAssembly")
#endif
}

@_expose(wasm, "bjs_makeAsyncPointMaker")
@_cdecl("bjs_makeAsyncPointMaker")
public func _bjs_makeAsyncPointMaker() -> Int32 {
Expand Down
17 changes: 17 additions & 0 deletions Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json
Original file line number Diff line number Diff line change
Expand Up @@ -11632,6 +11632,23 @@
}
}
},
{
"abiName" : "bjs_asyncThrowsClosureRejectSupported",
"effects" : {
"isAsync" : false,
"isStatic" : false,
"isThrows" : false
},
"name" : "asyncThrowsClosureRejectSupported",
"parameters" : [

],
"returnType" : {
"bool" : {

}
}
},
{
"abiName" : "bjs_makeAsyncPointMaker",
"effects" : {
Expand Down
Loading
Loading