Skip to content

Commit 9995d2f

Browse files
Add async JSRemote body support
1 parent d315b69 commit 9995d2f

3 files changed

Lines changed: 238 additions & 18 deletions

File tree

Sources/JavaScriptEventLoop/JSRemote.swift

Lines changed: 193 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public struct JSRemote<T>: @unchecked Sendable {
6060
return try body(storage.sourceObject)
6161
}
6262
let result: Result<R, E> = await withCheckedContinuation { continuation in
63-
let context = _JSRemoteContext(
63+
let context = _JSRemoteSyncContext(
6464
sourceObject: storage.sourceObject,
6565
body: body,
6666
continuation: continuation
@@ -75,6 +75,33 @@ public struct JSRemote<T>: @unchecked Sendable {
7575
return try body(storage.sourceObject)
7676
#endif
7777
}
78+
79+
#if compiler(>=6.1) && hasFeature(Embedded) && _runtime(_multithreaded)
80+
#else
81+
fileprivate func _withJSObject<R: Sendable, E: Error>(
82+
_ body: @Sendable @escaping (JSObject) async throws(E) -> R
83+
) async throws(E) -> sending R {
84+
#if compiler(>=6.1) && _runtime(_multithreaded)
85+
if storage.sourceTid == swjs_get_worker_thread_id_cached() {
86+
return try await body(storage.sourceObject)
87+
}
88+
let result: Result<R, E> = await withCheckedContinuation { continuation in
89+
let context = _JSRemoteAsyncContext(
90+
sourceObject: storage.sourceObject,
91+
body: body,
92+
continuation: continuation
93+
)
94+
swjs_request_remote_jsobject_body(
95+
storage.sourceTid,
96+
Unmanaged.passRetained(context).toOpaque()
97+
)
98+
}
99+
return try result.get()
100+
#else
101+
return try await body(storage.sourceObject)
102+
#endif
103+
}
104+
#endif
78105
}
79106

80107
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
@@ -120,6 +147,35 @@ extension JSRemote where T == JSObject {
120147
) async throws(E) -> sending R {
121148
try await _withJSObject(body)
122149
}
150+
151+
#if compiler(>=6.1) && hasFeature(Embedded) && _runtime(_multithreaded)
152+
#else
153+
/// Performs an asynchronous operation with the underlying `JSObject` on its owning thread.
154+
///
155+
/// If the caller is already running on the thread that owns the object, `body` executes
156+
/// immediately. Otherwise, this method asynchronously requests execution on the owner and
157+
/// resumes when the closure completes.
158+
///
159+
/// Use this API when the object must stay on its original thread but producing a result
160+
/// requires suspending, such as awaiting a JavaScript promise.
161+
///
162+
/// ## Example
163+
///
164+
/// ```swift
165+
/// let value = try await remoteWindow.withJSObject { window in
166+
/// try await JSPromise(from: window.fetch!("/api").object!)!.value
167+
/// }
168+
/// ```
169+
///
170+
/// - Parameter body: A sendable asynchronous closure that receives the owned `JSObject`.
171+
/// - Returns: The value produced by `body`.
172+
/// - Throws: Any error thrown by `body`.
173+
public func withJSObject<R: Sendable, E: Error>(
174+
_ body: @Sendable @escaping (JSObject) async throws(E) -> R
175+
) async throws(E) -> sending R {
176+
try await _withJSObject(body)
177+
}
178+
#endif
123179
}
124180

125181
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
@@ -167,39 +223,157 @@ extension JSRemote where T: _JSBridgedClass {
167223
/// - Parameter body: A sendable closure that receives the owned `T` object.
168224
/// - Returns: The value produced by `body`.
169225
/// - Throws: Any error thrown by `body`.
170-
public func withJSObject<R: Sendable, E: Error>(
171-
_ body: @Sendable @escaping (T) throws(E) -> R
172-
) async throws(E) -> sending R where T: SendableMetatype {
173-
try await _withJSObject { jsObject throws(E) -> R in
174-
let object = T(unsafelyWrapping: jsObject)
175-
return try body(object)
226+
#if compiler(>=6.2)
227+
public func withJSObject<R: Sendable, E: Error>(
228+
_ body: @Sendable @escaping (T) throws(E) -> R
229+
) async throws(E) -> sending R where T: SendableMetatype {
230+
try await _withJSObject { jsObject throws(E) -> R in
231+
let object = T(unsafelyWrapping: jsObject)
232+
return try body(object)
233+
}
176234
}
177-
}
235+
#else
236+
public func withJSObject<R: Sendable, E: Error>(
237+
_ body: @Sendable @escaping (T) throws(E) -> R
238+
) async throws(E) -> sending R {
239+
try await _withJSObject { jsObject throws(E) -> R in
240+
let object = T(unsafelyWrapping: jsObject)
241+
return try body(object)
242+
}
243+
}
244+
#endif
245+
246+
#if compiler(>=6.1) && hasFeature(Embedded) && _runtime(_multithreaded)
247+
#else
248+
/// Performs an asynchronous operation with the underlying `T` object on its owning thread.
249+
///
250+
/// If the caller is already running on the thread that owns the object, `body` executes
251+
/// immediately. Otherwise, this method asynchronously requests execution on the owner and
252+
/// resumes when the closure completes.
253+
///
254+
/// Use this API when the object must stay on its original thread but producing a result
255+
/// requires suspending, such as awaiting a JavaScript promise.
256+
///
257+
/// ## Example
258+
///
259+
/// ```swift
260+
/// let response = try await remoteWindow.withJSObject { window in
261+
/// try await window.fetch("/api").value
262+
/// }
263+
/// ```
264+
///
265+
/// - Parameter body: A sendable asynchronous closure that receives the owned `T` object.
266+
/// - Returns: The value produced by `body`.
267+
/// - Throws: Any error thrown by `body`.
268+
#if compiler(>=6.2)
269+
public func withJSObject<R: Sendable, E: Error>(
270+
_ body: @Sendable @escaping (T) async throws(E) -> R
271+
) async throws(E) -> sending R where T: SendableMetatype {
272+
try await _withJSObject { jsObject async throws(E) -> R in
273+
let object = T(unsafelyWrapping: jsObject)
274+
return try await body(object)
275+
}
276+
}
277+
#else
278+
public func withJSObject<R: Sendable, E: Error>(
279+
_ body: @Sendable @escaping (T) async throws(E) -> R
280+
) async throws(E) -> sending R {
281+
try await _withJSObject { jsObject async throws(E) -> R in
282+
let object = T(unsafelyWrapping: jsObject)
283+
return try await body(object)
284+
}
285+
}
286+
#endif
287+
#endif
178288
}
179289

180290

181291
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
182-
private final class _JSRemoteContext: @unchecked Sendable {
183-
let invokeBody: () -> Bool
292+
private class _JSRemoteContext: @unchecked Sendable {
293+
fileprivate func invoke() {
294+
preconditionFailure("JSRemote context subclasses must override invoke()")
295+
}
296+
}
297+
298+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
299+
private final class _JSRemoteSyncContext<R: Sendable, E: Error>: _JSRemoteContext, @unchecked Sendable {
300+
let sourceObject: JSObject
301+
let body: @Sendable (JSObject) throws(E) -> R
302+
let continuation: CheckedContinuation<Result<R, E>, Never>
184303

185-
init<R: Sendable, E: Error>(
304+
init(
186305
sourceObject: JSObject,
187306
body: @escaping @Sendable (JSObject) throws(E) -> R,
188307
continuation: CheckedContinuation<Result<R, E>, Never>
189308
) {
190-
self.invokeBody = {
191-
// NOTE: Sendability violation here for `sourceObject`
309+
self.sourceObject = sourceObject
310+
self.body = body
311+
self.continuation = continuation
312+
}
313+
314+
override fileprivate func invoke() {
315+
// NOTE: Sendability violation here for `sourceObject`.
316+
// Even though `JSObject` is not Sendable, it is safe to access it here
317+
// because this method will only be executed on the owning thread.
318+
do throws(E) {
319+
continuation.resume(returning: .success(try body(sourceObject)))
320+
} catch {
321+
continuation.resume(returning: .failure(error))
322+
}
323+
}
324+
}
325+
326+
#if compiler(>=6.1) && hasFeature(Embedded) && _runtime(_multithreaded)
327+
#else
328+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
329+
private final class _JSRemoteAsyncContext<R: Sendable, E: Error>: _JSRemoteContext, @unchecked Sendable {
330+
let sourceObject: JSObject
331+
let body: @Sendable (JSObject) async throws(E) -> R
332+
let continuation: CheckedContinuation<Result<R, E>, Never>
333+
334+
init(
335+
sourceObject: JSObject,
336+
body: @escaping @Sendable (JSObject) async throws(E) -> R,
337+
continuation: CheckedContinuation<Result<R, E>, Never>
338+
) {
339+
self.sourceObject = sourceObject
340+
self.body = body
341+
self.continuation = continuation
342+
}
343+
344+
override fileprivate func invoke() {
345+
_runJSRemoteBody {
346+
await self.invokeAsync()
347+
}
348+
}
349+
350+
private func invokeAsync() async {
351+
// NOTE: Sendability violation here for `sourceObject`.
192352
// Even though `JSObject` is not Sendable, it is safe to access it here
193-
// because this invokeBody closure will only be executed on the owning thread.
353+
// because this method will only be executed on the owning thread.
194354
do throws(E) {
195-
continuation.resume(returning: .success(try body(sourceObject)))
355+
continuation.resume(returning: .success(try await body(sourceObject)))
196356
} catch {
197357
continuation.resume(returning: .failure(error))
198358
}
199-
return false
200359
}
201360
}
202-
}
361+
362+
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
363+
private func _runJSRemoteBody(_ body: @escaping @Sendable () async -> Void) {
364+
#if compiler(>=6.0) && !hasFeature(Embedded)
365+
if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) {
366+
Task(executorPreference: WebWorkerTaskExecutor.currentExecutorPreference) {
367+
await body()
368+
}
369+
return
370+
}
371+
#endif
372+
Task {
373+
await body()
374+
}
375+
}
376+
#endif
203377

204378
#if compiler(>=6.1)
205379
@_expose(wasm, "swjs_invoke_remote_jsobject_body")
@@ -211,7 +385,8 @@ func _swjs_invoke_remote_jsobject_body(_ contextPtr: UnsafeRawPointer?) -> Bool
211385
guard let contextPtr else { return true }
212386
let context = Unmanaged<_JSRemoteContext>.fromOpaque(contextPtr).takeRetainedValue()
213387

214-
return context.invokeBody()
388+
context.invoke()
389+
return false
215390
#else
216391
_ = contextPtr
217392
return true

Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ import WASILibc
114114
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) // For `Atomic` and `TaskExecutor` types
115115
public final class WebWorkerTaskExecutor: TaskExecutor {
116116

117+
internal static var currentExecutorPreference: (any TaskExecutor)? {
118+
Worker.currentThread?.parentTaskExecutor
119+
}
120+
117121
/// An error that occurs when spawning a worker thread fails.
118122
public struct SpawnError: Error {
119123
/// The reason for the error.

Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,27 @@ final class WebWorkerTaskExecutorTests: XCTestCase {
636636
XCTAssertEqual(value, 42)
637637
}
638638

639+
func testRemoteMainToWorkerAsyncBodyCanAwaitPromise() async throws {
640+
let object = JSObject.global.Object.function!.new()
641+
object["value"] = 42
642+
let remote = JSRemote(object)
643+
644+
let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1)
645+
defer { executor.terminate() }
646+
647+
let task = Task(executorPreference: executor) {
648+
try await remote.withJSObject { object in
649+
XCTAssertTrue(isMainThread())
650+
let value = try await JSPromise.resolve(object["value"]).value
651+
XCTAssertTrue(isMainThread())
652+
return Int(value.number!)
653+
}
654+
}
655+
656+
let value = try await task.value
657+
XCTAssertEqual(value, 42)
658+
}
659+
639660
func testRemoteWorkerToMainAccess() async throws {
640661
let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1)
641662
defer { executor.terminate() }
@@ -654,6 +675,26 @@ final class WebWorkerTaskExecutorTests: XCTestCase {
654675
XCTAssertEqual(result, 99)
655676
}
656677

678+
func testRemoteWorkerToMainAsyncBodyCanAwaitPromise() async throws {
679+
let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1)
680+
defer { executor.terminate() }
681+
682+
let task = Task(executorPreference: executor) {
683+
let object = JSObject.global.Object.function!.new()
684+
object["value"] = 99
685+
return JSRemote(object)
686+
}
687+
688+
let remote = await task.value
689+
let result = try await remote.withJSObject { object in
690+
XCTAssertFalse(isMainThread())
691+
let value = try await JSPromise.resolve(object["value"]).value
692+
XCTAssertFalse(isMainThread())
693+
return Int(value.number!)
694+
}
695+
XCTAssertEqual(result, 99)
696+
}
697+
657698
func testRemoteSameThreadFastPath() async throws {
658699
let object = JSObject.global.Object.function!.new()
659700
object["flag"] = 1

0 commit comments

Comments
 (0)