diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index daac3c50f..1c8dae632 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,11 +15,11 @@ jobs: wasi-backend: Node - os: ubuntu-22.04 toolchain: - download-url: https://download.swift.org/development/ubuntu2204/swift-DEVELOPMENT-SNAPSHOT-2024-10-30-a/swift-DEVELOPMENT-SNAPSHOT-2024-10-30-a-ubuntu22.04.tar.gz + download-url: https://download.swift.org/development/ubuntu2204/swift-DEVELOPMENT-SNAPSHOT-2025-02-26-a/swift-DEVELOPMENT-SNAPSHOT-2025-02-26-a-ubuntu22.04.tar.gz wasi-backend: Node - os: ubuntu-22.04 toolchain: - download-url: https://download.swift.org/development/ubuntu2204/swift-DEVELOPMENT-SNAPSHOT-2024-10-30-a/swift-DEVELOPMENT-SNAPSHOT-2024-10-30-a-ubuntu22.04.tar.gz + download-url: https://download.swift.org/development/ubuntu2204/swift-DEVELOPMENT-SNAPSHOT-2025-02-26-a/swift-DEVELOPMENT-SNAPSHOT-2025-02-26-a-ubuntu22.04.tar.gz wasi-backend: Node runs-on: ${{ matrix.entry.os }} @@ -52,8 +52,6 @@ jobs: strategy: matrix: include: - - os: macos-14 - xcode: Xcode_15.2 - os: macos-15 xcode: Xcode_16 runs-on: ${{ matrix.os }} @@ -71,7 +69,7 @@ jobs: entry: - os: ubuntu-22.04 toolchain: - download-url: https://download.swift.org/development/ubuntu2204/swift-DEVELOPMENT-SNAPSHOT-2024-10-30-a/swift-DEVELOPMENT-SNAPSHOT-2024-10-30-a-ubuntu22.04.tar.gz + download-url: https://download.swift.org/development/ubuntu2204/swift-DEVELOPMENT-SNAPSHOT-2025-02-26-a/swift-DEVELOPMENT-SNAPSHOT-2025-02-26-a-ubuntu22.04.tar.gz steps: - uses: actions/checkout@v4 - uses: ./.github/actions/install-swift diff --git a/IntegrationTests/TestSuites/Sources/ConcurrencyTests/main.swift b/IntegrationTests/TestSuites/Sources/ConcurrencyTests/main.swift index ece58b317..1f0764e14 100644 --- a/IntegrationTests/TestSuites/Sources/ConcurrencyTests/main.swift +++ b/IntegrationTests/TestSuites/Sources/ConcurrencyTests/main.swift @@ -48,7 +48,7 @@ func entrypoint() async throws { resolve(.failure(.number(3))) }) let error = try await expectAsyncThrow(await p.value) - let jsValue = try expectCast(error, to: JSValue.self) + let jsValue = try expectCast(error, to: JSException.self).thrownValue try expectEqual(jsValue, 3) try await expectEqual(p.result, .failure(.number(3))) } @@ -157,7 +157,7 @@ func entrypoint() async throws { ) } let promise2 = promise.then { _ in - throw JSError(message: "should not succeed") + throw MessageError("Should not be called", file: #file, line: #line, column: #column) } failure: { err in return err } diff --git a/IntegrationTests/TestSuites/Sources/PrimaryTests/UnitTestUtils.swift b/IntegrationTests/TestSuites/Sources/PrimaryTests/UnitTestUtils.swift index c4f9a9fb1..0d51c6ff5 100644 --- a/IntegrationTests/TestSuites/Sources/PrimaryTests/UnitTestUtils.swift +++ b/IntegrationTests/TestSuites/Sources/PrimaryTests/UnitTestUtils.swift @@ -110,7 +110,7 @@ func expectThrow(_ body: @autoclosure () throws -> T, file: StaticString = #f throw MessageError("Expect to throw an exception", file: file, line: line, column: column) } -func wrapUnsafeThrowableFunction(_ body: @escaping () -> Void, file: StaticString = #file, line: UInt = #line, column: UInt = #column) throws -> Error { +func wrapUnsafeThrowableFunction(_ body: @escaping () -> Void, file: StaticString = #file, line: UInt = #line, column: UInt = #column) throws -> JSValue { JSObject.global.callThrowingClosure.function!(JSClosure { _ in body() return .undefined diff --git a/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift b/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift index 67a51aa2e..12cc91cc9 100644 --- a/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift +++ b/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift @@ -263,8 +263,8 @@ try test("Closure Lifetime") { let c1Line = #line + 1 let c1 = JSClosure { $0[0] } c1.release() - let error = try expectThrow(try evalClosure.throws(c1, JSValue.number(42.0))) as! JSValue - try expect("Error message should contains definition location", error.description.hasSuffix("PrimaryTests/main.swift:\(c1Line)")) + let error = try expectThrow(try evalClosure.throws(c1, JSValue.number(42.0))) as! JSException + try expect("Error message should contains definition location", error.thrownValue.description.hasSuffix("PrimaryTests/main.swift:\(c1Line)")) } #endif @@ -275,8 +275,8 @@ try test("Closure Lifetime") { do { let c1 = JSClosure { _ in fatalError("Crash while closure evaluation") } - let error = try expectThrow(try evalClosure.throws(c1)) as! JSValue - try expectEqual(error.description, "RuntimeError: unreachable") + let error = try expectThrow(try evalClosure.throws(c1)) as! JSException + try expectEqual(error.thrownValue.description, "RuntimeError: unreachable") } } @@ -770,32 +770,32 @@ try test("Exception") { // MARK: Throwing method calls let error1 = try expectThrow(try prop_9.object!.throwing.func1!()) - try expectEqual(error1 is JSValue, true) - let errorObject = JSError(from: error1 as! JSValue) + try expectEqual(error1 is JSException, true) + let errorObject = JSError(from: (error1 as! JSException).thrownValue) try expectNotNil(errorObject) let error2 = try expectThrow(try prop_9.object!.throwing.func2!()) - try expectEqual(error2 is JSValue, true) - let errorString = try expectString(error2 as! JSValue) + try expectEqual(error2 is JSException, true) + let errorString = try expectString((error2 as! JSException).thrownValue) try expectEqual(errorString, "String Error") let error3 = try expectThrow(try prop_9.object!.throwing.func3!()) - try expectEqual(error3 is JSValue, true) - let errorNumber = try expectNumber(error3 as! JSValue) + try expectEqual(error3 is JSException, true) + let errorNumber = try expectNumber((error3 as! JSException).thrownValue) try expectEqual(errorNumber, 3.0) // MARK: Simple function calls let error4 = try expectThrow(try prop_9.func1.function!.throws()) - try expectEqual(error4 is JSValue, true) - let errorObject2 = JSError(from: error4 as! JSValue) + try expectEqual(error4 is JSException, true) + let errorObject2 = JSError(from: (error4 as! JSException).thrownValue) try expectNotNil(errorObject2) // MARK: Throwing constructor call let Animal = JSObject.global.Animal.function! _ = try Animal.throws.new("Tama", 3, true) let ageError = try expectThrow(try Animal.throws.new("Tama", -3, true)) - try expectEqual(ageError is JSValue, true) - let errorObject3 = JSError(from: ageError as! JSValue) + try expectEqual(ageError is JSException, true) + let errorObject3 = JSError(from: (ageError as! JSException).thrownValue) try expectNotNil(errorObject3) } @@ -824,18 +824,15 @@ try test("Unhandled Exception") { // MARK: Throwing method calls let error1 = try wrapUnsafeThrowableFunction { _ = prop_9.object!.func1!() } - try expectEqual(error1 is JSValue, true) - let errorObject = JSError(from: error1 as! JSValue) + let errorObject = JSError(from: error1) try expectNotNil(errorObject) let error2 = try wrapUnsafeThrowableFunction { _ = prop_9.object!.func2!() } - try expectEqual(error2 is JSValue, true) - let errorString = try expectString(error2 as! JSValue) + let errorString = try expectString(error2) try expectEqual(errorString, "String Error") let error3 = try wrapUnsafeThrowableFunction { _ = prop_9.object!.func3!() } - try expectEqual(error3 is JSValue, true) - let errorNumber = try expectNumber(error3 as! JSValue) + let errorNumber = try expectNumber(error3) try expectEqual(errorNumber, 3.0) } diff --git a/Makefile b/Makefile index 1b653315c..88f4e0795 100644 --- a/Makefile +++ b/Makefile @@ -21,11 +21,18 @@ test: CONFIGURATION=release SWIFT_BUILD_FLAGS="$(SWIFT_BUILD_FLAGS)" $(MAKE) test && \ CONFIGURATION=release SWIFT_BUILD_FLAGS="$(SWIFT_BUILD_FLAGS) -Xswiftc -DJAVASCRIPTKIT_WITHOUT_WEAKREFS" $(MAKE) test +TEST_RUNNER := node --experimental-wasi-unstable-preview1 scripts/test-harness.mjs .PHONY: unittest unittest: @echo Running unit tests swift build --build-tests -Xswiftc -Xclang-linker -Xswiftc -mexec-model=reactor -Xlinker --export-if-defined=main -Xlinker --export-if-defined=__main_argc_argv --static-swift-stdlib -Xswiftc -static-stdlib $(SWIFT_BUILD_FLAGS) - node --experimental-wasi-unstable-preview1 scripts/test-harness.mjs ./.build/debug/JavaScriptKitPackageTests.wasm +# Swift 6.1 and later uses .xctest for XCTest bundle but earliers used .wasm +# See https://github.com/swiftlang/swift-package-manager/pull/8254 + if [ -f .build/debug/JavaScriptKitPackageTests.xctest ]; then \ + $(TEST_RUNNER) .build/debug/JavaScriptKitPackageTests.xctest; \ + else \ + $(TEST_RUNNER) .build/debug/JavaScriptKitPackageTests.wasm; \ + fi .PHONY: benchmark_setup benchmark_setup: diff --git a/Package.swift b/Package.swift index f21a95cb5..4d4634b88 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.8 +// swift-tools-version:6.0 import PackageDescription diff --git a/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift b/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift index 765746bb1..07eec2cd2 100644 --- a/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift +++ b/Sources/JavaScriptEventLoop/JavaScriptEventLoop.swift @@ -40,17 +40,17 @@ public final class JavaScriptEventLoop: SerialExecutor, @unchecked Sendable { /// A function that queues a given closure as a microtask into JavaScript event loop. /// See also: https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide - public var queueMicrotask: @Sendable (@escaping () -> Void) -> Void + public var queueMicrotask: (@escaping () -> Void) -> Void /// A function that invokes a given closure after a specified number of milliseconds. - public var setTimeout: @Sendable (Double, @escaping () -> Void) -> Void + public var setTimeout: (Double, @escaping () -> Void) -> Void /// A mutable state to manage internal job queue /// Note that this should be guarded atomically when supporting multi-threaded environment. var queueState = QueueState() private init( - queueTask: @Sendable @escaping (@escaping () -> Void) -> Void, - setTimeout: @Sendable @escaping (Double, @escaping () -> Void) -> Void + queueTask: @escaping (@escaping () -> Void) -> Void, + setTimeout: @escaping (Double, @escaping () -> Void) -> Void ) { self.queueMicrotask = queueTask self.setTimeout = setTimeout @@ -102,7 +102,7 @@ public final class JavaScriptEventLoop: SerialExecutor, @unchecked Sendable { return eventLoop } - private static var didInstallGlobalExecutor = false + @MainActor private static var didInstallGlobalExecutor = false /// Set JavaScript event loop based executor to be the global executor /// Note that this should be called before any of the jobs are created. @@ -110,6 +110,12 @@ public final class JavaScriptEventLoop: SerialExecutor, @unchecked Sendable { /// introduced officially. See also [a draft proposal for custom /// executors](https://github.com/rjmccall/swift-evolution/blob/custom-executors/proposals/0000-custom-executors.md#the-default-global-concurrent-executor) public static func installGlobalExecutor() { + MainActor.assumeIsolated { + Self.installGlobalExecutorIsolated() + } + } + + @MainActor private static func installGlobalExecutorIsolated() { guard !didInstallGlobalExecutor else { return } #if compiler(>=5.9) @@ -218,7 +224,7 @@ public extension JSPromise { return JSValue.undefined }, failure: { - continuation.resume(throwing: $0) + continuation.resume(throwing: JSException($0)) return JSValue.undefined } ) @@ -227,7 +233,7 @@ public extension JSPromise { } /// Wait for the promise to complete, returning its result or exception as a Result. - var result: Result { + var result: JSPromise.Result { get async { await withUnsafeContinuation { [self] continuation in self.then( diff --git a/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift b/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift index 5110f60db..ac4769a82 100644 --- a/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift +++ b/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift @@ -426,14 +426,15 @@ public final class WebWorkerTaskExecutor: TaskExecutor { // MARK: Global Executor hack - private static var _mainThread: pthread_t? - private static var _swift_task_enqueueGlobal_hook_original: UnsafeMutableRawPointer? - private static var _swift_task_enqueueGlobalWithDelay_hook_original: UnsafeMutableRawPointer? - private static var _swift_task_enqueueGlobalWithDeadline_hook_original: UnsafeMutableRawPointer? + @MainActor private static var _mainThread: pthread_t? + @MainActor private static var _swift_task_enqueueGlobal_hook_original: UnsafeMutableRawPointer? + @MainActor private static var _swift_task_enqueueGlobalWithDelay_hook_original: UnsafeMutableRawPointer? + @MainActor private static var _swift_task_enqueueGlobalWithDeadline_hook_original: UnsafeMutableRawPointer? /// Install a global executor that forwards jobs from Web Worker threads to the main thread. /// /// This function must be called once before using the Web Worker task executor. + @MainActor public static func installGlobalExecutor() { #if canImport(wasi_pthread) && compiler(>=6.1) && _runtime(_multithreaded) // Ensure this function is called only once. diff --git a/Sources/JavaScriptEventLoopTestSupport/JavaScriptEventLoopTestSupport.swift b/Sources/JavaScriptEventLoopTestSupport/JavaScriptEventLoopTestSupport.swift index 64e6776d4..4c441f3c4 100644 --- a/Sources/JavaScriptEventLoopTestSupport/JavaScriptEventLoopTestSupport.swift +++ b/Sources/JavaScriptEventLoopTestSupport/JavaScriptEventLoopTestSupport.swift @@ -25,7 +25,9 @@ import JavaScriptEventLoop @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) @_cdecl("swift_javascriptkit_activate_js_executor_impl") func swift_javascriptkit_activate_js_executor_impl() { - JavaScriptEventLoop.installGlobalExecutor() + MainActor.assumeIsolated { + JavaScriptEventLoop.installGlobalExecutor() + } } #endif diff --git a/Sources/JavaScriptKit/BasicObjects/JSArray.swift b/Sources/JavaScriptKit/BasicObjects/JSArray.swift index 95d14c637..56345d085 100644 --- a/Sources/JavaScriptKit/BasicObjects/JSArray.swift +++ b/Sources/JavaScriptKit/BasicObjects/JSArray.swift @@ -2,9 +2,8 @@ /// class](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array) /// that exposes its properties in a type-safe and Swifty way. public class JSArray: JSBridgedClass { - public static var constructor: JSFunction? { _constructor } - @LazyThreadLocal(initialize: { JSObject.global.Array.function }) - private static var _constructor: JSFunction? + public static var constructor: JSFunction? { _constructor.wrappedValue } + private static let _constructor = LazyThreadLocal(initialize: { JSObject.global.Array.function }) static func isArray(_ object: JSObject) -> Bool { constructor!.isArray!(object).boolean! diff --git a/Sources/JavaScriptKit/BasicObjects/JSDate.swift b/Sources/JavaScriptKit/BasicObjects/JSDate.swift index da31aca06..c8a6623a1 100644 --- a/Sources/JavaScriptKit/BasicObjects/JSDate.swift +++ b/Sources/JavaScriptKit/BasicObjects/JSDate.swift @@ -8,9 +8,8 @@ */ public final class JSDate: JSBridgedClass { /// The constructor function used to create new `Date` objects. - public static var constructor: JSFunction? { _constructor } - @LazyThreadLocal(initialize: { JSObject.global.Date.function }) - private static var _constructor: JSFunction? + public static var constructor: JSFunction? { _constructor.wrappedValue } + private static let _constructor = LazyThreadLocal(initialize: { JSObject.global.Date.function }) /// The underlying JavaScript `Date` object. public let jsObject: JSObject diff --git a/Sources/JavaScriptKit/BasicObjects/JSError.swift b/Sources/JavaScriptKit/BasicObjects/JSError.swift index 559618e15..0f87d3c67 100644 --- a/Sources/JavaScriptKit/BasicObjects/JSError.swift +++ b/Sources/JavaScriptKit/BasicObjects/JSError.swift @@ -2,11 +2,10 @@ class](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) that exposes its properties in a type-safe way. */ -public final class JSError: Error, JSBridgedClass { +public final class JSError: JSBridgedClass { /// The constructor function used to create new JavaScript `Error` objects. - public static var constructor: JSFunction? { _constructor } - @LazyThreadLocal(initialize: { JSObject.global.Error.function }) - private static var _constructor: JSFunction? + public static var constructor: JSFunction? { _constructor.wrappedValue } + private static let _constructor = LazyThreadLocal(initialize: { JSObject.global.Error.function }) /// The underlying JavaScript `Error` object. public let jsObject: JSObject diff --git a/Sources/JavaScriptKit/BasicObjects/JSPromise.swift b/Sources/JavaScriptKit/BasicObjects/JSPromise.swift index a41a3e1ca..cfe32d515 100644 --- a/Sources/JavaScriptKit/BasicObjects/JSPromise.swift +++ b/Sources/JavaScriptKit/BasicObjects/JSPromise.swift @@ -31,6 +31,14 @@ public final class JSPromise: JSBridgedClass { return Self(jsObject) } + /// The result of a promise. + public enum Result: Equatable { + /// The promise resolved with a value. + case success(JSValue) + /// The promise rejected with a value. + case failure(JSValue) + } + /// Creates a new `JSPromise` instance from a given `resolver` closure. /// The closure is passed a completion handler. Passing a successful /// `Result` to the completion handler will cause the promise to resolve @@ -38,7 +46,7 @@ public final class JSPromise: JSBridgedClass { /// promise to reject with the corresponding value. /// Calling the completion handler more than once will have no effect /// (per the JavaScript specification). - public convenience init(resolver: @escaping (@escaping (Result) -> Void) -> Void) { + public convenience init(resolver: @escaping (@escaping (Result) -> Void) -> Void) { let closure = JSOneshotClosure { arguments in // The arguments are always coming from the `Promise` constructor, so we should be // safe to assume their type here @@ -90,7 +98,7 @@ public final class JSPromise: JSBridgedClass { /// Schedules the `success` closure to be invoked on successful completion of `self`. @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) @discardableResult - public func then(success: @escaping (JSValue) async throws -> ConvertibleToJSValue) -> JSPromise { + public func then(success: sending @escaping (sending JSValue) async throws -> ConvertibleToJSValue) -> JSPromise { let closure = JSOneshotClosure.async { try await success($0[0]).jsValue } @@ -101,8 +109,8 @@ public final class JSPromise: JSBridgedClass { /// Schedules the `success` closure to be invoked on successful completion of `self`. @discardableResult public func then( - success: @escaping (JSValue) -> ConvertibleToJSValue, - failure: @escaping (JSValue) -> ConvertibleToJSValue + success: @escaping (sending JSValue) -> ConvertibleToJSValue, + failure: @escaping (sending JSValue) -> ConvertibleToJSValue ) -> JSPromise { let successClosure = JSOneshotClosure { success($0[0]).jsValue @@ -117,8 +125,8 @@ public final class JSPromise: JSBridgedClass { /// Schedules the `success` closure to be invoked on successful completion of `self`. @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) @discardableResult - public func then(success: @escaping (JSValue) async throws -> ConvertibleToJSValue, - failure: @escaping (JSValue) async throws -> ConvertibleToJSValue) -> JSPromise + public func then(success: sending @escaping (sending JSValue) async throws -> ConvertibleToJSValue, + failure: sending @escaping (sending JSValue) async throws -> ConvertibleToJSValue) -> JSPromise { let successClosure = JSOneshotClosure.async { try await success($0[0]).jsValue @@ -132,7 +140,7 @@ public final class JSPromise: JSBridgedClass { /// Schedules the `failure` closure to be invoked on rejected completion of `self`. @discardableResult - public func `catch`(failure: @escaping (JSValue) -> ConvertibleToJSValue) -> JSPromise { + public func `catch`(failure: @escaping (sending JSValue) -> ConvertibleToJSValue) -> JSPromise { let closure = JSOneshotClosure { failure($0[0]).jsValue } @@ -143,7 +151,7 @@ public final class JSPromise: JSBridgedClass { /// Schedules the `failure` closure to be invoked on rejected completion of `self`. @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) @discardableResult - public func `catch`(failure: @escaping (JSValue) async throws -> ConvertibleToJSValue) -> JSPromise { + public func `catch`(failure: sending @escaping (sending JSValue) async throws -> ConvertibleToJSValue) -> JSPromise { let closure = JSOneshotClosure.async { try await failure($0[0]).jsValue } diff --git a/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift b/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift index bc80cd25c..dec834bbd 100644 --- a/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift +++ b/Sources/JavaScriptKit/BasicObjects/JSTypedArray.swift @@ -143,19 +143,17 @@ func valueForBitWidth(typeName: String, bitWidth: Int, when32: T) -> T { } extension Int: TypedArrayElement { - public static var typedArrayClass: JSFunction { _typedArrayClass } - @LazyThreadLocal(initialize: { + public static var typedArrayClass: JSFunction { _typedArrayClass.wrappedValue } + private static let _typedArrayClass = LazyThreadLocal(initialize: { valueForBitWidth(typeName: "Int", bitWidth: Int.bitWidth, when32: JSObject.global.Int32Array).function! }) - private static var _typedArrayClass: JSFunction } extension UInt: TypedArrayElement { - public static var typedArrayClass: JSFunction { _typedArrayClass } - @LazyThreadLocal(initialize: { + public static var typedArrayClass: JSFunction { _typedArrayClass.wrappedValue } + private static let _typedArrayClass = LazyThreadLocal(initialize: { valueForBitWidth(typeName: "UInt", bitWidth: Int.bitWidth, when32: JSObject.global.Uint32Array).function! }) - private static var _typedArrayClass: JSFunction } extension Int8: TypedArrayElement { diff --git a/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift b/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift index 5d367ba38..c075c63e5 100644 --- a/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift +++ b/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift @@ -15,7 +15,7 @@ public protocol JSClosureProtocol: JSValueCompatible { public class JSOneshotClosure: JSObject, JSClosureProtocol { private var hostFuncRef: JavaScriptHostFuncRef = 0 - public init(_ body: @escaping ([JSValue]) -> JSValue, file: String = #fileID, line: UInt32 = #line) { + public init(_ body: @escaping (sending [JSValue]) -> JSValue, file: String = #fileID, line: UInt32 = #line) { // 1. Fill `id` as zero at first to access `self` to get `ObjectIdentifier`. super.init(id: 0) @@ -26,7 +26,7 @@ public class JSOneshotClosure: JSObject, JSClosureProtocol { } // 3. Retain the given body in static storage by `funcRef`. - JSClosure.sharedClosures[hostFuncRef] = (self, { + JSClosure.sharedClosures.wrappedValue[hostFuncRef] = (self, { defer { self.release() } return body($0) }) @@ -34,7 +34,7 @@ public class JSOneshotClosure: JSObject, JSClosureProtocol { #if compiler(>=5.5) && !hasFeature(Embedded) @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) - public static func async(_ body: @escaping ([JSValue]) async throws -> JSValue) -> JSOneshotClosure { + public static func async(_ body: sending @escaping (sending [JSValue]) async throws -> JSValue) -> JSOneshotClosure { JSOneshotClosure(makeAsyncClosure(body)) } #endif @@ -42,7 +42,7 @@ public class JSOneshotClosure: JSObject, JSClosureProtocol { /// Release this function resource. /// After calling `release`, calling this function from JavaScript will fail. public func release() { - JSClosure.sharedClosures[hostFuncRef] = nil + JSClosure.sharedClosures.wrappedValue[hostFuncRef] = nil } } @@ -64,24 +64,18 @@ public class JSOneshotClosure: JSObject, JSClosureProtocol { public class JSClosure: JSFunction, JSClosureProtocol { class SharedJSClosure { - private var storage: [JavaScriptHostFuncRef: (object: JSObject, body: ([JSValue]) -> JSValue)] = [:] + private var storage: [JavaScriptHostFuncRef: (object: JSObject, body: (sending [JSValue]) -> JSValue)] = [:] init() {} - subscript(_ key: JavaScriptHostFuncRef) -> (object: JSObject, body: ([JSValue]) -> JSValue)? { + subscript(_ key: JavaScriptHostFuncRef) -> (object: JSObject, body: (sending [JSValue]) -> JSValue)? { get { storage[key] } set { storage[key] = newValue } } } // Note: Retain the closure object itself also to avoid funcRef conflicts - fileprivate static var sharedClosures: SharedJSClosure { - if let swjs_thread_local_closures { - return Unmanaged.fromOpaque(swjs_thread_local_closures).takeUnretainedValue() - } else { - let shared = SharedJSClosure() - swjs_thread_local_closures = Unmanaged.passRetained(shared).toOpaque() - return shared - } + fileprivate static let sharedClosures = LazyThreadLocal { + SharedJSClosure() } private var hostFuncRef: JavaScriptHostFuncRef = 0 @@ -99,7 +93,7 @@ public class JSClosure: JSFunction, JSClosureProtocol { }) } - public init(_ body: @escaping ([JSValue]) -> JSValue, file: String = #fileID, line: UInt32 = #line) { + public init(_ body: @escaping (sending [JSValue]) -> JSValue, file: String = #fileID, line: UInt32 = #line) { // 1. Fill `id` as zero at first to access `self` to get `ObjectIdentifier`. super.init(id: 0) @@ -110,12 +104,12 @@ public class JSClosure: JSFunction, JSClosureProtocol { } // 3. Retain the given body in static storage by `funcRef`. - Self.sharedClosures[hostFuncRef] = (self, body) + Self.sharedClosures.wrappedValue[hostFuncRef] = (self, body) } #if compiler(>=5.5) && !hasFeature(Embedded) @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) - public static func async(_ body: @escaping ([JSValue]) async throws -> JSValue) -> JSClosure { + public static func async(_ body: @Sendable @escaping (sending [JSValue]) async throws -> JSValue) -> JSClosure { JSClosure(makeAsyncClosure(body)) } #endif @@ -131,18 +125,29 @@ public class JSClosure: JSFunction, JSClosureProtocol { #if compiler(>=5.5) && !hasFeature(Embedded) @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) -private func makeAsyncClosure(_ body: @escaping ([JSValue]) async throws -> JSValue) -> (([JSValue]) -> JSValue) { +private func makeAsyncClosure( + _ body: sending @escaping (sending [JSValue]) async throws -> JSValue +) -> ((sending [JSValue]) -> JSValue) { { arguments in JSPromise { resolver in + // NOTE: The context is fully transferred to the unstructured task + // isolation but the compiler can't prove it yet, so we need to + // use `@unchecked Sendable` to make it compile with the Swift 6 mode. + struct Context: @unchecked Sendable { + let resolver: (JSPromise.Result) -> Void + let arguments: [JSValue] + let body: (sending [JSValue]) async throws -> JSValue + } + let context = Context(resolver: resolver, arguments: arguments, body: body) Task { do { - let result = try await body(arguments) - resolver(.success(result)) + let result = try await context.body(context.arguments) + context.resolver(.success(result)) } catch { - if let jsError = error as? JSError { - resolver(.failure(jsError.jsValue)) + if let jsError = error as? JSException { + context.resolver(.failure(jsError.thrownValue)) } else { - resolver(.failure(JSError(message: String(describing: error)).jsValue)) + context.resolver(.failure(JSError(message: String(describing: error)).jsValue)) } } } @@ -192,10 +197,13 @@ func _call_host_function_impl( _ argv: UnsafePointer, _ argc: Int32, _ callbackFuncRef: JavaScriptObjectRef ) -> Bool { - guard let (_, hostFunc) = JSClosure.sharedClosures[hostFuncRef] else { + guard let (_, hostFunc) = JSClosure.sharedClosures.wrappedValue[hostFuncRef] else { return true } - let arguments = UnsafeBufferPointer(start: argv, count: Int(argc)).map { $0.jsValue} + var arguments: [JSValue] = [] + for i in 0.. JSObject { - try arguments.withRawJSValues { rawValues -> Result in + try arguments.withRawJSValues { rawValues -> Result in rawValues.withUnsafeBufferPointer { bufferPointer in let argv = bufferPointer.baseAddress let argc = bufferPointer.count @@ -52,7 +52,7 @@ public class JSThrowingFunction { let exceptionKind = JavaScriptValueKindAndFlags(bitPattern: exceptionRawKind) if exceptionKind.isException { let exception = RawJSValue(kind: exceptionKind.kind, payload1: exceptionPayload1, payload2: exceptionPayload2) - return .failure(exception.jsValue) + return .failure(JSException(exception.jsValue)) } return .success(JSObject(id: resultObj)) } @@ -92,7 +92,7 @@ private func invokeJSFunction(_ jsFunc: JSFunction, arguments: [ConvertibleToJSV } } if isException { - throw result + throw JSException(result) } return result } diff --git a/Sources/JavaScriptKit/JSException.swift b/Sources/JavaScriptKit/JSException.swift new file mode 100644 index 000000000..393ae9615 --- /dev/null +++ b/Sources/JavaScriptKit/JSException.swift @@ -0,0 +1,34 @@ +/// `JSException` is a wrapper that handles exceptions thrown during JavaScript execution as Swift +/// `Error` objects. +/// When a JavaScript function throws an exception, it's wrapped as a `JSException` and propagated +/// through Swift's error handling mechanism. +/// +/// Example: +/// ```swift +/// do { +/// try jsFunction.throws() +/// } catch let error as JSException { +/// // Access the value thrown from JavaScript +/// let jsErrorValue = error.thrownValue +/// } +/// ``` +public struct JSException: Error, Equatable { + /// The value thrown from JavaScript. + /// This can be any JavaScript value (error object, string, number, etc.). + public var thrownValue: JSValue { + return _thrownValue + } + + /// The actual JavaScript value that was thrown. + /// + /// Marked as `nonisolated(unsafe)` to satisfy `Sendable` requirement + /// from `Error` protocol. + private nonisolated(unsafe) let _thrownValue: JSValue + + /// Initializes a new JSException instance with a value thrown from JavaScript. + /// + /// Only available within the package. + package init(_ thrownValue: JSValue) { + self._thrownValue = thrownValue + } +} diff --git a/Sources/JavaScriptKit/JSValue.swift b/Sources/JavaScriptKit/JSValue.swift index ed44f50ea..2562daac8 100644 --- a/Sources/JavaScriptKit/JSValue.swift +++ b/Sources/JavaScriptKit/JSValue.swift @@ -100,6 +100,13 @@ public enum JSValue: Equatable { } } +/// JSValue is intentionally not `Sendable` because accessing a JSValue living in a different +/// thread is invalid. Although there are some cases where Swift allows sending a non-Sendable +/// values to other isolation domains, not conforming `Sendable` is still useful to prevent +/// accidental misuse. +@available(*, unavailable) +extension JSValue: Sendable {} + public extension JSValue { #if !hasFeature(Embedded) /// An unsafe convenience method of `JSObject.subscript(_ name: String) -> ((ConvertibleToJSValue...) -> JSValue)?` @@ -124,8 +131,6 @@ public extension JSValue { } } -extension JSValue: Swift.Error {} - public extension JSValue { func fromJSValue() -> Type? where Type: ConstructibleFromJSValue { return Type.construct(from: self) diff --git a/Sources/_CJavaScriptEventLoop/include/_CJavaScriptEventLoop.h b/Sources/_CJavaScriptEventLoop/include/_CJavaScriptEventLoop.h index 4f1b9470c..08efcb948 100644 --- a/Sources/_CJavaScriptEventLoop/include/_CJavaScriptEventLoop.h +++ b/Sources/_CJavaScriptEventLoop/include/_CJavaScriptEventLoop.h @@ -9,6 +9,8 @@ #define SWIFT_EXPORT_FROM(LIBRARY) __attribute__((__visibility__("default"))) +#define SWIFT_NONISOLATED_UNSAFE __attribute__((swift_attr("nonisolated(unsafe)"))) + /// A schedulable unit /// Note that this type layout is a part of public ABI, so we expect this field layout won't break in the future versions. /// Current implementation refers the `swift-5.5-RELEASE` implementation. @@ -27,13 +29,13 @@ typedef SWIFT_CC(swift) void (*swift_task_enqueueGlobal_original)( Job *_Nonnull job); SWIFT_EXPORT_FROM(swift_Concurrency) -extern void *_Nullable swift_task_enqueueGlobal_hook; +extern void *_Nullable swift_task_enqueueGlobal_hook SWIFT_NONISOLATED_UNSAFE; /// A hook to take over global enqueuing with delay. typedef SWIFT_CC(swift) void (*swift_task_enqueueGlobalWithDelay_original)( unsigned long long delay, Job *_Nonnull job); SWIFT_EXPORT_FROM(swift_Concurrency) -extern void *_Nullable swift_task_enqueueGlobalWithDelay_hook; +extern void *_Nullable swift_task_enqueueGlobalWithDelay_hook SWIFT_NONISOLATED_UNSAFE; typedef SWIFT_CC(swift) void (*swift_task_enqueueGlobalWithDeadline_original)( long long sec, @@ -42,13 +44,13 @@ typedef SWIFT_CC(swift) void (*swift_task_enqueueGlobalWithDeadline_original)( long long tnsec, int clock, Job *_Nonnull job); SWIFT_EXPORT_FROM(swift_Concurrency) -extern void *_Nullable swift_task_enqueueGlobalWithDeadline_hook; +extern void *_Nullable swift_task_enqueueGlobalWithDeadline_hook SWIFT_NONISOLATED_UNSAFE; /// A hook to take over main executor enqueueing. typedef SWIFT_CC(swift) void (*swift_task_enqueueMainExecutor_original)( Job *_Nonnull job); SWIFT_EXPORT_FROM(swift_Concurrency) -extern void *_Nullable swift_task_enqueueMainExecutor_hook; +extern void *_Nullable swift_task_enqueueMainExecutor_hook SWIFT_NONISOLATED_UNSAFE; /// A hook to override the entrypoint to the main runloop used to drive the /// concurrency runtime and drain the main queue. This function must not return. @@ -59,13 +61,13 @@ typedef SWIFT_CC(swift) void (*swift_task_asyncMainDrainQueue_original)(); typedef SWIFT_CC(swift) void (*swift_task_asyncMainDrainQueue_override)( swift_task_asyncMainDrainQueue_original _Nullable original); SWIFT_EXPORT_FROM(swift_Concurrency) -extern void *_Nullable swift_task_asyncMainDrainQueue_hook; +extern void *_Nullable swift_task_asyncMainDrainQueue_hook SWIFT_NONISOLATED_UNSAFE; /// MARK: - thread local storage extern _Thread_local void * _Nullable swjs_thread_local_event_loop; -extern _Thread_local void * _Nullable swjs_thread_local_task_executor_worker; +extern _Thread_local void * _Nullable swjs_thread_local_task_executor_worker SWIFT_NONISOLATED_UNSAFE; #endif diff --git a/Sources/_CJavaScriptKit/_CJavaScriptKit.c b/Sources/_CJavaScriptKit/_CJavaScriptKit.c index 424e9081b..ea8b5b43d 100644 --- a/Sources/_CJavaScriptKit/_CJavaScriptKit.c +++ b/Sources/_CJavaScriptKit/_CJavaScriptKit.c @@ -61,5 +61,3 @@ int swjs_library_features(void) { } #endif #endif - -_Thread_local void *swjs_thread_local_closures; diff --git a/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h b/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h index aa0b978a2..5cb6e6037 100644 --- a/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h +++ b/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h @@ -308,9 +308,4 @@ IMPORT_JS_FUNCTION(swjs_terminate_worker_thread, void, (int tid)) IMPORT_JS_FUNCTION(swjs_get_worker_thread_id, int, (void)) -/// MARK: - thread local storage - -// TODO: Rewrite closure system without global storage -extern _Thread_local void * _Nullable swjs_thread_local_closures; - #endif /* _CJavaScriptKit_h */