From d315b693cb75621495b41aee6be9fbae23aca155 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Sat, 16 May 2026 15:35:41 +0100 Subject: [PATCH 1/2] Add `@JSClass` support to `JSRemote` --- Sources/JavaScriptEventLoop/JSRemote.swift | 112 ++++++++++++++++----- 1 file changed, 88 insertions(+), 24 deletions(-) diff --git a/Sources/JavaScriptEventLoop/JSRemote.swift b/Sources/JavaScriptEventLoop/JSRemote.swift index 4f488d7b8..53ab25f6b 100644 --- a/Sources/JavaScriptEventLoop/JSRemote.swift +++ b/Sources/JavaScriptEventLoop/JSRemote.swift @@ -42,9 +42,39 @@ public struct JSRemote: @unchecked Sendable { private let storage: Storage - fileprivate init(sourceObject: JSObject, sourceTid: Int32) { + fileprivate init(sourceObject: JSObject) { + let sourceTid: Int32 + #if compiler(>=6.1) && _runtime(_multithreaded) + sourceTid = sourceObject.ownerTid + #else + sourceTid = -1 + #endif self.storage = Storage(sourceObject: sourceObject, sourceTid: sourceTid) } + + fileprivate func _withJSObject( + _ body: @Sendable @escaping (JSObject) throws(E) -> R + ) async throws(E) -> sending R { + #if compiler(>=6.1) && _runtime(_multithreaded) + if storage.sourceTid == swjs_get_worker_thread_id_cached() { + return try body(storage.sourceObject) + } + let result: Result = await withCheckedContinuation { continuation in + let context = _JSRemoteContext( + sourceObject: storage.sourceObject, + body: body, + continuation: continuation + ) + swjs_request_remote_jsobject_body( + storage.sourceTid, + Unmanaged.passRetained(context).toOpaque() + ) + } + return try result.get() + #else + return try body(storage.sourceObject) + #endif + } } @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) @@ -62,11 +92,7 @@ extension JSRemote where T == JSObject { /// /// - Parameter object: The JavaScript object to reference remotely. public init(_ object: JSObject) { - #if compiler(>=6.1) && _runtime(_multithreaded) - self.init(sourceObject: object, sourceTid: object.ownerTid) - #else - self.init(sourceObject: object, sourceTid: -1) - #endif + self.init(sourceObject: object) } /// Performs an operation with the underlying `JSObject` on its owning thread. @@ -92,28 +118,66 @@ extension JSRemote where T == JSObject { public func withJSObject( _ body: @Sendable @escaping (JSObject) throws(E) -> R ) async throws(E) -> sending R { - #if compiler(>=6.1) && _runtime(_multithreaded) - if storage.sourceTid == swjs_get_worker_thread_id_cached() { - return try body(storage.sourceObject) - } - let result: Result = await withCheckedContinuation { continuation in - let context = _JSRemoteContext( - sourceObject: storage.sourceObject, - body: body, - continuation: continuation - ) - swjs_request_remote_jsobject_body( - storage.sourceTid, - Unmanaged.passRetained(context).toOpaque() - ) + try await _withJSObject(body) + } +} + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +extension JSRemote where T: _JSBridgedClass { + /// Creates a remote handle for a `@JSClass`-imported object. + /// + /// The object remains owned by its current JavaScript thread. Access it later by calling + /// `withJSObject(_:)`, which executes the closure on the owning thread when necessary. + /// + /// ## Example + /// + /// ```swift + /// @JSClass struct Window { + /// @JSGetter var location: Location + /// } + /// let remoteWindow = JSRemote(Window(unsafelyWrapping: JSObject.global)) + /// remoteWindow.withJSObject { window in + /// print(window.location.href.string ?? "") + /// } + /// ``` + /// + /// - Parameter object: The JavaScript object to reference remotely. + public init(_ object: T) { + self.init(sourceObject: object.jsObject) + } + + + /// Performs an operation with the underlying `T` object on its owning thread. + /// + /// If the caller is already running on the thread that owns the object, `body` executes + /// immediately. Otherwise, this method asynchronously requests execution on the owner and + /// resumes when the closure completes. + /// + /// Use this API when the object must stay on its original thread but a result derived from + /// that object needs to be produced in another Swift concurrency context. + /// + /// ## Example + /// + /// ```swift + /// let location = try await remoteWindow.withJSObject { window in + /// window.location.href.string ?? "" + /// } + /// ``` + /// + /// - Parameter body: A sendable closure that receives the owned `T` object. + /// - Returns: The value produced by `body`. + /// - Throws: Any error thrown by `body`. + public func withJSObject( + _ body: @Sendable @escaping (T) throws(E) -> R + ) async throws(E) -> sending R where T: SendableMetatype { + try await _withJSObject { jsObject throws(E) -> R in + let object = T(unsafelyWrapping: jsObject) + return try body(object) } - return try result.get() - #else - return try body(storage.sourceObject) - #endif } } + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) private final class _JSRemoteContext: @unchecked Sendable { let invokeBody: () -> Bool From 9995d2f28054b46d52ef5f8a248ffdd4f7392edd Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Sat, 16 May 2026 17:12:18 +0100 Subject: [PATCH 2/2] Add async JSRemote body support --- Sources/JavaScriptEventLoop/JSRemote.swift | 211 ++++++++++++++++-- .../WebWorkerTaskExecutor.swift | 4 + .../WebWorkerTaskExecutorTests.swift | 41 ++++ 3 files changed, 238 insertions(+), 18 deletions(-) diff --git a/Sources/JavaScriptEventLoop/JSRemote.swift b/Sources/JavaScriptEventLoop/JSRemote.swift index 53ab25f6b..d22b92baa 100644 --- a/Sources/JavaScriptEventLoop/JSRemote.swift +++ b/Sources/JavaScriptEventLoop/JSRemote.swift @@ -60,7 +60,7 @@ public struct JSRemote: @unchecked Sendable { return try body(storage.sourceObject) } let result: Result = await withCheckedContinuation { continuation in - let context = _JSRemoteContext( + let context = _JSRemoteSyncContext( sourceObject: storage.sourceObject, body: body, continuation: continuation @@ -75,6 +75,33 @@ public struct JSRemote: @unchecked Sendable { return try body(storage.sourceObject) #endif } + + #if compiler(>=6.1) && hasFeature(Embedded) && _runtime(_multithreaded) + #else + fileprivate func _withJSObject( + _ body: @Sendable @escaping (JSObject) async throws(E) -> R + ) async throws(E) -> sending R { + #if compiler(>=6.1) && _runtime(_multithreaded) + if storage.sourceTid == swjs_get_worker_thread_id_cached() { + return try await body(storage.sourceObject) + } + let result: Result = await withCheckedContinuation { continuation in + let context = _JSRemoteAsyncContext( + sourceObject: storage.sourceObject, + body: body, + continuation: continuation + ) + swjs_request_remote_jsobject_body( + storage.sourceTid, + Unmanaged.passRetained(context).toOpaque() + ) + } + return try result.get() + #else + return try await body(storage.sourceObject) + #endif + } + #endif } @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) @@ -120,6 +147,35 @@ extension JSRemote where T == JSObject { ) async throws(E) -> sending R { try await _withJSObject(body) } + + #if compiler(>=6.1) && hasFeature(Embedded) && _runtime(_multithreaded) + #else + /// Performs an asynchronous operation with the underlying `JSObject` on its owning thread. + /// + /// If the caller is already running on the thread that owns the object, `body` executes + /// immediately. Otherwise, this method asynchronously requests execution on the owner and + /// resumes when the closure completes. + /// + /// Use this API when the object must stay on its original thread but producing a result + /// requires suspending, such as awaiting a JavaScript promise. + /// + /// ## Example + /// + /// ```swift + /// let value = try await remoteWindow.withJSObject { window in + /// try await JSPromise(from: window.fetch!("/api").object!)!.value + /// } + /// ``` + /// + /// - Parameter body: A sendable asynchronous closure that receives the owned `JSObject`. + /// - Returns: The value produced by `body`. + /// - Throws: Any error thrown by `body`. + public func withJSObject( + _ body: @Sendable @escaping (JSObject) async throws(E) -> R + ) async throws(E) -> sending R { + try await _withJSObject(body) + } + #endif } @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) @@ -167,39 +223,157 @@ extension JSRemote where T: _JSBridgedClass { /// - Parameter body: A sendable closure that receives the owned `T` object. /// - Returns: The value produced by `body`. /// - Throws: Any error thrown by `body`. - public func withJSObject( - _ body: @Sendable @escaping (T) throws(E) -> R - ) async throws(E) -> sending R where T: SendableMetatype { - try await _withJSObject { jsObject throws(E) -> R in - let object = T(unsafelyWrapping: jsObject) - return try body(object) + #if compiler(>=6.2) + public func withJSObject( + _ body: @Sendable @escaping (T) throws(E) -> R + ) async throws(E) -> sending R where T: SendableMetatype { + try await _withJSObject { jsObject throws(E) -> R in + let object = T(unsafelyWrapping: jsObject) + return try body(object) + } } - } + #else + public func withJSObject( + _ body: @Sendable @escaping (T) throws(E) -> R + ) async throws(E) -> sending R { + try await _withJSObject { jsObject throws(E) -> R in + let object = T(unsafelyWrapping: jsObject) + return try body(object) + } + } + #endif + + #if compiler(>=6.1) && hasFeature(Embedded) && _runtime(_multithreaded) + #else + /// Performs an asynchronous operation with the underlying `T` object on its owning thread. + /// + /// If the caller is already running on the thread that owns the object, `body` executes + /// immediately. Otherwise, this method asynchronously requests execution on the owner and + /// resumes when the closure completes. + /// + /// Use this API when the object must stay on its original thread but producing a result + /// requires suspending, such as awaiting a JavaScript promise. + /// + /// ## Example + /// + /// ```swift + /// let response = try await remoteWindow.withJSObject { window in + /// try await window.fetch("/api").value + /// } + /// ``` + /// + /// - Parameter body: A sendable asynchronous closure that receives the owned `T` object. + /// - Returns: The value produced by `body`. + /// - Throws: Any error thrown by `body`. + #if compiler(>=6.2) + public func withJSObject( + _ body: @Sendable @escaping (T) async throws(E) -> R + ) async throws(E) -> sending R where T: SendableMetatype { + try await _withJSObject { jsObject async throws(E) -> R in + let object = T(unsafelyWrapping: jsObject) + return try await body(object) + } + } + #else + public func withJSObject( + _ body: @Sendable @escaping (T) async throws(E) -> R + ) async throws(E) -> sending R { + try await _withJSObject { jsObject async throws(E) -> R in + let object = T(unsafelyWrapping: jsObject) + return try await body(object) + } + } + #endif + #endif } @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) -private final class _JSRemoteContext: @unchecked Sendable { - let invokeBody: () -> Bool +private class _JSRemoteContext: @unchecked Sendable { + fileprivate func invoke() { + preconditionFailure("JSRemote context subclasses must override invoke()") + } +} + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +private final class _JSRemoteSyncContext: _JSRemoteContext, @unchecked Sendable { + let sourceObject: JSObject + let body: @Sendable (JSObject) throws(E) -> R + let continuation: CheckedContinuation, Never> - init( + init( sourceObject: JSObject, body: @escaping @Sendable (JSObject) throws(E) -> R, continuation: CheckedContinuation, Never> ) { - self.invokeBody = { - // NOTE: Sendability violation here for `sourceObject` + self.sourceObject = sourceObject + self.body = body + self.continuation = continuation + } + + override fileprivate func invoke() { + // NOTE: Sendability violation here for `sourceObject`. + // Even though `JSObject` is not Sendable, it is safe to access it here + // because this method will only be executed on the owning thread. + do throws(E) { + continuation.resume(returning: .success(try body(sourceObject))) + } catch { + continuation.resume(returning: .failure(error)) + } + } +} + +#if compiler(>=6.1) && hasFeature(Embedded) && _runtime(_multithreaded) +#else + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + private final class _JSRemoteAsyncContext: _JSRemoteContext, @unchecked Sendable { + let sourceObject: JSObject + let body: @Sendable (JSObject) async throws(E) -> R + let continuation: CheckedContinuation, Never> + + init( + sourceObject: JSObject, + body: @escaping @Sendable (JSObject) async throws(E) -> R, + continuation: CheckedContinuation, Never> + ) { + self.sourceObject = sourceObject + self.body = body + self.continuation = continuation + } + + override fileprivate func invoke() { + _runJSRemoteBody { + await self.invokeAsync() + } + } + + private func invokeAsync() async { + // NOTE: Sendability violation here for `sourceObject`. // Even though `JSObject` is not Sendable, it is safe to access it here - // because this invokeBody closure will only be executed on the owning thread. + // because this method will only be executed on the owning thread. do throws(E) { - continuation.resume(returning: .success(try body(sourceObject))) + continuation.resume(returning: .success(try await body(sourceObject))) } catch { continuation.resume(returning: .failure(error)) } - return false } } -} + + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + private func _runJSRemoteBody(_ body: @escaping @Sendable () async -> Void) { + #if compiler(>=6.0) && !hasFeature(Embedded) + if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) { + Task(executorPreference: WebWorkerTaskExecutor.currentExecutorPreference) { + await body() + } + return + } + #endif + Task { + await body() + } + } +#endif #if compiler(>=6.1) @_expose(wasm, "swjs_invoke_remote_jsobject_body") @@ -211,7 +385,8 @@ func _swjs_invoke_remote_jsobject_body(_ contextPtr: UnsafeRawPointer?) -> Bool guard let contextPtr else { return true } let context = Unmanaged<_JSRemoteContext>.fromOpaque(contextPtr).takeRetainedValue() - return context.invokeBody() + context.invoke() + return false #else _ = contextPtr return true diff --git a/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift b/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift index b827ad980..b40e895d3 100644 --- a/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift +++ b/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift @@ -114,6 +114,10 @@ import WASILibc @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) // For `Atomic` and `TaskExecutor` types public final class WebWorkerTaskExecutor: TaskExecutor { + internal static var currentExecutorPreference: (any TaskExecutor)? { + Worker.currentThread?.parentTaskExecutor + } + /// An error that occurs when spawning a worker thread fails. public struct SpawnError: Error { /// The reason for the error. diff --git a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift index 69b3390dc..636de93d7 100644 --- a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift +++ b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift @@ -636,6 +636,27 @@ final class WebWorkerTaskExecutorTests: XCTestCase { XCTAssertEqual(value, 42) } + func testRemoteMainToWorkerAsyncBodyCanAwaitPromise() async throws { + let object = JSObject.global.Object.function!.new() + object["value"] = 42 + let remote = JSRemote(object) + + let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + defer { executor.terminate() } + + let task = Task(executorPreference: executor) { + try await remote.withJSObject { object in + XCTAssertTrue(isMainThread()) + let value = try await JSPromise.resolve(object["value"]).value + XCTAssertTrue(isMainThread()) + return Int(value.number!) + } + } + + let value = try await task.value + XCTAssertEqual(value, 42) + } + func testRemoteWorkerToMainAccess() async throws { let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) defer { executor.terminate() } @@ -654,6 +675,26 @@ final class WebWorkerTaskExecutorTests: XCTestCase { XCTAssertEqual(result, 99) } + func testRemoteWorkerToMainAsyncBodyCanAwaitPromise() async throws { + let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + defer { executor.terminate() } + + let task = Task(executorPreference: executor) { + let object = JSObject.global.Object.function!.new() + object["value"] = 99 + return JSRemote(object) + } + + let remote = await task.value + let result = try await remote.withJSObject { object in + XCTAssertFalse(isMainThread()) + let value = try await JSPromise.resolve(object["value"]).value + XCTAssertFalse(isMainThread()) + return Int(value.number!) + } + XCTAssertEqual(result, 99) + } + func testRemoteSameThreadFastPath() async throws { let object = JSObject.global.Object.function!.new() object["flag"] = 1