Skip to content

Commit f1ef517

Browse files
authored
Add async closure support (#159)
1 parent 2d7bc96 commit f1ef517

File tree

4 files changed

+186
-31
lines changed

4 files changed

+186
-31
lines changed

IntegrationTests/TestSuites/Sources/ConcurrencyTests/UnitTestUtils.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ struct MessageError: Error {
4141
}
4242
}
4343

44+
func expectGTE<T: Comparable>(
45+
_ lhs: T, _ rhs: T,
46+
file: StaticString = #file, line: UInt = #line, column: UInt = #column
47+
) throws {
48+
if lhs < rhs {
49+
throw MessageError(
50+
"Expected \(lhs) to be greater than or equal to \(rhs)",
51+
file: file, line: line, column: column
52+
)
53+
}
54+
}
55+
4456
func expectEqual<T: Equatable>(
4557
_ lhs: T, _ rhs: T,
4658
file: StaticString = #file, line: UInt = #line, column: UInt = #column

IntegrationTests/TestSuites/Sources/ConcurrencyTests/main.swift

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func entrypoint() async throws {
6262
let start = time(nil)
6363
try await Task.sleep(nanoseconds: 2_000_000_000)
6464
let diff = difftime(time(nil), start);
65-
try expectEqual(diff >= 2, true)
65+
try expectGTE(diff, 2)
6666
}
6767

6868
try await asyncTest("Job reordering based on priority") {
@@ -97,6 +97,85 @@ func entrypoint() async throws {
9797
_ = await (t3.value, t4.value, t5.value)
9898
try expectEqual(context.completed, ["t4", "t3", "t5"])
9999
}
100+
101+
try await asyncTest("Async JSClosure") {
102+
let delayClosure = JSClosure.async { _ -> JSValue in
103+
try await Task.sleep(nanoseconds: 2_000_000_000)
104+
return JSValue.number(3)
105+
}
106+
let delayObject = JSObject.global.Object.function!.new()
107+
delayObject.closure = delayClosure.jsValue
108+
109+
let start = time(nil)
110+
let promise = JSPromise(from: delayObject.closure!())
111+
try expectNotNil(promise)
112+
let result = try await promise!.value
113+
let diff = difftime(time(nil), start)
114+
try expectGTE(diff, 2)
115+
try expectEqual(result, .number(3))
116+
}
117+
118+
try await asyncTest("Async JSPromise: then") {
119+
let promise = JSPromise { resolve in
120+
_ = JSObject.global.setTimeout!(
121+
JSClosure { _ in
122+
resolve(.success(JSValue.number(3)))
123+
return .undefined
124+
}.jsValue,
125+
1_000
126+
)
127+
}
128+
let promise2 = promise.then { result in
129+
try await Task.sleep(nanoseconds: 1_000_000_000)
130+
return String(result.number!)
131+
}
132+
let start = time(nil)
133+
let result = try await promise2.value
134+
let diff = difftime(time(nil), start)
135+
try expectGTE(diff, 2)
136+
try expectEqual(result, .string("3.0"))
137+
}
138+
139+
try await asyncTest("Async JSPromise: then(success:failure:)") {
140+
let promise = JSPromise { resolve in
141+
_ = JSObject.global.setTimeout!(
142+
JSClosure { _ in
143+
resolve(.failure(JSError(message: "test").jsValue))
144+
return .undefined
145+
}.jsValue,
146+
1_000
147+
)
148+
}
149+
let promise2 = promise.then { _ in
150+
throw JSError(message: "should not succeed")
151+
} failure: { err in
152+
return err
153+
}
154+
let result = try await promise2.value
155+
try expectEqual(result.object?.message, .string("test"))
156+
}
157+
158+
try await asyncTest("Async JSPromise: catch") {
159+
let promise = JSPromise { resolve in
160+
_ = JSObject.global.setTimeout!(
161+
JSClosure { _ in
162+
resolve(.failure(JSError(message: "test").jsValue))
163+
return .undefined
164+
}.jsValue,
165+
1_000
166+
)
167+
}
168+
let promise2 = promise.catch { err in
169+
try await Task.sleep(nanoseconds: 1_000_000_000)
170+
return err
171+
}
172+
let start = time(nil)
173+
let result = try await promise2.value
174+
let diff = difftime(time(nil), start)
175+
try expectGTE(diff, 2)
176+
try expectEqual(result.object?.message, .string("test"))
177+
}
178+
100179
// FIXME(katei): Somehow it doesn't work due to a mysterious unreachable inst
101180
// at the end of thunk.
102181
// This issue is not only on JS host environment, but also on standalone coop executor.

Sources/JavaScriptKit/BasicObjects/JSPromise.swift

Lines changed: 59 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,4 @@
1-
/** A wrapper around [the JavaScript `Promise` class](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)
2-
that exposes its functions in a type-safe and Swifty way. The `JSPromise` API is generic over both
3-
`Success` and `Failure` types, which improves compatibility with other statically-typed APIs such
4-
as Combine. If you don't know the exact type of your `Success` value, you should use `JSValue`, e.g.
5-
`JSPromise<JSValue, JSError>`. In the rare case, where you can't guarantee that the error thrown
6-
is of actual JavaScript `Error` type, you should use `JSPromise<JSValue, JSValue>`.
7-
8-
This doesn't 100% match the JavaScript API, as `then` overload with two callbacks is not available.
9-
It's impossible to unify success and failure types from both callbacks in a single returned promise
10-
without type erasure. You should chain `then` and `catch` in those cases to avoid type erasure.
11-
*/
1+
/// A wrapper around [the JavaScript `Promise` class](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise)
122
public final class JSPromise: JSBridgedClass {
133
/// The underlying JavaScript `Promise` object.
144
public let jsObject: JSObject
@@ -27,25 +17,27 @@ public final class JSPromise: JSBridgedClass {
2717
jsObject = object
2818
}
2919

30-
/** Creates a new `JSPromise` instance from a given JavaScript `Promise` object. If `jsObject`
31-
is not an instance of JavaScript `Promise`, this initializer will return `nil`.
32-
*/
20+
/// Creates a new `JSPromise` instance from a given JavaScript `Promise` object. If `jsObject`
21+
/// is not an instance of JavaScript `Promise`, this initializer will return `nil`.
3322
public convenience init?(_ jsObject: JSObject) {
3423
self.init(from: jsObject)
3524
}
3625

37-
/** Creates a new `JSPromise` instance from a given JavaScript `Promise` object. If `value`
38-
is not an object and is not an instance of JavaScript `Promise`, this function will
39-
return `nil`.
40-
*/
26+
/// Creates a new `JSPromise` instance from a given JavaScript `Promise` object. If `value`
27+
/// is not an object and is not an instance of JavaScript `Promise`, this function will
28+
/// return `nil`.
4129
public static func construct(from value: JSValue) -> Self? {
4230
guard case let .object(jsObject) = value else { return nil }
4331
return Self(jsObject)
4432
}
4533

46-
/** Creates a new `JSPromise` instance from a given `resolver` closure. `resolver` takes
47-
two closure that your code should call to either resolve or reject this `JSPromise` instance.
48-
*/
34+
/// Creates a new `JSPromise` instance from a given `resolver` closure.
35+
/// The closure is passed a completion handler. Passing a successful
36+
/// `Result` to the completion handler will cause the promise to resolve
37+
/// with the corresponding value; passing a failure `Result` will cause the
38+
/// promise to reject with the corresponding value.
39+
/// Calling the completion handler more than once will have no effect
40+
/// (per the JavaScript specification).
4941
public convenience init(resolver: @escaping (@escaping (Result<JSValue, JSValue>) -> Void) -> Void) {
5042
let closure = JSOneshotClosure { arguments in
5143
// The arguments are always coming from the `Promise` constructor, so we should be
@@ -74,8 +66,7 @@ public final class JSPromise: JSBridgedClass {
7466
self.init(unsafelyWrapping: Self.constructor!.reject!(reason).object!)
7567
}
7668

77-
/** Schedules the `success` closure to be invoked on sucessful completion of `self`.
78-
*/
69+
/// Schedules the `success` closure to be invoked on successful completion of `self`.
7970
@discardableResult
8071
public func then(success: @escaping (JSValue) -> ConvertibleToJSValue) -> JSPromise {
8172
let closure = JSOneshotClosure {
@@ -84,8 +75,19 @@ public final class JSPromise: JSBridgedClass {
8475
return JSPromise(unsafelyWrapping: jsObject.then!(closure).object!)
8576
}
8677

87-
/** Schedules the `success` closure to be invoked on sucessful completion of `self`.
88-
*/
78+
#if compiler(>=5.5)
79+
/// Schedules the `success` closure to be invoked on successful completion of `self`.
80+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
81+
@discardableResult
82+
public func then(success: @escaping (JSValue) async throws -> ConvertibleToJSValue) -> JSPromise {
83+
let closure = JSOneshotClosure.async {
84+
try await success($0[0]).jsValue
85+
}
86+
return JSPromise(unsafelyWrapping: jsObject.then!(closure).object!)
87+
}
88+
#endif
89+
90+
/// Schedules the `success` closure to be invoked on successful completion of `self`.
8991
@discardableResult
9092
public func then(
9193
success: @escaping (JSValue) -> ConvertibleToJSValue,
@@ -100,8 +102,24 @@ public final class JSPromise: JSBridgedClass {
100102
return JSPromise(unsafelyWrapping: jsObject.then!(successClosure, failureClosure).object!)
101103
}
102104

103-
/** Schedules the `failure` closure to be invoked on rejected completion of `self`.
104-
*/
105+
#if compiler(>=5.5)
106+
/// Schedules the `success` closure to be invoked on successful completion of `self`.
107+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
108+
@discardableResult
109+
public func then(success: @escaping (JSValue) async throws -> ConvertibleToJSValue,
110+
failure: @escaping (JSValue) async throws -> ConvertibleToJSValue) -> JSPromise
111+
{
112+
let successClosure = JSOneshotClosure.async {
113+
try await success($0[0]).jsValue
114+
}
115+
let failureClosure = JSOneshotClosure.async {
116+
try await failure($0[0]).jsValue
117+
}
118+
return JSPromise(unsafelyWrapping: jsObject.then!(successClosure, failureClosure).object!)
119+
}
120+
#endif
121+
122+
/// Schedules the `failure` closure to be invoked on rejected completion of `self`.
105123
@discardableResult
106124
public func `catch`(failure: @escaping (JSValue) -> ConvertibleToJSValue) -> JSPromise {
107125
let closure = JSOneshotClosure {
@@ -110,9 +128,20 @@ public final class JSPromise: JSBridgedClass {
110128
return .init(unsafelyWrapping: jsObject.catch!(closure).object!)
111129
}
112130

113-
/** Schedules the `failure` closure to be invoked on either successful or rejected completion of
114-
`self`.
115-
*/
131+
#if compiler(>=5.5)
132+
/// Schedules the `failure` closure to be invoked on rejected completion of `self`.
133+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
134+
@discardableResult
135+
public func `catch`(failure: @escaping (JSValue) async throws -> ConvertibleToJSValue) -> JSPromise {
136+
let closure = JSOneshotClosure.async {
137+
try await failure($0[0]).jsValue
138+
}
139+
return .init(unsafelyWrapping: jsObject.catch!(closure).object!)
140+
}
141+
#endif
142+
143+
/// Schedules the `failure` closure to be invoked on either successful or rejected
144+
/// completion of `self`.
116145
@discardableResult
117146
public func finally(successOrFailure: @escaping () -> Void) -> JSPromise {
118147
let closure = JSOneshotClosure { _ in

Sources/JavaScriptKit/FundamentalObjects/JSClosure.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ public class JSOneshotClosure: JSObject, JSClosureProtocol {
3232
})
3333
}
3434

35+
#if compiler(>=5.5)
36+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
37+
public static func async(_ body: @escaping ([JSValue]) async throws -> JSValue) -> JSOneshotClosure {
38+
JSOneshotClosure(makeAsyncClosure(body))
39+
}
40+
#endif
41+
3542
/// Release this function resource.
3643
/// After calling `release`, calling this function from JavaScript will fail.
3744
public func release() {
@@ -88,6 +95,13 @@ public class JSClosure: JSObject, JSClosureProtocol {
8895
Self.sharedClosures[hostFuncRef] = (self, body)
8996
}
9097

98+
#if compiler(>=5.5)
99+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
100+
public static func async(_ body: @escaping ([JSValue]) async throws -> JSValue) -> JSClosure {
101+
JSClosure(makeAsyncClosure(body))
102+
}
103+
#endif
104+
91105
#if JAVASCRIPTKIT_WITHOUT_WEAKREFS
92106
deinit {
93107
guard isReleased else {
@@ -97,6 +111,27 @@ public class JSClosure: JSObject, JSClosureProtocol {
97111
#endif
98112
}
99113

114+
#if compiler(>=5.5)
115+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
116+
private func makeAsyncClosure(_ body: @escaping ([JSValue]) async throws -> JSValue) -> (([JSValue]) -> JSValue) {
117+
{ arguments in
118+
JSPromise { resolver in
119+
Task {
120+
do {
121+
let result = try await body(arguments)
122+
resolver(.success(result))
123+
} catch {
124+
if let jsError = error as? JSError {
125+
resolver(.failure(jsError.jsValue))
126+
} else {
127+
resolver(.failure(JSError(message: String(describing: error)).jsValue))
128+
}
129+
}
130+
}
131+
}.jsValue()
132+
}
133+
}
134+
#endif
100135

101136
// MARK: - `JSClosure` mechanism note
102137
//

0 commit comments

Comments
 (0)