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) {