From 5887f87a46ee309006f6f74e4a94fb121ab45c2b Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Tue, 31 Mar 2026 11:34:45 +0000 Subject: [PATCH] Add `JSRemote` API for accessing JSObjects without transfer The new API allows creating a handle for a `JSObject` that remains on its original JavaScript thread and hopping back to that thread to access the object when needed. This is useful for cases where the object cannot be transferred to another thread, but occasional access is still required or when we want to guarantee that an object is always accessed on the same thread for safety (it should be statically guaranteed with strict Sendable checking, but modules with language mode 5 don't have that). Example: ```swift let document = JSObject.global.document.object! let remoteDocument = JSRemote(document) let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) let title = try await Task(executorPreference: executor) { try await remoteDocument.withJSObject { document in document.title.string ?? "" } }.value ``` --- Examples/ActorOnWebWorker/Package.swift | 2 +- Examples/Multithreading/Package.swift | 2 +- Examples/OffscrenCanvas/Package.swift | 2 +- Plugins/PackageToJS/Templates/runtime.d.ts | 6 + Plugins/PackageToJS/Templates/runtime.mjs | 80 +++++++-- Runtime/src/index.ts | 125 +++++++++++--- Runtime/src/itc.ts | 9 + Runtime/src/types.ts | 1 + Sources/JavaScriptEventLoop/JSRemote.swift | 155 ++++++++++++++++++ .../_CJavaScriptKit/include/_CJavaScriptKit.h | 5 + .../WebWorkerTaskExecutorTests.swift | 146 +++++++++++++++++ 11 files changed, 495 insertions(+), 38 deletions(-) create mode 100644 Sources/JavaScriptEventLoop/JSRemote.swift diff --git a/Examples/ActorOnWebWorker/Package.swift b/Examples/ActorOnWebWorker/Package.swift index 82e87dfdc..04441a9a3 100644 --- a/Examples/ActorOnWebWorker/Package.swift +++ b/Examples/ActorOnWebWorker/Package.swift @@ -6,7 +6,7 @@ let package = Package( name: "Example", platforms: [.macOS("15"), .iOS("18"), .watchOS("11"), .tvOS("18"), .visionOS("2")], dependencies: [ - .package(path: "../../") + .package(name: "JavaScriptKit", path: "../../") ], targets: [ .executableTarget( diff --git a/Examples/Multithreading/Package.swift b/Examples/Multithreading/Package.swift index 4d1ebde70..ca147c5d8 100644 --- a/Examples/Multithreading/Package.swift +++ b/Examples/Multithreading/Package.swift @@ -6,7 +6,7 @@ let package = Package( name: "Example", platforms: [.macOS("15"), .iOS("18"), .watchOS("11"), .tvOS("18"), .visionOS("2")], dependencies: [ - .package(path: "../../"), + .package(name: "JavaScriptKit", path: "../../"), .package( url: "https://github.com/kateinoigakukun/chibi-ray", revision: "c8cab621a3338dd2f8e817d3785362409d3b8cf1" diff --git a/Examples/OffscrenCanvas/Package.swift b/Examples/OffscrenCanvas/Package.swift index ca6d7357f..54fa57747 100644 --- a/Examples/OffscrenCanvas/Package.swift +++ b/Examples/OffscrenCanvas/Package.swift @@ -6,7 +6,7 @@ let package = Package( name: "Example", platforms: [.macOS("15"), .iOS("18"), .watchOS("11"), .tvOS("18"), .visionOS("2")], dependencies: [ - .package(path: "../../") + .package(name: "JavaScriptKit", path: "../../") ], targets: [ .executableTarget( diff --git a/Plugins/PackageToJS/Templates/runtime.d.ts b/Plugins/PackageToJS/Templates/runtime.d.ts index 353db3894..87cbeea72 100644 --- a/Plugins/PackageToJS/Templates/runtime.d.ts +++ b/Plugins/PackageToJS/Templates/runtime.d.ts @@ -97,6 +97,10 @@ declare class ITCInterface { sendingContext: pointer; transfer: Transferable[]; }; + invokeRemoteJSObjectBody(invocationContext: pointer): { + object: undefined; + transfer: Transferable[]; + }; release(objectRef: ref): { object: undefined; transfer: Transferable[]; @@ -140,6 +144,8 @@ type ResponseMessage = { sourceTid: number; /** The context pointer of the request */ context: pointer; + /** The request method this response corresponds to */ + requestMethod: keyof ITCInterface; /** The response content */ response: { ok: true; diff --git a/Plugins/PackageToJS/Templates/runtime.mjs b/Plugins/PackageToJS/Templates/runtime.mjs index d79275476..ab85e7893 100644 --- a/Plugins/PackageToJS/Templates/runtime.mjs +++ b/Plugins/PackageToJS/Templates/runtime.mjs @@ -135,6 +135,9 @@ class ITCInterface { const transfer = transferringObjects.map((ref) => this.memory.getObject(ref)); return { object: objects, sendingContext, transfer }; } + invokeRemoteJSObjectBody(invocationContext) { + return { object: undefined, transfer: [] }; + } release(objectRef) { this.memory.release(objectRef); return { object: undefined, transfer: [] }; @@ -455,13 +458,51 @@ class SwiftRuntime { if (broker) return broker; const itcInterface = new ITCInterface(this.memory); + const defaultRequestHandler = (message) => { + const request = message.data.request; + // @ts-ignore dynamic dispatch by method name + const result = itcInterface[request.method].apply(itcInterface, request.parameters); + return { ok: true, value: result }; + }; + const requestHandlers = { + invokeRemoteJSObjectBody: (message) => { + const invocationContext = message.data.request + .parameters[0]; + const hasError = this.exports.swjs_invoke_remote_jsobject_body(invocationContext); + return { + ok: true, + value: { + object: hasError, + sendingContext: message.data.context, + transfer: [], + }, + }; + }, + }; + const defaultResponseHandler = (message) => { + if (message.data.response.ok) { + const object = this.memory.retain(message.data.response.value.object); + this.exports.swjs_receive_response(object, message.data.context); + } + else { + const error = deserializeError(message.data.response.error); + const errorObject = this.memory.retain(error); + this.exports.swjs_receive_error(errorObject, message.data.context); + } + }; + const responseHandlers = { + invokeRemoteJSObjectBody: (_message) => { + // Swift continuation is resumed on the owner thread. + }, + }; const newBroker = new MessageBroker((_a = this.tid) !== null && _a !== void 0 ? _a : -1, threadChannel, { onRequest: (message) => { + var _a; let returnValue; try { - // @ts-ignore - const result = itcInterface[message.data.request.method](...message.data.request.parameters); - returnValue = { ok: true, value: result }; + const method = message.data.request.method; + const handler = (_a = requestHandlers[method]) !== null && _a !== void 0 ? _a : defaultRequestHandler; + returnValue = handler(message); } catch (error) { returnValue = { @@ -474,6 +515,7 @@ class SwiftRuntime { data: { sourceTid: message.data.sourceTid, context: message.data.context, + requestMethod: message.data.request.method, response: returnValue, }, }; @@ -489,15 +531,10 @@ class SwiftRuntime { } }, onResponse: (message) => { - if (message.data.response.ok) { - const object = this.memory.retain(message.data.response.value.object); - this.exports.swjs_receive_response(object, message.data.context); - } - else { - const error = deserializeError(message.data.response.error); - const errorObject = this.memory.retain(error); - this.exports.swjs_receive_error(errorObject, message.data.context); - } + var _a; + const method = message.data.requestMethod; + const handler = (_a = responseHandlers[method]) !== null && _a !== void 0 ? _a : defaultResponseHandler; + handler(message); }, }); broker = newBroker; @@ -842,6 +879,25 @@ class SwiftRuntime { }, }); }, + swjs_request_remote_jsobject_body: (object_source_tid, invocation_context) => { + var _a; + if (!this.options.threadChannel) { + throw new Error("threadChannel is not set in options given to SwiftRuntime. Please set it to request remote JSObject access."); + } + const broker = getMessageBroker(this.options.threadChannel); + broker.request({ + type: "request", + data: { + sourceTid: (_a = this.tid) !== null && _a !== void 0 ? _a : MAIN_THREAD_TID, + targetTid: object_source_tid, + context: invocation_context, + request: { + method: "invokeRemoteJSObjectBody", + parameters: [invocation_context], + }, + }, + }); + }, }; } postMessageToMainThread(message, transfer = []) { diff --git a/Runtime/src/index.ts b/Runtime/src/index.ts index 5d6fe258f..7d75a6801 100644 --- a/Runtime/src/index.ts +++ b/Runtime/src/index.ts @@ -11,6 +11,7 @@ import { deserializeError, MainToWorkerMessage, MessageBroker, + RequestMessage, ResponseMessage, ITCInterface, serializeError, @@ -265,29 +266,98 @@ export class SwiftRuntime { const getMessageBroker = (threadChannel: SwiftRuntimeThreadChannel) => { if (broker) return broker; const itcInterface = new ITCInterface(this.memory); + type ITCMethodName = keyof ITCInterface; + + const defaultRequestHandler = ( + message: RequestMessage, + ): ResponseMessage["data"]["response"] => { + const request = message.data.request; + // @ts-ignore dynamic dispatch by method name + const result = itcInterface[request.method].apply( + itcInterface, + request.parameters as any[], + ); + return { ok: true, value: result }; + }; + + const requestHandlers: Partial< + Record< + ITCMethodName, + ( + message: RequestMessage, + ) => ResponseMessage["data"]["response"] + > + > = { + invokeRemoteJSObjectBody: (message) => { + const invocationContext = message.data.request + .parameters[0] as pointer; + const hasError = + this.exports.swjs_invoke_remote_jsobject_body( + invocationContext, + ); + return { + ok: true, + value: { + object: hasError, + sendingContext: message.data.context, + transfer: [], + }, + }; + }, + }; + + const defaultResponseHandler = (message: ResponseMessage) => { + if (message.data.response.ok) { + const object = this.memory.retain( + message.data.response.value.object, + ); + this.exports.swjs_receive_response( + object, + message.data.context, + ); + } else { + const error = deserializeError(message.data.response.error); + const errorObject = this.memory.retain(error); + this.exports.swjs_receive_error( + errorObject, + message.data.context, + ); + } + }; + + const responseHandlers: Partial< + Record void> + > = { + invokeRemoteJSObjectBody: (_message) => { + // Swift continuation is resumed on the owner thread. + }, + }; + const newBroker = new MessageBroker(this.tid ?? -1, threadChannel, { onRequest: (message) => { let returnValue: ResponseMessage["data"]["response"]; try { - // @ts-ignore - const result = itcInterface[ - message.data.request.method - ](...message.data.request.parameters); - returnValue = { ok: true, value: result }; + const method = message.data.request.method; + const handler = + requestHandlers[method] ?? defaultRequestHandler; + returnValue = handler(message); } catch (error) { returnValue = { ok: false, error: serializeError(error), }; } + const responseMessage: ResponseMessage = { type: "response", data: { sourceTid: message.data.sourceTid, context: message.data.context, + requestMethod: message.data.request.method, response: returnValue, }, }; + try { newBroker.reply(responseMessage); } catch (error) { @@ -303,24 +373,10 @@ export class SwiftRuntime { } }, onResponse: (message) => { - if (message.data.response.ok) { - const object = this.memory.retain( - message.data.response.value.object, - ); - this.exports.swjs_receive_response( - object, - message.data.context, - ); - } else { - const error = deserializeError( - message.data.response.error, - ); - const errorObject = this.memory.retain(error); - this.exports.swjs_receive_error( - errorObject, - message.data.context, - ); - } + const method = message.data.requestMethod; + const handler = + responseHandlers[method] ?? defaultResponseHandler; + handler(message); }, }); broker = newBroker; @@ -934,6 +990,29 @@ export class SwiftRuntime { }, }); }, + swjs_request_remote_jsobject_body: ( + object_source_tid: number, + invocation_context: pointer, + ) => { + if (!this.options.threadChannel) { + throw new Error( + "threadChannel is not set in options given to SwiftRuntime. Please set it to request remote JSObject access.", + ); + } + const broker = getMessageBroker(this.options.threadChannel); + broker.request({ + type: "request", + data: { + sourceTid: this.tid ?? MAIN_THREAD_TID, + targetTid: object_source_tid, + context: invocation_context, + request: { + method: "invokeRemoteJSObjectBody", + parameters: [invocation_context], + }, + }, + }); + }, }; } diff --git a/Runtime/src/itc.ts b/Runtime/src/itc.ts index 9fadff54a..5d110762b 100644 --- a/Runtime/src/itc.ts +++ b/Runtime/src/itc.ts @@ -117,6 +117,13 @@ export class ITCInterface { return { object: objects, sendingContext, transfer }; } + invokeRemoteJSObjectBody(invocationContext: pointer): { + object: undefined; + transfer: Transferable[]; + } { + return { object: undefined, transfer: [] }; + } + release(objectRef: ref): { object: undefined; transfer: Transferable[] } { this.memory.release(objectRef); return { object: undefined, transfer: [] }; @@ -163,6 +170,8 @@ export type ResponseMessage = { sourceTid: number; /** The context pointer of the request */ context: pointer; + /** The request method this response corresponds to */ + requestMethod: keyof ITCInterface; /** The response content */ response: | { diff --git a/Runtime/src/types.ts b/Runtime/src/types.ts index b39e949b2..bc83fcd22 100644 --- a/Runtime/src/types.ts +++ b/Runtime/src/types.ts @@ -22,6 +22,7 @@ export interface ExportedFunctions { swjs_wake_worker_thread(): void; swjs_receive_response(object: ref, transferring: pointer): void; swjs_receive_error(error: ref, context: number): void; + swjs_invoke_remote_jsobject_body(context: pointer): number; } export const enum LibraryFeatures { diff --git a/Sources/JavaScriptEventLoop/JSRemote.swift b/Sources/JavaScriptEventLoop/JSRemote.swift new file mode 100644 index 000000000..4f488d7b8 --- /dev/null +++ b/Sources/JavaScriptEventLoop/JSRemote.swift @@ -0,0 +1,155 @@ +import _Concurrency +@_spi(JSObject_id) import JavaScriptKit +import _CJavaScriptKit + +/// A sendable handle for temporarily accessing a `JSObject` on its owning thread. +/// +/// `JSRemote` lets you share a reference to a JavaScript object across Swift concurrency +/// domains without transferring or cloning the object itself. Instead, the object stays +/// owned by its original JavaScript thread, and `withJSObject(_:)` schedules a closure to +/// run on that owner when needed. +/// +/// This is useful when you need occasional coordinated access to a JavaScript object from +/// another thread, but cannot or should not move the object with `JSSending`. +/// +/// - Note: `JSRemote` does not make the underlying `JSObject` itself thread-safe. The object +/// may only be touched inside `withJSObject(_:)`. +/// +/// ## Example +/// +/// ```swift +/// let document = JSObject.global.document.object! +/// let remoteDocument = JSRemote(document) +/// +/// let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) +/// let title = try await Task(executorPreference: executor) { +/// try await remoteDocument.withJSObject { document in +/// document.title.string ?? "" +/// } +/// }.value +/// ``` +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +public struct JSRemote: @unchecked Sendable { + private final class Storage { + let sourceObject: JSObject + let sourceTid: Int32 + + init(sourceObject: JSObject, sourceTid: Int32) { + self.sourceObject = sourceObject + self.sourceTid = sourceTid + } + } + + private let storage: Storage + + fileprivate init(sourceObject: JSObject, sourceTid: Int32) { + self.storage = Storage(sourceObject: sourceObject, sourceTid: sourceTid) + } +} + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +extension JSRemote where T == JSObject { + /// Creates a remote handle for a `JSObject`. + /// + /// 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 + /// let remoteWindow = JSRemote(JSObject.global) + /// ``` + /// + /// - 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 + } + + /// Performs an 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 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 `JSObject`. + /// - Returns: The value produced by `body`. + /// - Throws: Any error thrown by `body`. + 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() + ) + } + 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 + + init( + sourceObject: JSObject, + body: @escaping @Sendable (JSObject) throws(E) -> R, + continuation: CheckedContinuation, Never> + ) { + self.invokeBody = { + // 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. + do throws(E) { + continuation.resume(returning: .success(try body(sourceObject))) + } catch { + continuation.resume(returning: .failure(error)) + } + return false + } + } +} + +#if compiler(>=6.1) +@_expose(wasm, "swjs_invoke_remote_jsobject_body") +@_cdecl("swjs_invoke_remote_jsobject_body") +#endif +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +func _swjs_invoke_remote_jsobject_body(_ contextPtr: UnsafeRawPointer?) -> Bool { + #if compiler(>=6.1) && _runtime(_multithreaded) + guard let contextPtr else { return true } + let context = Unmanaged<_JSRemoteContext>.fromOpaque(contextPtr).takeRetainedValue() + + return context.invokeBody() + #else + _ = contextPtr + return true + #endif +} diff --git a/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h b/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h index 28e7b5e3d..3800a6d9e 100644 --- a/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h +++ b/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h @@ -355,4 +355,9 @@ IMPORT_JS_FUNCTION(swjs_request_sending_objects, void, (const JavaScriptObjectRe int object_source_tid, void * _Nonnull sending_context)) +/// Requests invoking a Swift closure associated with `invocation_context` on `object_source_tid`. +/// This must be called from a non-owner thread and will asynchronously notify completion. +IMPORT_JS_FUNCTION(swjs_request_remote_jsobject_body, void, (int object_source_tid, + void * _Nonnull invocation_context)) + #endif /* _CJavaScriptKit_h */ diff --git a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift index f743d8ef0..54559f3d8 100644 --- a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift +++ b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift @@ -618,6 +618,152 @@ final class WebWorkerTaskExecutorTests: XCTestCase { XCTAssertEqual(object["test"].string!, "Hello, World!") } + func testRemoteMainToWorkerAccess() 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 + object["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() } + + let task = Task(executorPreference: executor) { + let object = JSObject.global.Object.function!.new() + object["value"] = 99 + let remote = JSRemote(object) + return remote + } + + let remote = await task.value + let result = try await remote.withJSObject { object in + object["value"].number! + } + XCTAssertEqual(result, 99) + } + + func testRemoteSameThreadFastPath() async throws { + let object = JSObject.global.Object.function!.new() + object["flag"] = 1 + let remote = JSRemote(object) + + let result = try await remote.withJSObject { object in + object["flag"].number! + } + XCTAssertEqual(result, 1) + } + + func testRemoteCanBeUsedMultipleTimesAcrossThreads() async throws { + let object = JSObject.global.Object.function!.new() + object["count"] = 0 + let remote = JSRemote(object) + + let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + defer { executor.terminate() } + + let task = Task(executorPreference: executor) { + let first = try await remote.withJSObject { object in + let nextValue = object["count"].number! + 1 + object["count"] = .number(nextValue) + return nextValue + } + let second = try await remote.withJSObject { object in + let nextValue = object["count"].number! + 1 + object["count"] = .number(nextValue) + return nextValue + } + return (first, second) + } + + let (first, second) = try await task.value + XCTAssertEqual(first, 1) + XCTAssertEqual(second, 2) + XCTAssertEqual(object["count"].number!, 2) + } + + func testRemoteConcurrentAccessFromWorkerTasks() async throws { + let object = JSObject.global.Object.function!.new() + object["value"] = 42 + let remote = JSRemote(object) + + let executor = try await WebWorkerTaskExecutor(numberOfThreads: 2) + defer { executor.terminate() } + + let results = try await withThrowingTaskGroup(of: Int.self, returning: [Int].self) { group in + for _ in 0..<8 { + group.addTask(executorPreference: executor) { + try await remote.withJSObject { object in + Int(object["value"].number!) + } + } + } + + var results: [Int] = [] + for try await value in group { + results.append(value) + } + return results + } + + XCTAssertEqual(results.count, 8) + XCTAssertEqual(results, Array(repeating: 42, count: 8)) + } + + func testRemoteMutationIsVisibleOnOwnerThread() async throws { + let object = JSObject.global.Object.function!.new() + object["value"] = 10 + 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 + object["value"] = .number(object["value"].number! + 5) + } + } + + _ = try await task.value + XCTAssertEqual(object["value"].number!, 15) + } + + func testRemoteThrowsTypedError() async throws { + struct TestError: Error, Equatable { + let message: String + } + + let object = JSObject.global.Object.function!.new() + let remote = JSRemote(object) + let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + defer { executor.terminate() } + + let task = Task(executorPreference: executor) { + do { + _ = try await remote.withJSObject { _ in + throw TestError(message: "boom") + } + return "unexpected" + } catch { + return String(describing: error) + } + } + + let errorDescription = try await task.value + XCTAssertTrue(errorDescription.contains("boom"), errorDescription) + } + func testThrowJSExceptionAcrossThreads() async throws { let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) let task = Task(executorPreference: executor) {