Skip to content

Commit cc62d17

Browse files
Merge pull request #383 from swiftwasm/yt/add-async-closure-executor-pref
Add JSClosure APIs to support specifying TaskExecutor and TaskPriority
2 parents 83e2335 + a3a3868 commit cc62d17

File tree

4 files changed

+288
-90
lines changed

4 files changed

+288
-90
lines changed

Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public protocol JSClosureProtocol: JSValueCompatible {
1818
public class JSOneshotClosure: JSObject, JSClosureProtocol {
1919
private var hostFuncRef: JavaScriptHostFuncRef = 0
2020

21-
public init(_ body: @escaping (sending [JSValue]) -> JSValue, file: String = #fileID, line: UInt32 = #line) {
21+
public init(file: String = #fileID, line: UInt32 = #line, _ body: @escaping (sending [JSValue]) -> JSValue) {
2222
// 1. Fill `id` as zero at first to access `self` to get `ObjectIdentifier`.
2323
super.init(id: 0)
2424

@@ -44,11 +44,40 @@ public class JSOneshotClosure: JSObject, JSClosureProtocol {
4444
}
4545

4646
#if compiler(>=5.5) && (!hasFeature(Embedded) || os(WASI))
47+
/// Creates a new `JSOneshotClosure` that calls the given Swift function asynchronously.
48+
///
49+
/// - Parameters:
50+
/// - priority: The priority of the new unstructured Task created under the hood.
51+
/// - body: The Swift function to call asynchronously.
4752
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
4853
public static func async(
54+
priority: TaskPriority? = nil,
55+
file: String = #fileID,
56+
line: UInt32 = #line,
4957
_ body: sending @escaping (sending [JSValue]) async throws(JSException) -> JSValue
5058
) -> JSOneshotClosure {
51-
JSOneshotClosure(makeAsyncClosure(body))
59+
JSOneshotClosure(file: file, line: line, makeAsyncClosure(priority: priority, body))
60+
}
61+
62+
/// Creates a new `JSOneshotClosure` that calls the given Swift function asynchronously.
63+
///
64+
/// - Parameters:
65+
/// - taskExecutor: The executor preference of the new unstructured Task created under the hood.
66+
/// - priority: The priority of the new unstructured Task created under the hood.
67+
/// - body: The Swift function to call asynchronously.
68+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
69+
public static func async(
70+
executorPreference taskExecutor: (any TaskExecutor)? = nil,
71+
priority: TaskPriority? = nil,
72+
file: String = #fileID,
73+
line: UInt32 = #line,
74+
_ body: @Sendable @escaping (sending [JSValue]) async throws(JSException) -> JSValue
75+
) -> JSOneshotClosure {
76+
JSOneshotClosure(
77+
file: file,
78+
line: line,
79+
makeAsyncClosure(executorPreference: taskExecutor, priority: priority, body)
80+
)
5281
}
5382
#endif
5483

@@ -117,7 +146,7 @@ public class JSClosure: JSFunction, JSClosureProtocol {
117146
})
118147
}
119148

120-
public init(_ body: @escaping (sending [JSValue]) -> JSValue, file: String = #fileID, line: UInt32 = #line) {
149+
public init(file: String = #fileID, line: UInt32 = #line, _ body: @escaping (sending [JSValue]) -> JSValue) {
121150
// 1. Fill `id` as zero at first to access `self` to get `ObjectIdentifier`.
122151
super.init(id: 0)
123152

@@ -137,11 +166,36 @@ public class JSClosure: JSFunction, JSClosureProtocol {
137166
}
138167

139168
#if compiler(>=5.5) && (!hasFeature(Embedded) || os(WASI))
169+
/// Creates a new `JSClosure` that calls the given Swift function asynchronously.
170+
///
171+
/// - Parameters:
172+
/// - priority: The priority of the new unstructured Task created under the hood.
173+
/// - body: The Swift function to call asynchronously.
140174
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
141175
public static func async(
142-
_ body: @Sendable @escaping (sending [JSValue]) async throws(JSException) -> JSValue
176+
priority: TaskPriority? = nil,
177+
file: String = #fileID,
178+
line: UInt32 = #line,
179+
_ body: sending @escaping @isolated(any) (sending [JSValue]) async throws(JSException) -> JSValue
180+
) -> JSClosure {
181+
JSClosure(file: file, line: line, makeAsyncClosure(priority: priority, body))
182+
}
183+
184+
/// Creates a new `JSClosure` that calls the given Swift function asynchronously.
185+
///
186+
/// - Parameters:
187+
/// - taskExecutor: The executor preference of the new unstructured Task created under the hood.
188+
/// - priority: The priority of the new unstructured Task created under the hood.
189+
/// - body: The Swift function to call asynchronously.
190+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
191+
public static func async(
192+
executorPreference taskExecutor: (any TaskExecutor)? = nil,
193+
priority: TaskPriority? = nil,
194+
file: String = #fileID,
195+
line: UInt32 = #line,
196+
_ body: sending @escaping (sending [JSValue]) async throws(JSException) -> JSValue
143197
) -> JSClosure {
144-
JSClosure(makeAsyncClosure(body))
198+
JSClosure(file: file, line: line, makeAsyncClosure(executorPreference: taskExecutor, priority: priority, body))
145199
}
146200
#endif
147201

@@ -157,6 +211,36 @@ public class JSClosure: JSFunction, JSClosureProtocol {
157211
#if compiler(>=5.5) && (!hasFeature(Embedded) || os(WASI))
158212
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
159213
private func makeAsyncClosure(
214+
priority: TaskPriority?,
215+
_ body: sending @escaping @isolated(any) (sending [JSValue]) async throws(JSException) -> JSValue
216+
) -> ((sending [JSValue]) -> JSValue) {
217+
{ arguments in
218+
JSPromise { resolver in
219+
// NOTE: The context is fully transferred to the unstructured task
220+
// isolation but the compiler can't prove it yet, so we need to
221+
// use `@unchecked Sendable` to make it compile with the Swift 6 mode.
222+
struct Context: @unchecked Sendable {
223+
let resolver: (JSPromise.Result) -> Void
224+
let arguments: [JSValue]
225+
let body: (sending [JSValue]) async throws(JSException) -> JSValue
226+
}
227+
let context = Context(resolver: resolver, arguments: arguments, body: body)
228+
Task(priority: priority) {
229+
do throws(JSException) {
230+
let result = try await context.body(context.arguments)
231+
context.resolver(.success(result))
232+
} catch {
233+
context.resolver(.failure(error.thrownValue))
234+
}
235+
}
236+
}.jsValue()
237+
}
238+
}
239+
240+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
241+
private func makeAsyncClosure(
242+
executorPreference taskExecutor: (any TaskExecutor)?,
243+
priority: TaskPriority?,
160244
_ body: sending @escaping (sending [JSValue]) async throws(JSException) -> JSValue
161245
) -> ((sending [JSValue]) -> JSValue) {
162246
{ arguments in
@@ -170,7 +254,7 @@ private func makeAsyncClosure(
170254
let body: (sending [JSValue]) async throws(JSException) -> JSValue
171255
}
172256
let context = Context(resolver: resolver, arguments: arguments, body: body)
173-
Task {
257+
Task(executorPreference: taskExecutor, priority: priority) {
174258
do throws(JSException) {
175259
let result = try await context.body(context.arguments)
176260
context.resolver(.success(result))
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import JavaScriptKit
2+
import XCTest
3+
4+
class JSClosureAsyncTests: XCTestCase {
5+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
6+
final class AnyTaskExecutor: TaskExecutor {
7+
func enqueue(_ job: UnownedJob) {
8+
job.runSynchronously(on: asUnownedTaskExecutor())
9+
}
10+
}
11+
12+
final class UnsafeSendableBox<T>: @unchecked Sendable {
13+
var value: T
14+
init(_ value: T) {
15+
self.value = value
16+
}
17+
}
18+
19+
func testAsyncClosure() async throws {
20+
let closure = JSClosure.async { _ in
21+
return (42.0).jsValue
22+
}.jsValue
23+
let result = try await JSPromise(from: closure.function!())!.value()
24+
XCTAssertEqual(result, 42.0)
25+
}
26+
27+
func testAsyncClosureWithPriority() async throws {
28+
let priority = UnsafeSendableBox<TaskPriority?>(nil)
29+
let closure = JSClosure.async(priority: .high) { _ in
30+
priority.value = Task.currentPriority
31+
return (42.0).jsValue
32+
}.jsValue
33+
let result = try await JSPromise(from: closure.function!())!.value()
34+
XCTAssertEqual(result, 42.0)
35+
XCTAssertEqual(priority.value, .high)
36+
}
37+
38+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
39+
func testAsyncClosureWithTaskExecutor() async throws {
40+
let executor = AnyTaskExecutor()
41+
let closure = JSClosure.async(executorPreference: executor) { _ in
42+
return (42.0).jsValue
43+
}.jsValue
44+
let result = try await JSPromise(from: closure.function!())!.value()
45+
XCTAssertEqual(result, 42.0)
46+
}
47+
48+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
49+
func testAsyncClosureWithTaskExecutorPreference() async throws {
50+
let executor = AnyTaskExecutor()
51+
let priority = UnsafeSendableBox<TaskPriority?>(nil)
52+
let closure = JSClosure.async(executorPreference: executor, priority: .high) { _ in
53+
priority.value = Task.currentPriority
54+
return (42.0).jsValue
55+
}.jsValue
56+
let result = try await JSPromise(from: closure.function!())!.value()
57+
XCTAssertEqual(result, 42.0)
58+
XCTAssertEqual(priority.value, .high)
59+
}
60+
61+
// TODO: Enable the following tests once:
62+
// - Make JSObject a final-class
63+
// - Unify JSFunction and JSObject into JSValue
64+
// - Make JS(Oneshot)Closure as a wrapper of JSObject, not a subclass
65+
/*
66+
func testAsyncOneshotClosure() async throws {
67+
let closure = JSOneshotClosure.async { _ in
68+
return (42.0).jsValue
69+
}.jsValue
70+
let result = try await JSPromise(
71+
from: closure.function!()
72+
)!.value()
73+
XCTAssertEqual(result, 42.0)
74+
}
75+
76+
func testAsyncOneshotClosureWithPriority() async throws {
77+
let priority = UnsafeSendableBox<TaskPriority?>(nil)
78+
let closure = JSOneshotClosure.async(priority: .high) { _ in
79+
priority.value = Task.currentPriority
80+
return (42.0).jsValue
81+
}.jsValue
82+
let result = try await JSPromise(from: closure.function!())!.value()
83+
XCTAssertEqual(result, 42.0)
84+
XCTAssertEqual(priority.value, .high)
85+
}
86+
87+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
88+
func testAsyncOneshotClosureWithTaskExecutor() async throws {
89+
let executor = AnyTaskExecutor()
90+
let closure = JSOneshotClosure.async(executorPreference: executor) { _ in
91+
return (42.0).jsValue
92+
}.jsValue
93+
let result = try await JSPromise(from: closure.function!())!.value()
94+
XCTAssertEqual(result, 42.0)
95+
}
96+
97+
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
98+
func testAsyncOneshotClosureWithTaskExecutorPreference() async throws {
99+
let executor = AnyTaskExecutor()
100+
let priority = UnsafeSendableBox<TaskPriority?>(nil)
101+
let closure = JSOneshotClosure.async(executorPreference: executor, priority: .high) { _ in
102+
priority.value = Task.currentPriority
103+
return (42.0).jsValue
104+
}.jsValue
105+
let result = try await JSPromise(from: closure.function!())!.value()
106+
XCTAssertEqual(result, 42.0)
107+
XCTAssertEqual(priority.value, .high)
108+
}
109+
*/
110+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import JavaScriptKit
2+
import XCTest
3+
4+
class JSClosureTests: XCTestCase {
5+
func testClosureLifetime() {
6+
let evalClosure = JSObject.global.globalObject1.eval_closure.function!
7+
8+
do {
9+
let c1 = JSClosure { arguments in
10+
return arguments[0]
11+
}
12+
XCTAssertEqual(evalClosure(c1, JSValue.number(1.0)), .number(1.0))
13+
#if JAVASCRIPTKIT_WITHOUT_WEAKREFS
14+
c1.release()
15+
#endif
16+
}
17+
18+
do {
19+
let array = JSObject.global.Array.function!.new()
20+
let c1 = JSClosure { _ in .number(3) }
21+
_ = array.push!(c1)
22+
XCTAssertEqual(array[0].function!().number, 3.0)
23+
#if JAVASCRIPTKIT_WITHOUT_WEAKREFS
24+
c1.release()
25+
#endif
26+
}
27+
28+
do {
29+
let c1 = JSClosure { _ in .undefined }
30+
XCTAssertEqual(c1(), .undefined)
31+
}
32+
33+
do {
34+
let c1 = JSClosure { _ in .number(4) }
35+
XCTAssertEqual(c1(), .number(4))
36+
}
37+
}
38+
39+
func testHostFunctionRegistration() {
40+
// ```js
41+
// global.globalObject1 = {
42+
// ...
43+
// "prop_6": {
44+
// "call_host_1": function() {
45+
// return global.globalObject1.prop_6.host_func_1()
46+
// }
47+
// }
48+
// }
49+
// ```
50+
let globalObject1 = getJSValue(this: .global, name: "globalObject1")
51+
let globalObject1Ref = try! XCTUnwrap(globalObject1.object)
52+
let prop_6 = getJSValue(this: globalObject1Ref, name: "prop_6")
53+
let prop_6Ref = try! XCTUnwrap(prop_6.object)
54+
55+
var isHostFunc1Called = false
56+
let hostFunc1 = JSClosure { (_) -> JSValue in
57+
isHostFunc1Called = true
58+
return .number(1)
59+
}
60+
61+
setJSValue(this: prop_6Ref, name: "host_func_1", value: .object(hostFunc1))
62+
63+
let call_host_1 = getJSValue(this: prop_6Ref, name: "call_host_1")
64+
let call_host_1Func = try! XCTUnwrap(call_host_1.function)
65+
XCTAssertEqual(call_host_1Func(), .number(1))
66+
XCTAssertEqual(isHostFunc1Called, true)
67+
68+
#if JAVASCRIPTKIT_WITHOUT_WEAKREFS
69+
hostFunc1.release()
70+
#endif
71+
72+
let evalClosure = JSObject.global.globalObject1.eval_closure.function!
73+
let hostFunc2 = JSClosure { (arguments) -> JSValue in
74+
if let input = arguments[0].number {
75+
return .number(input * 2)
76+
} else {
77+
return .string(String(describing: arguments[0]))
78+
}
79+
}
80+
81+
XCTAssertEqual(evalClosure(hostFunc2, 3), .number(6))
82+
XCTAssertTrue(evalClosure(hostFunc2, true).string != nil)
83+
84+
#if JAVASCRIPTKIT_WITHOUT_WEAKREFS
85+
hostFunc2.release()
86+
#endif
87+
}
88+
}

0 commit comments

Comments
 (0)