diff --git a/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift b/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift index 18a40078..ed5e652e 100644 --- a/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift +++ b/Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift @@ -18,7 +18,7 @@ public protocol JSClosureProtocol: JSValueCompatible { public class JSOneshotClosure: JSObject, JSClosureProtocol { private var hostFuncRef: JavaScriptHostFuncRef = 0 - public init(_ body: @escaping (sending [JSValue]) -> JSValue, file: String = #fileID, line: UInt32 = #line) { + public init(file: String = #fileID, line: UInt32 = #line, _ body: @escaping (sending [JSValue]) -> JSValue) { // 1. Fill `id` as zero at first to access `self` to get `ObjectIdentifier`. super.init(id: 0) @@ -44,11 +44,40 @@ public class JSOneshotClosure: JSObject, JSClosureProtocol { } #if compiler(>=5.5) && (!hasFeature(Embedded) || os(WASI)) + /// Creates a new `JSOneshotClosure` that calls the given Swift function asynchronously. + /// + /// - Parameters: + /// - priority: The priority of the new unstructured Task created under the hood. + /// - body: The Swift function to call asynchronously. @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) public static func async( + priority: TaskPriority? = nil, + file: String = #fileID, + line: UInt32 = #line, _ body: sending @escaping (sending [JSValue]) async throws(JSException) -> JSValue ) -> JSOneshotClosure { - JSOneshotClosure(makeAsyncClosure(body)) + JSOneshotClosure(file: file, line: line, makeAsyncClosure(priority: priority, body)) + } + + /// Creates a new `JSOneshotClosure` that calls the given Swift function asynchronously. + /// + /// - Parameters: + /// - taskExecutor: The executor preference of the new unstructured Task created under the hood. + /// - priority: The priority of the new unstructured Task created under the hood. + /// - body: The Swift function to call asynchronously. + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + public static func async( + executorPreference taskExecutor: (any TaskExecutor)? = nil, + priority: TaskPriority? = nil, + file: String = #fileID, + line: UInt32 = #line, + _ body: @Sendable @escaping (sending [JSValue]) async throws(JSException) -> JSValue + ) -> JSOneshotClosure { + JSOneshotClosure( + file: file, + line: line, + makeAsyncClosure(executorPreference: taskExecutor, priority: priority, body) + ) } #endif @@ -117,7 +146,7 @@ public class JSClosure: JSFunction, JSClosureProtocol { }) } - public init(_ body: @escaping (sending [JSValue]) -> JSValue, file: String = #fileID, line: UInt32 = #line) { + public init(file: String = #fileID, line: UInt32 = #line, _ body: @escaping (sending [JSValue]) -> JSValue) { // 1. Fill `id` as zero at first to access `self` to get `ObjectIdentifier`. super.init(id: 0) @@ -137,11 +166,36 @@ public class JSClosure: JSFunction, JSClosureProtocol { } #if compiler(>=5.5) && (!hasFeature(Embedded) || os(WASI)) + /// Creates a new `JSClosure` that calls the given Swift function asynchronously. + /// + /// - Parameters: + /// - priority: The priority of the new unstructured Task created under the hood. + /// - body: The Swift function to call asynchronously. @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) public static func async( - _ body: @Sendable @escaping (sending [JSValue]) async throws(JSException) -> JSValue + priority: TaskPriority? = nil, + file: String = #fileID, + line: UInt32 = #line, + _ body: sending @escaping @isolated(any) (sending [JSValue]) async throws(JSException) -> JSValue + ) -> JSClosure { + JSClosure(file: file, line: line, makeAsyncClosure(priority: priority, body)) + } + + /// Creates a new `JSClosure` that calls the given Swift function asynchronously. + /// + /// - Parameters: + /// - taskExecutor: The executor preference of the new unstructured Task created under the hood. + /// - priority: The priority of the new unstructured Task created under the hood. + /// - body: The Swift function to call asynchronously. + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + public static func async( + executorPreference taskExecutor: (any TaskExecutor)? = nil, + priority: TaskPriority? = nil, + file: String = #fileID, + line: UInt32 = #line, + _ body: sending @escaping (sending [JSValue]) async throws(JSException) -> JSValue ) -> JSClosure { - JSClosure(makeAsyncClosure(body)) + JSClosure(file: file, line: line, makeAsyncClosure(executorPreference: taskExecutor, priority: priority, body)) } #endif @@ -157,6 +211,36 @@ public class JSClosure: JSFunction, JSClosureProtocol { #if compiler(>=5.5) && (!hasFeature(Embedded) || os(WASI)) @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) private func makeAsyncClosure( + priority: TaskPriority?, + _ body: sending @escaping @isolated(any) (sending [JSValue]) async throws(JSException) -> 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(JSException) -> JSValue + } + let context = Context(resolver: resolver, arguments: arguments, body: body) + Task(priority: priority) { + do throws(JSException) { + let result = try await context.body(context.arguments) + context.resolver(.success(result)) + } catch { + context.resolver(.failure(error.thrownValue)) + } + } + }.jsValue() + } +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +private func makeAsyncClosure( + executorPreference taskExecutor: (any TaskExecutor)?, + priority: TaskPriority?, _ body: sending @escaping (sending [JSValue]) async throws(JSException) -> JSValue ) -> ((sending [JSValue]) -> JSValue) { { arguments in @@ -170,7 +254,7 @@ private func makeAsyncClosure( let body: (sending [JSValue]) async throws(JSException) -> JSValue } let context = Context(resolver: resolver, arguments: arguments, body: body) - Task { + Task(executorPreference: taskExecutor, priority: priority) { do throws(JSException) { let result = try await context.body(context.arguments) context.resolver(.success(result)) diff --git a/Tests/JavaScriptEventLoopTests/JSClosure+AsyncTests.swift b/Tests/JavaScriptEventLoopTests/JSClosure+AsyncTests.swift new file mode 100644 index 00000000..e3c19a8e --- /dev/null +++ b/Tests/JavaScriptEventLoopTests/JSClosure+AsyncTests.swift @@ -0,0 +1,110 @@ +import JavaScriptKit +import XCTest + +class JSClosureAsyncTests: XCTestCase { + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + final class AnyTaskExecutor: TaskExecutor { + func enqueue(_ job: UnownedJob) { + job.runSynchronously(on: asUnownedTaskExecutor()) + } + } + + final class UnsafeSendableBox: @unchecked Sendable { + var value: T + init(_ value: T) { + self.value = value + } + } + + func testAsyncClosure() async throws { + let closure = JSClosure.async { _ in + return (42.0).jsValue + }.jsValue + let result = try await JSPromise(from: closure.function!())!.value() + XCTAssertEqual(result, 42.0) + } + + func testAsyncClosureWithPriority() async throws { + let priority = UnsafeSendableBox(nil) + let closure = JSClosure.async(priority: .high) { _ in + priority.value = Task.currentPriority + return (42.0).jsValue + }.jsValue + let result = try await JSPromise(from: closure.function!())!.value() + XCTAssertEqual(result, 42.0) + XCTAssertEqual(priority.value, .high) + } + + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func testAsyncClosureWithTaskExecutor() async throws { + let executor = AnyTaskExecutor() + let closure = JSClosure.async(executorPreference: executor) { _ in + return (42.0).jsValue + }.jsValue + let result = try await JSPromise(from: closure.function!())!.value() + XCTAssertEqual(result, 42.0) + } + + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func testAsyncClosureWithTaskExecutorPreference() async throws { + let executor = AnyTaskExecutor() + let priority = UnsafeSendableBox(nil) + let closure = JSClosure.async(executorPreference: executor, priority: .high) { _ in + priority.value = Task.currentPriority + return (42.0).jsValue + }.jsValue + let result = try await JSPromise(from: closure.function!())!.value() + XCTAssertEqual(result, 42.0) + XCTAssertEqual(priority.value, .high) + } + + // TODO: Enable the following tests once: + // - Make JSObject a final-class + // - Unify JSFunction and JSObject into JSValue + // - Make JS(Oneshot)Closure as a wrapper of JSObject, not a subclass + /* + func testAsyncOneshotClosure() async throws { + let closure = JSOneshotClosure.async { _ in + return (42.0).jsValue + }.jsValue + let result = try await JSPromise( + from: closure.function!() + )!.value() + XCTAssertEqual(result, 42.0) + } + + func testAsyncOneshotClosureWithPriority() async throws { + let priority = UnsafeSendableBox(nil) + let closure = JSOneshotClosure.async(priority: .high) { _ in + priority.value = Task.currentPriority + return (42.0).jsValue + }.jsValue + let result = try await JSPromise(from: closure.function!())!.value() + XCTAssertEqual(result, 42.0) + XCTAssertEqual(priority.value, .high) + } + + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func testAsyncOneshotClosureWithTaskExecutor() async throws { + let executor = AnyTaskExecutor() + let closure = JSOneshotClosure.async(executorPreference: executor) { _ in + return (42.0).jsValue + }.jsValue + let result = try await JSPromise(from: closure.function!())!.value() + XCTAssertEqual(result, 42.0) + } + + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) + func testAsyncOneshotClosureWithTaskExecutorPreference() async throws { + let executor = AnyTaskExecutor() + let priority = UnsafeSendableBox(nil) + let closure = JSOneshotClosure.async(executorPreference: executor, priority: .high) { _ in + priority.value = Task.currentPriority + return (42.0).jsValue + }.jsValue + let result = try await JSPromise(from: closure.function!())!.value() + XCTAssertEqual(result, 42.0) + XCTAssertEqual(priority.value, .high) + } + */ +} diff --git a/Tests/JavaScriptKitTests/JSClosureTests.swift b/Tests/JavaScriptKitTests/JSClosureTests.swift new file mode 100644 index 00000000..2dd6c605 --- /dev/null +++ b/Tests/JavaScriptKitTests/JSClosureTests.swift @@ -0,0 +1,88 @@ +import JavaScriptKit +import XCTest + +class JSClosureTests: XCTestCase { + func testClosureLifetime() { + let evalClosure = JSObject.global.globalObject1.eval_closure.function! + + do { + let c1 = JSClosure { arguments in + return arguments[0] + } + XCTAssertEqual(evalClosure(c1, JSValue.number(1.0)), .number(1.0)) + #if JAVASCRIPTKIT_WITHOUT_WEAKREFS + c1.release() + #endif + } + + do { + let array = JSObject.global.Array.function!.new() + let c1 = JSClosure { _ in .number(3) } + _ = array.push!(c1) + XCTAssertEqual(array[0].function!().number, 3.0) + #if JAVASCRIPTKIT_WITHOUT_WEAKREFS + c1.release() + #endif + } + + do { + let c1 = JSClosure { _ in .undefined } + XCTAssertEqual(c1(), .undefined) + } + + do { + let c1 = JSClosure { _ in .number(4) } + XCTAssertEqual(c1(), .number(4)) + } + } + + func testHostFunctionRegistration() { + // ```js + // global.globalObject1 = { + // ... + // "prop_6": { + // "call_host_1": function() { + // return global.globalObject1.prop_6.host_func_1() + // } + // } + // } + // ``` + let globalObject1 = getJSValue(this: .global, name: "globalObject1") + let globalObject1Ref = try! XCTUnwrap(globalObject1.object) + let prop_6 = getJSValue(this: globalObject1Ref, name: "prop_6") + let prop_6Ref = try! XCTUnwrap(prop_6.object) + + var isHostFunc1Called = false + let hostFunc1 = JSClosure { (_) -> JSValue in + isHostFunc1Called = true + return .number(1) + } + + setJSValue(this: prop_6Ref, name: "host_func_1", value: .object(hostFunc1)) + + let call_host_1 = getJSValue(this: prop_6Ref, name: "call_host_1") + let call_host_1Func = try! XCTUnwrap(call_host_1.function) + XCTAssertEqual(call_host_1Func(), .number(1)) + XCTAssertEqual(isHostFunc1Called, true) + + #if JAVASCRIPTKIT_WITHOUT_WEAKREFS + hostFunc1.release() + #endif + + let evalClosure = JSObject.global.globalObject1.eval_closure.function! + let hostFunc2 = JSClosure { (arguments) -> JSValue in + if let input = arguments[0].number { + return .number(input * 2) + } else { + return .string(String(describing: arguments[0])) + } + } + + XCTAssertEqual(evalClosure(hostFunc2, 3), .number(6)) + XCTAssertTrue(evalClosure(hostFunc2, true).string != nil) + + #if JAVASCRIPTKIT_WITHOUT_WEAKREFS + hostFunc2.release() + #endif + } +} diff --git a/Tests/JavaScriptKitTests/JavaScriptKitTests.swift b/Tests/JavaScriptKitTests/JavaScriptKitTests.swift index 246df522..0e84283e 100644 --- a/Tests/JavaScriptKitTests/JavaScriptKitTests.swift +++ b/Tests/JavaScriptKitTests/JavaScriptKitTests.swift @@ -197,90 +197,6 @@ class JavaScriptKitTests: XCTestCase { XCTAssertEqual(func6(true, "OK", 2), .string("OK")) } - func testClosureLifetime() { - let evalClosure = JSObject.global.globalObject1.eval_closure.function! - - do { - let c1 = JSClosure { arguments in - return arguments[0] - } - XCTAssertEqual(evalClosure(c1, JSValue.number(1.0)), .number(1.0)) - #if JAVASCRIPTKIT_WITHOUT_WEAKREFS - c1.release() - #endif - } - - do { - let array = JSObject.global.Array.function!.new() - let c1 = JSClosure { _ in .number(3) } - _ = array.push!(c1) - XCTAssertEqual(array[0].function!().number, 3.0) - #if JAVASCRIPTKIT_WITHOUT_WEAKREFS - c1.release() - #endif - } - - do { - let c1 = JSClosure { _ in .undefined } - XCTAssertEqual(c1(), .undefined) - } - - do { - let c1 = JSClosure { _ in .number(4) } - XCTAssertEqual(c1(), .number(4)) - } - } - - func testHostFunctionRegistration() { - // ```js - // global.globalObject1 = { - // ... - // "prop_6": { - // "call_host_1": function() { - // return global.globalObject1.prop_6.host_func_1() - // } - // } - // } - // ``` - let globalObject1 = getJSValue(this: .global, name: "globalObject1") - let globalObject1Ref = try! XCTUnwrap(globalObject1.object) - let prop_6 = getJSValue(this: globalObject1Ref, name: "prop_6") - let prop_6Ref = try! XCTUnwrap(prop_6.object) - - var isHostFunc1Called = false - let hostFunc1 = JSClosure { (_) -> JSValue in - isHostFunc1Called = true - return .number(1) - } - - setJSValue(this: prop_6Ref, name: "host_func_1", value: .object(hostFunc1)) - - let call_host_1 = getJSValue(this: prop_6Ref, name: "call_host_1") - let call_host_1Func = try! XCTUnwrap(call_host_1.function) - XCTAssertEqual(call_host_1Func(), .number(1)) - XCTAssertEqual(isHostFunc1Called, true) - - #if JAVASCRIPTKIT_WITHOUT_WEAKREFS - hostFunc1.release() - #endif - - let evalClosure = JSObject.global.globalObject1.eval_closure.function! - let hostFunc2 = JSClosure { (arguments) -> JSValue in - if let input = arguments[0].number { - return .number(input * 2) - } else { - return .string(String(describing: arguments[0])) - } - } - - XCTAssertEqual(evalClosure(hostFunc2, 3), .number(6)) - XCTAssertTrue(evalClosure(hostFunc2, true).string != nil) - - #if JAVASCRIPTKIT_WITHOUT_WEAKREFS - hostFunc2.release() - #endif - } - func testNewObjectConstruction() { // ```js // global.Animal = function(name, age, isCat) {