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,7 +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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ 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

Expand All @@ -251,7 +251,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` | ✅ |
| Throwing closures `(A) throws(JSException) -> B` | ✅ |

## See Also
Expand Down
64 changes: 49 additions & 15 deletions Sources/JavaScriptKit/JSException.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,38 +13,66 @@
/// }
/// ```
public struct JSException: Error, Equatable, CustomStringConvertible {
/// The value thrown from JavaScript.
/// This can be any JavaScript value (error object, string, number, etc.).
public var thrownValue: JSValue {
return _thrownValue
/// Boxes the exception payload in a class so `JSException` stays within the direct
/// typed-error convention on wasm32.
private final class Storage {
/// The actual JavaScript value that was thrown.
let thrownValue: JSValue

/// A description of the exception.
let description: String

/// The stack trace of the exception.
let stack: String?

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

/// The actual JavaScript value that was thrown.
/// The boxed payload of the exception.
///
/// Marked as `nonisolated(unsafe)` to satisfy `Sendable` requirement
/// from `Error` protocol.
private nonisolated(unsafe) let _thrownValue: JSValue
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 let description: String
public var description: String {
return storage.description
}

/// The stack trace of the exception.
public let stack: String?
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) {
self._thrownValue = thrownValue
// Capture the stringified representation on the object owner thread
// to bring useful info to the catching thread even if they are different threads.
if let errorObject = thrownValue.object, let stack = errorObject.stack.string {
self.description = "JSException(\(stack))"
self.stack = stack
self.storage = Storage(
thrownValue: thrownValue,
description: "JSException(\(stack))",
stack: stack
)
} else {
self.description = "JSException(\(thrownValue))"
self.stack = nil
self.storage = Storage(
thrownValue: thrownValue,
description: "JSException(\(thrownValue))",
stack: nil
)
}
}

Expand All @@ -55,4 +83,10 @@ public struct JSException: Error, Equatable, CustomStringConvertible {
public init(message: String) {
self.init(JSError(message: message).jsValue)
}

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
}
}
6 changes: 0 additions & 6 deletions Tests/BridgeJSRuntimeTests/JavaScript/ClosureAsyncTests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,6 @@ export async function runJsClosureAsyncTests(exports) {
assert.equal(await parsed, "parsed:42");
assert.equal(await parser("-7"), "parsed:-7");

// Blocked by swiftlang/swift#89320 (wasm32 typed-throws async miscompile for captureless closures); re-enable once swiftlang/swift#89715 lands.
const ASYNC_THROWS_CLOSURE_REJECT_BLOCKED = true;
if (!ASYNC_THROWS_CLOSURE_REJECT_BLOCKED) {
let directionBReject = null;
try {
await parser("not-a-number");
Expand All @@ -57,7 +54,6 @@ export async function runJsClosureAsyncTests(exports) {
}
assert.notEqual(directionBReject, null);
assert.equal(directionBReject.message, "AsyncParseError: not-a-number");
}

assert.equal(await parser("100"), "parsed:100");

Expand All @@ -72,7 +68,6 @@ export async function runJsClosureAsyncTests(exports) {
assert.equal(await recorded, undefined);
assert.equal(exports.lastRecordedValue(), "logged-value");

if (!ASYNC_THROWS_CLOSURE_REJECT_BLOCKED) {
let voidReject = null;
try {
await recorder("boom");
Expand All @@ -82,7 +77,6 @@ export async function runJsClosureAsyncTests(exports) {
}
assert.notEqual(voidReject, null);
assert.equal(voidReject.message, "AsyncRecorderError");
}

const payloadLoader = exports.makeAsyncPayloadLoader();
const payloadPromise = payloadLoader(true);
Expand Down
12 changes: 12 additions & 0 deletions Tests/JavaScriptEventLoopTests/JSClosure+AsyncTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ class JSClosureAsyncTests: XCTestCase {
XCTAssertEqual(result, 42.0)
}

func testAsyncClosureReject() async throws {
let closure = JSClosure.async { (_) async throws(JSException) -> JSValue in
throw JSException(message: "AsyncClosureRejected")
}.jsValue
let result = await JSPromise(from: closure.function!())!.result
guard case .failure(let rejectedValue) = result else {
XCTFail("Expected the async closure promise to reject, got \(result)")
return
}
XCTAssertEqual(rejectedValue.object?.message.string, "AsyncClosureRejected")
}

func testAsyncClosureWithPriority() async throws {
let priority = UnsafeSendableBox<TaskPriority?>(nil)
let closure = JSClosure.async(priority: .high) { _ in
Expand Down
Loading