From 28d5ec060749d2ed386b554e282977a4ecee9a4a Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 10 Mar 2025 14:21:50 +0000 Subject: [PATCH 01/19] Add `JSObject.transfer` and `JSObject.receive` APIs These APIs allow transferring a `JSObject` between worker threads. The `JSObject.transfer` method creates a `JSObject.Transferring` instance that is `Sendable` and can be sent to another worker thread. The `JSObject.receive` method requests the object from the source worker thread and postMessage it to the destination worker thread. --- Runtime/src/index.ts | 147 ++++++++++++++++-- Runtime/src/types.ts | 8 + .../JSObject+Transferring.swift | 60 +++++++ .../FundamentalObjects/JSObject.swift | 16 +- Sources/JavaScriptKit/Runtime/index.js | 111 ++++++++++++- Sources/JavaScriptKit/Runtime/index.mjs | 111 ++++++++++++- Sources/_CJavaScriptKit/_CJavaScriptKit.c | 8 + .../_CJavaScriptKit/include/_CJavaScriptKit.h | 9 ++ 8 files changed, 436 insertions(+), 34 deletions(-) create mode 100644 Sources/JavaScriptEventLoop/JSObject+Transferring.swift diff --git a/Runtime/src/index.ts b/Runtime/src/index.ts index 73f56411a..25d6e92f5 100644 --- a/Runtime/src/index.ts +++ b/Runtime/src/index.ts @@ -6,18 +6,45 @@ import { pointer, TypedArray, ImportedFunctions, + MAIN_THREAD_TID, } from "./types.js"; import * as JSValue from "./js-value.js"; import { Memory } from "./memory.js"; +type TransferMessage = { + type: "transfer"; + data: { + object: any; + transferring: pointer; + destinationTid: number; + }; +}; + +type RequestTransferMessage = { + type: "requestTransfer"; + data: { + objectRef: ref; + objectSourceTid: number; + transferring: pointer; + destinationTid: number; + }; +}; + +type TransferErrorMessage = { + type: "transferError"; + data: { + error: string; + }; +}; + type MainToWorkerMessage = { type: "wake"; -}; +} | RequestTransferMessage | TransferMessage | TransferErrorMessage; type WorkerToMainMessage = { type: "job"; data: number; -}; +} | RequestTransferMessage | TransferMessage | TransferErrorMessage; /** * A thread channel is a set of functions that are used to communicate between @@ -60,8 +87,9 @@ export type SwiftRuntimeThreadChannel = * This function is used to send messages from the worker thread to the main thread. * The message submitted by this function is expected to be listened by `listenMessageFromWorkerThread`. * @param message The message to be sent to the main thread. + * @param transfer The array of objects to be transferred to the main thread. */ - postMessageToMainThread: (message: WorkerToMainMessage) => void; + postMessageToMainThread: (message: WorkerToMainMessage, transfer: any[]) => void; /** * This function is expected to be set in the worker thread and should listen * to messages from the main thread sent by `postMessageToWorkerThread`. @@ -75,8 +103,9 @@ export type SwiftRuntimeThreadChannel = * The message submitted by this function is expected to be listened by `listenMessageFromMainThread`. * @param tid The thread ID of the worker thread. * @param message The message to be sent to the worker thread. + * @param transfer The array of objects to be transferred to the worker thread. */ - postMessageToWorkerThread: (tid: number, message: MainToWorkerMessage) => void; + postMessageToWorkerThread: (tid: number, message: MainToWorkerMessage, transfer: any[]) => void; /** * This function is expected to be set in the main thread and should listen * to messages sent by `postMessageToMainThread` from the worker thread. @@ -610,8 +639,37 @@ export class SwiftRuntime { case "wake": this.exports.swjs_wake_worker_thread(); break; + case "requestTransfer": { + const object = this.memory.getObject(message.data.objectRef); + const messageToMainThread: TransferMessage = { + type: "transfer", + data: { + object, + destinationTid: message.data.destinationTid, + transferring: message.data.transferring, + }, + }; + try { + this.postMessageToMainThread(messageToMainThread, [object]); + } catch (error) { + this.postMessageToMainThread({ + type: "transferError", + data: { error: String(error) }, + }); + } + break; + } + case "transfer": { + const objectRef = this.memory.retain(message.data.object); + this.exports.swjs_receive_object(objectRef, message.data.transferring); + break; + } + case "transferError": { + console.error(message.data.error); // TODO: Handle the error + break; + } default: - const unknownMessage: never = message.type; + const unknownMessage: never = message; throw new Error(`Unknown message type: ${unknownMessage}`); } }); @@ -632,8 +690,57 @@ export class SwiftRuntime { case "job": this.exports.swjs_enqueue_main_job_from_worker(message.data); break; + case "requestTransfer": { + if (message.data.objectSourceTid == MAIN_THREAD_TID) { + const object = this.memory.getObject(message.data.objectRef); + if (message.data.destinationTid != tid) { + throw new Error("Invariant violation: The destination tid of the transfer request must be the same as the tid of the worker thread that received the request."); + } + this.postMessageToWorkerThread(message.data.destinationTid, { + type: "transfer", + data: { + object, + transferring: message.data.transferring, + destinationTid: message.data.destinationTid, + }, + }, [object]); + } else { + // Proxy the transfer request to the worker thread that owns the object + this.postMessageToWorkerThread(message.data.objectSourceTid, { + type: "requestTransfer", + data: { + objectRef: message.data.objectRef, + objectSourceTid: tid, + transferring: message.data.transferring, + destinationTid: message.data.destinationTid, + }, + }); + } + break; + } + case "transfer": { + if (message.data.destinationTid == MAIN_THREAD_TID) { + const objectRef = this.memory.retain(message.data.object); + this.exports.swjs_receive_object(objectRef, message.data.transferring); + } else { + // Proxy the transfer response to the destination worker thread + this.postMessageToWorkerThread(message.data.destinationTid, { + type: "transfer", + data: { + object: message.data.object, + transferring: message.data.transferring, + destinationTid: message.data.destinationTid, + }, + }, [message.data.object]); + } + break; + } + case "transferError": { + console.error(message.data.error); // TODO: Handle the error + break; + } default: - const unknownMessage: never = message.type; + const unknownMessage: never = message; throw new Error(`Unknown message type: ${unknownMessage}`); } }, @@ -649,27 +756,47 @@ export class SwiftRuntime { // Main thread's tid is always -1 return this.tid || -1; }, + swjs_request_transferring_object: ( + object_ref: ref, + object_source_tid: number, + transferring: pointer, + ) => { + if (this.tid == object_source_tid) { + // Fast path: The object is already in the same thread + this.exports.swjs_receive_object(object_ref, transferring); + return; + } + this.postMessageToMainThread({ + type: "requestTransfer", + data: { + objectRef: object_ref, + objectSourceTid: object_source_tid, + transferring, + destinationTid: this.tid ?? MAIN_THREAD_TID, + }, + }); + }, }; } - private postMessageToMainThread(message: WorkerToMainMessage) { + private postMessageToMainThread(message: WorkerToMainMessage, transfer: any[] = []) { const threadChannel = this.options.threadChannel; if (!(threadChannel && "postMessageToMainThread" in threadChannel)) { throw new Error( "postMessageToMainThread is not set in options given to SwiftRuntime. Please set it to send messages to the main thread." ); } - threadChannel.postMessageToMainThread(message); + threadChannel.postMessageToMainThread(message, transfer); } - private postMessageToWorkerThread(tid: number, message: MainToWorkerMessage) { + private postMessageToWorkerThread(tid: number, message: MainToWorkerMessage, transfer: any[] = []) { const threadChannel = this.options.threadChannel; if (!(threadChannel && "postMessageToWorkerThread" in threadChannel)) { throw new Error( "postMessageToWorkerThread is not set in options given to SwiftRuntime. Please set it to send messages to worker threads." ); } - threadChannel.postMessageToWorkerThread(tid, message); + threadChannel.postMessageToWorkerThread(tid, message, transfer); } } diff --git a/Runtime/src/types.ts b/Runtime/src/types.ts index dd638acc5..4e311ef80 100644 --- a/Runtime/src/types.ts +++ b/Runtime/src/types.ts @@ -22,6 +22,7 @@ export interface ExportedFunctions { swjs_enqueue_main_job_from_worker(unowned_job: number): void; swjs_wake_worker_thread(): void; + swjs_receive_object(object: ref, transferring: pointer): void; } export interface ImportedFunctions { @@ -112,6 +113,11 @@ export interface ImportedFunctions { swjs_listen_message_from_worker_thread: (tid: number) => void; swjs_terminate_worker_thread: (tid: number) => void; swjs_get_worker_thread_id: () => number; + swjs_request_transferring_object: ( + object_ref: ref, + object_source_tid: number, + transferring: pointer, + ) => void; } export const enum LibraryFeatures { @@ -133,3 +139,5 @@ export type TypedArray = export function assertNever(x: never, message: string) { throw new Error(message); } + +export const MAIN_THREAD_TID = -1; diff --git a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift new file mode 100644 index 000000000..dce32d7ec --- /dev/null +++ b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift @@ -0,0 +1,60 @@ +@_spi(JSObject_id) import JavaScriptKit +import _CJavaScriptKit + +extension JSObject { + public class Transferring: @unchecked Sendable { + fileprivate let sourceTid: Int32 + fileprivate let idInSource: JavaScriptObjectRef + fileprivate var continuation: CheckedContinuation? = nil + + init(sourceTid: Int32, id: JavaScriptObjectRef) { + self.sourceTid = sourceTid + self.idInSource = id + } + + func receive(isolation: isolated (any Actor)?) async throws -> JSObject { + #if compiler(>=6.1) && _runtime(_multithreaded) + swjs_request_transferring_object( + idInSource, + sourceTid, + Unmanaged.passRetained(self).toOpaque() + ) + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + } + #else + return JSObject(id: idInSource) + #endif + } + } + + /// Transfers the ownership of a `JSObject` to be sent to another Worker. + /// + /// - Parameter object: The `JSObject` to be transferred. + /// - Returns: A `JSTransferring` instance that can be shared across worker threads. + /// - Note: The original `JSObject` should not be accessed after calling this method. + public static func transfer(_ object: JSObject) -> Transferring { + #if compiler(>=6.1) && _runtime(_multithreaded) + Transferring(sourceTid: object.ownerTid, id: object.id) + #else + Transferring(sourceTid: -1, id: object.id) + #endif + } + + /// Receives a transferred `JSObject` from a Worker. + /// + /// - Parameter transferring: The `JSTransferring` instance received from other worker threads. + /// - Returns: The reconstructed `JSObject` that can be used in the receiving Worker. + public static func receive(_ transferring: Transferring, isolation: isolated (any Actor)? = #isolation) async throws -> JSObject { + try await transferring.receive(isolation: isolation) + } +} + +#if compiler(>=6.1) // @_expose and @_extern are only available in Swift 6.1+ +@_expose(wasm, "swjs_receive_object") +@_cdecl("swjs_receive_object") +#endif +func _swjs_receive_object(_ object: JavaScriptObjectRef, _ transferring: UnsafeRawPointer) { + let transferring = Unmanaged.fromOpaque(transferring).takeRetainedValue() + transferring.continuation?.resume(returning: JSObject(id: object)) +} diff --git a/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift b/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift index f74b337d8..18c683682 100644 --- a/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift +++ b/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift @@ -1,13 +1,5 @@ import _CJavaScriptKit -#if arch(wasm32) - #if canImport(wasi_pthread) - import wasi_pthread - #endif -#else - import Foundation // for pthread_t on non-wasi platforms -#endif - /// `JSObject` represents an object in JavaScript and supports dynamic member lookup. /// Any member access like `object.foo` will dynamically request the JavaScript and Swift /// runtime bridge library for a member with the specified name in this object. @@ -31,14 +23,14 @@ public class JSObject: Equatable { public var id: JavaScriptObjectRef #if compiler(>=6.1) && _runtime(_multithreaded) - private let ownerThread: pthread_t + package let ownerTid: Int32 #endif @_spi(JSObject_id) public init(id: JavaScriptObjectRef) { self.id = id #if compiler(>=6.1) && _runtime(_multithreaded) - self.ownerThread = pthread_self() + self.ownerTid = swjs_get_worker_thread_id_cached() #endif } @@ -51,14 +43,14 @@ public class JSObject: Equatable { /// object spaces are not shared across threads backed by Web Workers. private func assertOnOwnerThread(hint: @autoclosure () -> String) { #if compiler(>=6.1) && _runtime(_multithreaded) - precondition(pthread_equal(ownerThread, pthread_self()) != 0, "JSObject is being accessed from a thread other than the owner thread: \(hint())") + precondition(ownerTid == swjs_get_worker_thread_id_cached(), "JSObject is being accessed from a thread other than the owner thread: \(hint())") #endif } /// Asserts that the two objects being compared are owned by the same thread. private static func assertSameOwnerThread(lhs: JSObject, rhs: JSObject, hint: @autoclosure () -> String) { #if compiler(>=6.1) && _runtime(_multithreaded) - precondition(pthread_equal(lhs.ownerThread, rhs.ownerThread) != 0, "JSObject is being accessed from a thread other than the owner thread: \(hint())") + precondition(lhs.ownerTid == rhs.ownerTid, "JSObject is being accessed from a thread other than the owner thread: \(hint())") #endif } diff --git a/Sources/JavaScriptKit/Runtime/index.js b/Sources/JavaScriptKit/Runtime/index.js index 223fed3e1..8027593e5 100644 --- a/Sources/JavaScriptKit/Runtime/index.js +++ b/Sources/JavaScriptKit/Runtime/index.js @@ -25,6 +25,7 @@ function assertNever(x, message) { throw new Error(message); } + const MAIN_THREAD_TID = -1; const decode = (kind, payload1, payload2, memory) => { switch (kind) { @@ -512,8 +513,38 @@ case "wake": this.exports.swjs_wake_worker_thread(); break; + case "requestTransfer": { + const object = this.memory.getObject(message.data.objectRef); + const messageToMainThread = { + type: "transfer", + data: { + object, + destinationTid: message.data.destinationTid, + transferring: message.data.transferring, + }, + }; + try { + this.postMessageToMainThread(messageToMainThread, [object]); + } + catch (error) { + this.postMessageToMainThread({ + type: "transferError", + data: { error: String(error) }, + }); + } + break; + } + case "transfer": { + const objectRef = this.memory.retain(message.data.object); + this.exports.swjs_receive_object(objectRef, message.data.transferring); + break; + } + case "transferError": { + console.error(message.data.error); // TODO: Handle the error + break; + } default: - const unknownMessage = message.type; + const unknownMessage = message; throw new Error(`Unknown message type: ${unknownMessage}`); } }); @@ -531,8 +562,59 @@ case "job": this.exports.swjs_enqueue_main_job_from_worker(message.data); break; + case "requestTransfer": { + if (message.data.objectSourceTid == MAIN_THREAD_TID) { + const object = this.memory.getObject(message.data.objectRef); + if (message.data.destinationTid != tid) { + throw new Error("Invariant violation: The destination tid of the transfer request must be the same as the tid of the worker thread that received the request."); + } + this.postMessageToWorkerThread(message.data.destinationTid, { + type: "transfer", + data: { + object, + transferring: message.data.transferring, + destinationTid: message.data.destinationTid, + }, + }, [object]); + } + else { + // Proxy the transfer request to the worker thread that owns the object + this.postMessageToWorkerThread(message.data.objectSourceTid, { + type: "requestTransfer", + data: { + objectRef: message.data.objectRef, + objectSourceTid: tid, + transferring: message.data.transferring, + destinationTid: message.data.destinationTid, + }, + }); + } + break; + } + case "transfer": { + if (message.data.destinationTid == MAIN_THREAD_TID) { + const objectRef = this.memory.retain(message.data.object); + this.exports.swjs_receive_object(objectRef, message.data.transferring); + } + else { + // Proxy the transfer response to the destination worker thread + this.postMessageToWorkerThread(message.data.destinationTid, { + type: "transfer", + data: { + object: message.data.object, + transferring: message.data.transferring, + destinationTid: message.data.destinationTid, + }, + }, [message.data.object]); + } + break; + } + case "transferError": { + console.error(message.data.error); // TODO: Handle the error + break; + } default: - const unknownMessage = message.type; + const unknownMessage = message; throw new Error(`Unknown message type: ${unknownMessage}`); } }); @@ -548,21 +630,38 @@ // Main thread's tid is always -1 return this.tid || -1; }, + swjs_request_transferring_object: (object_ref, object_source_tid, transferring) => { + var _a; + if (this.tid == object_source_tid) { + // Fast path: The object is already in the same thread + this.exports.swjs_receive_object(object_ref, transferring); + return; + } + this.postMessageToMainThread({ + type: "requestTransfer", + data: { + objectRef: object_ref, + objectSourceTid: object_source_tid, + transferring, + destinationTid: (_a = this.tid) !== null && _a !== void 0 ? _a : MAIN_THREAD_TID, + }, + }); + }, }; } - postMessageToMainThread(message) { + postMessageToMainThread(message, transfer = []) { const threadChannel = this.options.threadChannel; if (!(threadChannel && "postMessageToMainThread" in threadChannel)) { throw new Error("postMessageToMainThread is not set in options given to SwiftRuntime. Please set it to send messages to the main thread."); } - threadChannel.postMessageToMainThread(message); + threadChannel.postMessageToMainThread(message, transfer); } - postMessageToWorkerThread(tid, message) { + postMessageToWorkerThread(tid, message, transfer = []) { const threadChannel = this.options.threadChannel; if (!(threadChannel && "postMessageToWorkerThread" in threadChannel)) { throw new Error("postMessageToWorkerThread is not set in options given to SwiftRuntime. Please set it to send messages to worker threads."); } - threadChannel.postMessageToWorkerThread(tid, message); + threadChannel.postMessageToWorkerThread(tid, message, transfer); } } /// This error is thrown when yielding event loop control from `swift_task_asyncMainDrainQueue` diff --git a/Sources/JavaScriptKit/Runtime/index.mjs b/Sources/JavaScriptKit/Runtime/index.mjs index 34e4dd13f..6a3df7477 100644 --- a/Sources/JavaScriptKit/Runtime/index.mjs +++ b/Sources/JavaScriptKit/Runtime/index.mjs @@ -19,6 +19,7 @@ class SwiftClosureDeallocator { function assertNever(x, message) { throw new Error(message); } +const MAIN_THREAD_TID = -1; const decode = (kind, payload1, payload2, memory) => { switch (kind) { @@ -506,8 +507,38 @@ class SwiftRuntime { case "wake": this.exports.swjs_wake_worker_thread(); break; + case "requestTransfer": { + const object = this.memory.getObject(message.data.objectRef); + const messageToMainThread = { + type: "transfer", + data: { + object, + destinationTid: message.data.destinationTid, + transferring: message.data.transferring, + }, + }; + try { + this.postMessageToMainThread(messageToMainThread, [object]); + } + catch (error) { + this.postMessageToMainThread({ + type: "transferError", + data: { error: String(error) }, + }); + } + break; + } + case "transfer": { + const objectRef = this.memory.retain(message.data.object); + this.exports.swjs_receive_object(objectRef, message.data.transferring); + break; + } + case "transferError": { + console.error(message.data.error); // TODO: Handle the error + break; + } default: - const unknownMessage = message.type; + const unknownMessage = message; throw new Error(`Unknown message type: ${unknownMessage}`); } }); @@ -525,8 +556,59 @@ class SwiftRuntime { case "job": this.exports.swjs_enqueue_main_job_from_worker(message.data); break; + case "requestTransfer": { + if (message.data.objectSourceTid == MAIN_THREAD_TID) { + const object = this.memory.getObject(message.data.objectRef); + if (message.data.destinationTid != tid) { + throw new Error("Invariant violation: The destination tid of the transfer request must be the same as the tid of the worker thread that received the request."); + } + this.postMessageToWorkerThread(message.data.destinationTid, { + type: "transfer", + data: { + object, + transferring: message.data.transferring, + destinationTid: message.data.destinationTid, + }, + }, [object]); + } + else { + // Proxy the transfer request to the worker thread that owns the object + this.postMessageToWorkerThread(message.data.objectSourceTid, { + type: "requestTransfer", + data: { + objectRef: message.data.objectRef, + objectSourceTid: tid, + transferring: message.data.transferring, + destinationTid: message.data.destinationTid, + }, + }); + } + break; + } + case "transfer": { + if (message.data.destinationTid == MAIN_THREAD_TID) { + const objectRef = this.memory.retain(message.data.object); + this.exports.swjs_receive_object(objectRef, message.data.transferring); + } + else { + // Proxy the transfer response to the destination worker thread + this.postMessageToWorkerThread(message.data.destinationTid, { + type: "transfer", + data: { + object: message.data.object, + transferring: message.data.transferring, + destinationTid: message.data.destinationTid, + }, + }, [message.data.object]); + } + break; + } + case "transferError": { + console.error(message.data.error); // TODO: Handle the error + break; + } default: - const unknownMessage = message.type; + const unknownMessage = message; throw new Error(`Unknown message type: ${unknownMessage}`); } }); @@ -542,21 +624,38 @@ class SwiftRuntime { // Main thread's tid is always -1 return this.tid || -1; }, + swjs_request_transferring_object: (object_ref, object_source_tid, transferring) => { + var _a; + if (this.tid == object_source_tid) { + // Fast path: The object is already in the same thread + this.exports.swjs_receive_object(object_ref, transferring); + return; + } + this.postMessageToMainThread({ + type: "requestTransfer", + data: { + objectRef: object_ref, + objectSourceTid: object_source_tid, + transferring, + destinationTid: (_a = this.tid) !== null && _a !== void 0 ? _a : MAIN_THREAD_TID, + }, + }); + }, }; } - postMessageToMainThread(message) { + postMessageToMainThread(message, transfer = []) { const threadChannel = this.options.threadChannel; if (!(threadChannel && "postMessageToMainThread" in threadChannel)) { throw new Error("postMessageToMainThread is not set in options given to SwiftRuntime. Please set it to send messages to the main thread."); } - threadChannel.postMessageToMainThread(message); + threadChannel.postMessageToMainThread(message, transfer); } - postMessageToWorkerThread(tid, message) { + postMessageToWorkerThread(tid, message, transfer = []) { const threadChannel = this.options.threadChannel; if (!(threadChannel && "postMessageToWorkerThread" in threadChannel)) { throw new Error("postMessageToWorkerThread is not set in options given to SwiftRuntime. Please set it to send messages to worker threads."); } - threadChannel.postMessageToWorkerThread(tid, message); + threadChannel.postMessageToWorkerThread(tid, message, transfer); } } /// This error is thrown when yielding event loop control from `swift_task_asyncMainDrainQueue` diff --git a/Sources/_CJavaScriptKit/_CJavaScriptKit.c b/Sources/_CJavaScriptKit/_CJavaScriptKit.c index ea8b5b43d..ed8240ca1 100644 --- a/Sources/_CJavaScriptKit/_CJavaScriptKit.c +++ b/Sources/_CJavaScriptKit/_CJavaScriptKit.c @@ -59,5 +59,13 @@ __attribute__((export_name("swjs_library_features"))) int swjs_library_features(void) { return _library_features(); } + +int swjs_get_worker_thread_id_cached(void) { + _Thread_local static int tid = 0; + if (tid == 0) { + tid = swjs_get_worker_thread_id(); + } + return tid; +} #endif #endif diff --git a/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h b/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h index 5cb6e6037..575c0e6fd 100644 --- a/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h +++ b/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h @@ -308,4 +308,13 @@ IMPORT_JS_FUNCTION(swjs_terminate_worker_thread, void, (int tid)) IMPORT_JS_FUNCTION(swjs_get_worker_thread_id, int, (void)) +int swjs_get_worker_thread_id_cached(void); + +/// Requests transferring a JavaScript object to another worker thread. +/// +/// This must be called from the destination thread of the transfer. +IMPORT_JS_FUNCTION(swjs_request_transferring_object, void, (JavaScriptObjectRef object, + int object_source_tid, + void * _Nonnull transferring)) + #endif /* _CJavaScriptKit_h */ From e406cd3663255fe1761e8d8bb8287f7b75434bc8 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 10 Mar 2025 14:23:56 +0000 Subject: [PATCH 02/19] Stop hardcoding the Swift toolchain version in the Multithreading example --- Examples/Multithreading/README.md | 16 ++++++++++++++-- Examples/Multithreading/build.sh | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Examples/Multithreading/README.md b/Examples/Multithreading/README.md index c95df2a8b..346f8cc8b 100644 --- a/Examples/Multithreading/README.md +++ b/Examples/Multithreading/README.md @@ -1,9 +1,21 @@ # Multithreading example -Install Development Snapshot toolchain `DEVELOPMENT-SNAPSHOT-2024-07-08-a` from [swift.org/install](https://www.swift.org/install/) and run the following commands: +Install Development Snapshot toolchain `DEVELOPMENT-SNAPSHOT-2024-07-08-a` or later from [swift.org/install](https://www.swift.org/install/) and run the following commands: ```sh -$ swift sdk install https://github.com/swiftwasm/swift/releases/download/swift-wasm-DEVELOPMENT-SNAPSHOT-2024-07-09-a/swift-wasm-DEVELOPMENT-SNAPSHOT-2024-07-09-a-wasm32-unknown-wasip1-threads.artifactbundle.zip +$ ( + set -eo pipefail; \ + V="$(swiftc --version | head -n1)"; \ + TAG="$(curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/tag-by-version.json" | jq -e -r --arg v "$V" '.[$v] | .[-1]')"; \ + curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/builds/$TAG.json" | \ + jq -r '.["swift-sdks"]["wasm32-unknown-wasip1-threads"] | "swift sdk install \"\(.url)\" --checksum \"\(.checksum)\""' | sh -x +) +$ export SWIFT_SDK_ID=$( + V="$(swiftc --version | head -n1)"; \ + TAG="$(curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/tag-by-version.json" | jq -e -r --arg v "$V" '.[$v] | .[-1]')"; \ + curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/builds/$TAG.json" | \ + jq -r '.["swift-sdks"]["wasm32-unknown-wasip1-threads"]["id"]' +) $ ./build.sh $ npx serve ``` diff --git a/Examples/Multithreading/build.sh b/Examples/Multithreading/build.sh index 7d903b1f4..0f8670db1 100755 --- a/Examples/Multithreading/build.sh +++ b/Examples/Multithreading/build.sh @@ -1 +1 @@ -swift build --swift-sdk DEVELOPMENT-SNAPSHOT-2024-07-09-a-wasm32-unknown-wasip1-threads -Xswiftc -Xclang-linker -Xswiftc -mexec-model=reactor -Xlinker --export=__main_argc_argv -c release -Xswiftc -g +swift build --swift-sdk "${SWIFT_SDK_ID:-wasm32-unknown-wasip1-threads}" -Xswiftc -Xclang-linker -Xswiftc -mexec-model=reactor -Xlinker --export=__main_argc_argv -c release -Xswiftc -g From cfa1b2ded3bf86b0fb6ca250a5674f2d2af9c5e6 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 10 Mar 2025 14:24:53 +0000 Subject: [PATCH 03/19] Update Multithreading example to support transferable objects --- Examples/Multithreading/Sources/JavaScript/index.js | 4 ++-- Examples/Multithreading/Sources/JavaScript/worker.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Examples/Multithreading/Sources/JavaScript/index.js b/Examples/Multithreading/Sources/JavaScript/index.js index cc0c7e4e4..3cfc01a43 100644 --- a/Examples/Multithreading/Sources/JavaScript/index.js +++ b/Examples/Multithreading/Sources/JavaScript/index.js @@ -27,9 +27,9 @@ class ThreadRegistry { }; } - postMessageToWorkerThread(tid, data) { + postMessageToWorkerThread(tid, data, transfer) { const worker = this.workers.get(tid); - worker.postMessage(data); + worker.postMessage(data, transfer); } terminateWorkerThread(tid) { diff --git a/Examples/Multithreading/Sources/JavaScript/worker.js b/Examples/Multithreading/Sources/JavaScript/worker.js index eadd42bef..703df4407 100644 --- a/Examples/Multithreading/Sources/JavaScript/worker.js +++ b/Examples/Multithreading/Sources/JavaScript/worker.js @@ -5,9 +5,9 @@ self.onmessage = async (event) => { const { instance, wasi, swiftRuntime } = await instantiate({ module, threadChannel: { - postMessageToMainThread: (message) => { + postMessageToMainThread: (message, transfer) => { // Send the job to the main thread - postMessage(message); + postMessage(message, transfer); }, listenMessageFromMainThread: (listener) => { self.onmessage = (event) => listener(event.data); From 9d335a88d2048abca1dfd96e80a21c2e56c7311d Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 10 Mar 2025 14:25:18 +0000 Subject: [PATCH 04/19] Add OffscreenCanvas example --- Examples/OffscrenCanvas/.gitignore | 8 + Examples/OffscrenCanvas/Package.swift | 20 ++ Examples/OffscrenCanvas/README.md | 21 +++ Examples/OffscrenCanvas/Sources/JavaScript | 1 + .../OffscrenCanvas/Sources/MyApp/main.swift | 139 ++++++++++++++ .../OffscrenCanvas/Sources/MyApp/render.swift | 174 ++++++++++++++++++ Examples/OffscrenCanvas/build.sh | 1 + Examples/OffscrenCanvas/index.html | 98 ++++++++++ Examples/OffscrenCanvas/serve.json | 1 + 9 files changed, 463 insertions(+) create mode 100644 Examples/OffscrenCanvas/.gitignore create mode 100644 Examples/OffscrenCanvas/Package.swift create mode 100644 Examples/OffscrenCanvas/README.md create mode 120000 Examples/OffscrenCanvas/Sources/JavaScript create mode 100644 Examples/OffscrenCanvas/Sources/MyApp/main.swift create mode 100644 Examples/OffscrenCanvas/Sources/MyApp/render.swift create mode 100755 Examples/OffscrenCanvas/build.sh create mode 100644 Examples/OffscrenCanvas/index.html create mode 120000 Examples/OffscrenCanvas/serve.json diff --git a/Examples/OffscrenCanvas/.gitignore b/Examples/OffscrenCanvas/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/Examples/OffscrenCanvas/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Examples/OffscrenCanvas/Package.swift b/Examples/OffscrenCanvas/Package.swift new file mode 100644 index 000000000..7fc45ad1b --- /dev/null +++ b/Examples/OffscrenCanvas/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.10 + +import PackageDescription + +let package = Package( + name: "Example", + platforms: [.macOS("15"), .iOS("18"), .watchOS("11"), .tvOS("18"), .visionOS("2")], + dependencies: [ + .package(path: "../../"), + ], + targets: [ + .executableTarget( + name: "MyApp", + dependencies: [ + .product(name: "JavaScriptKit", package: "JavaScriptKit"), + .product(name: "JavaScriptEventLoop", package: "JavaScriptKit"), + ] + ), + ] +) diff --git a/Examples/OffscrenCanvas/README.md b/Examples/OffscrenCanvas/README.md new file mode 100644 index 000000000..395b0c295 --- /dev/null +++ b/Examples/OffscrenCanvas/README.md @@ -0,0 +1,21 @@ +# OffscreenCanvas example + +Install Development Snapshot toolchain `DEVELOPMENT-SNAPSHOT-2024-07-08-a` or later from [swift.org/install](https://www.swift.org/install/) and run the following commands: + +```sh +$ ( + set -eo pipefail; \ + V="$(swiftc --version | head -n1)"; \ + TAG="$(curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/tag-by-version.json" | jq -e -r --arg v "$V" '.[$v] | .[-1]')"; \ + curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/builds/$TAG.json" | \ + jq -r '.["swift-sdks"]["wasm32-unknown-wasip1-threads"] | "swift sdk install \"\(.url)\" --checksum \"\(.checksum)\""' | sh -x +) +$ export SWIFT_SDK_ID=$( + V="$(swiftc --version | head -n1)"; \ + TAG="$(curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/tag-by-version.json" | jq -e -r --arg v "$V" '.[$v] | .[-1]')"; \ + curl -sL "https://raw.githubusercontent.com/swiftwasm/swift-sdk-index/refs/heads/main/v1/builds/$TAG.json" | \ + jq -r '.["swift-sdks"]["wasm32-unknown-wasip1-threads"]["id"]' +) +$ ./build.sh +$ npx serve +``` diff --git a/Examples/OffscrenCanvas/Sources/JavaScript b/Examples/OffscrenCanvas/Sources/JavaScript new file mode 120000 index 000000000..b24c2256e --- /dev/null +++ b/Examples/OffscrenCanvas/Sources/JavaScript @@ -0,0 +1 @@ +../../Multithreading/Sources/JavaScript \ No newline at end of file diff --git a/Examples/OffscrenCanvas/Sources/MyApp/main.swift b/Examples/OffscrenCanvas/Sources/MyApp/main.swift new file mode 100644 index 000000000..ba660c6b2 --- /dev/null +++ b/Examples/OffscrenCanvas/Sources/MyApp/main.swift @@ -0,0 +1,139 @@ +import JavaScriptEventLoop +import JavaScriptKit + +JavaScriptEventLoop.installGlobalExecutor() +WebWorkerTaskExecutor.installGlobalExecutor() + +protocol CanvasRenderer { + func render(canvas: JSObject, size: Int) async throws +} + +struct BackgroundRenderer: CanvasRenderer { + func render(canvas: JSObject, size: Int) async throws { + let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + let transferringCanvas = JSObject.transfer(canvas) + let renderingTask = Task(executorPreference: executor) { + let canvas = try await JSObject.receive(transferringCanvas) + try await renderAnimation(canvas: canvas, size: size) + } + await withTaskCancellationHandler { + try? await renderingTask.value + } onCancel: { + renderingTask.cancel() + } + executor.terminate() + } +} + +struct MainThreadRenderer: CanvasRenderer { + func render(canvas: JSObject, size: Int) async throws { + try await renderAnimation(canvas: canvas, size: size) + } +} + +// FPS Counter for CSS animation +func startFPSMonitor() { + let fpsCounterElement = JSObject.global.document.getElementById("fps-counter").object! + + var lastTime = JSObject.global.performance.now().number! + var frames = 0 + + // Create a frame counter function + func countFrame() { + frames += 1 + let currentTime = JSObject.global.performance.now().number! + let elapsed = currentTime - lastTime + + if elapsed >= 1000 { + let fps = Int(Double(frames) * 1000 / elapsed) + fpsCounterElement.textContent = .string("FPS: \(fps)") + frames = 0 + lastTime = currentTime + } + + // Request next frame + _ = JSObject.global.requestAnimationFrame!( + JSClosure { _ in + countFrame() + return .undefined + }) + } + + // Start counting + countFrame() +} + +@MainActor +func onClick(renderer: CanvasRenderer) async throws { + let document = JSObject.global.document + + let canvasContainerElement = document.getElementById("canvas-container").object! + + // Remove all child elements from the canvas container + for i in 0..? = nil + + // Start the FPS monitor for CSS animations + startFPSMonitor() + + _ = renderButtonElement.addEventListener!( + "click", + JSClosure { _ in + renderingTask?.cancel() + renderingTask = Task { + let selectedValue = rendererSelectElement.value.string! + let renderer: CanvasRenderer = + selectedValue == "main" ? MainThreadRenderer() : BackgroundRenderer() + try await onClick(renderer: renderer) + } + return JSValue.undefined + }) + + _ = cancelButtonElement.addEventListener!( + "click", + JSClosure { _ in + renderingTask?.cancel() + return JSValue.undefined + }) +} + +Task { + try await main() +} + +#if canImport(wasi_pthread) + import wasi_pthread + import WASILibc + + /// Trick to avoid blocking the main thread. pthread_mutex_lock function is used by + /// the Swift concurrency runtime. + @_cdecl("pthread_mutex_lock") + func pthread_mutex_lock(_ mutex: UnsafeMutablePointer) -> Int32 { + // DO NOT BLOCK MAIN THREAD + var ret: Int32 + repeat { + ret = pthread_mutex_trylock(mutex) + } while ret == EBUSY + return ret + } +#endif diff --git a/Examples/OffscrenCanvas/Sources/MyApp/render.swift b/Examples/OffscrenCanvas/Sources/MyApp/render.swift new file mode 100644 index 000000000..714cac184 --- /dev/null +++ b/Examples/OffscrenCanvas/Sources/MyApp/render.swift @@ -0,0 +1,174 @@ +import Foundation +import JavaScriptKit + +func sleepOnThread(milliseconds: Int, isolation: isolated (any Actor)? = #isolation) async { + // Use the JavaScript setTimeout function to avoid hopping back to the main thread + await withCheckedContinuation(isolation: isolation) { continuation in + _ = JSObject.global.setTimeout!( + JSOneshotClosure { _ in + continuation.resume() + return JSValue.undefined + }, milliseconds + ) + } +} + +func renderAnimation(canvas: JSObject, size: Int, isolation: isolated (any Actor)? = #isolation) + async throws +{ + let ctx = canvas.getContext!("2d").object! + + // Animation state variables + var time: Double = 0 + + // Create a large number of particles + let particleCount = 5000 + var particles: [[Double]] = [] + + // Initialize particles with random positions and velocities + for _ in 0.. Double(size) { + particles[i][2] *= -0.8 + } + if particles[i][1] < 0 || particles[i][1] > Double(size) { + particles[i][3] *= -0.8 + } + + // Calculate opacity based on lifespan + let opacity = particles[i][6] / particles[i][7] + + // Get coordinates and properties + let x = particles[i][0] + let y = particles[i][1] + let size = particles[i][4] + let hue = (particles[i][5] + time * 10).truncatingRemainder(dividingBy: 360) + + // Draw particle + _ = ctx.beginPath!() + ctx.fillStyle = .string("hsla(\(hue), 100%, 60%, \(opacity))") + _ = ctx.arc!(x, y, size, 0, 2 * Double.pi) + _ = ctx.fill!() + + // Connect nearby particles with lines (only check some to save CPU) + if i % 20 == 0 { + for j in (i + 1).. + + + + OffscreenCanvas Example + + + + + +

OffscreenCanvas Example

+

+

+ + + +
+

+ +

CSS Animation (Main Thread Performance Indicator)

+
+
+
+
+
+
+
+ +
FPS: 0
+ +
+ + + diff --git a/Examples/OffscrenCanvas/serve.json b/Examples/OffscrenCanvas/serve.json new file mode 120000 index 000000000..326719cd4 --- /dev/null +++ b/Examples/OffscrenCanvas/serve.json @@ -0,0 +1 @@ +../Multithreading/serve.json \ No newline at end of file From 98cec71bec7acf7e6fbd8ba282f9d6616fc4fc48 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 10 Mar 2025 14:56:39 +0000 Subject: [PATCH 05/19] Rename `JSObject.receive` to `JSObject.Transferring.receive` --- .../OffscrenCanvas/Sources/MyApp/main.swift | 2 +- .../JSObject+Transferring.swift | 112 +++++++++++++----- 2 files changed, 85 insertions(+), 29 deletions(-) diff --git a/Examples/OffscrenCanvas/Sources/MyApp/main.swift b/Examples/OffscrenCanvas/Sources/MyApp/main.swift index ba660c6b2..9d169f39b 100644 --- a/Examples/OffscrenCanvas/Sources/MyApp/main.swift +++ b/Examples/OffscrenCanvas/Sources/MyApp/main.swift @@ -13,7 +13,7 @@ struct BackgroundRenderer: CanvasRenderer { let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) let transferringCanvas = JSObject.transfer(canvas) let renderingTask = Task(executorPreference: executor) { - let canvas = try await JSObject.receive(transferringCanvas) + let canvas = try await transferringCanvas.receive() try await renderAnimation(canvas: canvas, size: size) } await withTaskCancellationHandler { diff --git a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift index dce32d7ec..c1be7185b 100644 --- a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift +++ b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift @@ -1,60 +1,116 @@ @_spi(JSObject_id) import JavaScriptKit import _CJavaScriptKit +#if canImport(Synchronization) + import Synchronization +#endif + extension JSObject { - public class Transferring: @unchecked Sendable { - fileprivate let sourceTid: Int32 - fileprivate let idInSource: JavaScriptObjectRef - fileprivate var continuation: CheckedContinuation? = nil - - init(sourceTid: Int32, id: JavaScriptObjectRef) { - self.sourceTid = sourceTid - self.idInSource = id + + /// A temporary object intended to transfer a ``JSObject`` from one thread to another. + /// + /// ``JSObject`` itself is not `Sendable`, but ``Transferring`` is `Sendable` because it's + /// intended to be shared across threads. + public struct Transferring: @unchecked Sendable { + fileprivate struct CriticalState { + var continuation: CheckedContinuation? + } + fileprivate class Storage { + let sourceTid: Int32 + let idInSource: JavaScriptObjectRef + #if compiler(>=6.1) && _runtime(_multithreaded) + let criticalState: Mutex = .init(CriticalState()) + #endif + + init(sourceTid: Int32, id: JavaScriptObjectRef) { + self.sourceTid = sourceTid + self.idInSource = id + } + } + + private let storage: Storage + + fileprivate init(sourceTid: Int32, id: JavaScriptObjectRef) { + self.init(storage: Storage(sourceTid: sourceTid, id: id)) } - func receive(isolation: isolated (any Actor)?) async throws -> JSObject { + fileprivate init(storage: Storage) { + self.storage = storage + } + + /// Receives a transferred ``JSObject`` from a thread. + /// + /// The original ``JSObject`` is ["Transferred"](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects) + /// to the receiving thread. + /// + /// Note that this method should be called only once for each ``Transferring`` instance + /// on the receiving thread. + /// + /// ### Example + /// + /// ```swift + /// let canvas = JSObject.global.document.createElement("canvas").object! + /// let transferring = JSObject.transfer(canvas.transferControlToOffscreen().object!) + /// let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + /// Task(executorPreference: executor) { + /// let canvas = try await transferring.receive() + /// } + /// ``` + public func receive(isolation: isolated (any Actor)? = #isolation, file: StaticString = #file, line: UInt = #line) async throws -> JSObject { #if compiler(>=6.1) && _runtime(_multithreaded) swjs_request_transferring_object( - idInSource, - sourceTid, - Unmanaged.passRetained(self).toOpaque() + self.storage.idInSource, + self.storage.sourceTid, + Unmanaged.passRetained(self.storage).toOpaque() ) return try await withCheckedThrowingContinuation { continuation in - self.continuation = continuation + self.storage.criticalState.withLock { criticalState in + guard criticalState.continuation == nil else { + // This is a programming error, `receive` should be called only once. + fatalError("JSObject.Transferring object is already received", file: file, line: line) + } + criticalState.continuation = continuation + } } #else - return JSObject(id: idInSource) + return JSObject(id: storage.idInSource) #endif } } - /// Transfers the ownership of a `JSObject` to be sent to another Worker. + /// Transfers the ownership of a `JSObject` to be sent to another thread. + /// + /// Note that the original ``JSObject`` should not be accessed after calling this method. /// - /// - Parameter object: The `JSObject` to be transferred. - /// - Returns: A `JSTransferring` instance that can be shared across worker threads. - /// - Note: The original `JSObject` should not be accessed after calling this method. + /// - Parameter object: The ``JSObject`` to be transferred. + /// - Returns: A ``Transferring`` instance that can be shared across threads. public static func transfer(_ object: JSObject) -> Transferring { #if compiler(>=6.1) && _runtime(_multithreaded) Transferring(sourceTid: object.ownerTid, id: object.id) #else + // On single-threaded runtime, source and destination threads are always the main thread (TID = -1). Transferring(sourceTid: -1, id: object.id) #endif } - - /// Receives a transferred `JSObject` from a Worker. - /// - /// - Parameter transferring: The `JSTransferring` instance received from other worker threads. - /// - Returns: The reconstructed `JSObject` that can be used in the receiving Worker. - public static func receive(_ transferring: Transferring, isolation: isolated (any Actor)? = #isolation) async throws -> JSObject { - try await transferring.receive(isolation: isolation) - } } + +/// A function that should be called when an object source thread sends an object to a +/// destination thread. +/// +/// - Parameters: +/// - object: The `JSObject` to be received. +/// - transferring: A pointer to the `Transferring.Storage` instance. #if compiler(>=6.1) // @_expose and @_extern are only available in Swift 6.1+ @_expose(wasm, "swjs_receive_object") @_cdecl("swjs_receive_object") #endif func _swjs_receive_object(_ object: JavaScriptObjectRef, _ transferring: UnsafeRawPointer) { - let transferring = Unmanaged.fromOpaque(transferring).takeRetainedValue() - transferring.continuation?.resume(returning: JSObject(id: object)) + #if compiler(>=6.1) && _runtime(_multithreaded) + let storage = Unmanaged.fromOpaque(transferring).takeRetainedValue() + storage.criticalState.withLock { criticalState in + assert(criticalState.continuation != nil, "JSObject.Transferring object is not yet received!?") + criticalState.continuation?.resume(returning: JSObject(id: object)) + } + #endif } From 9b84176c44c9b1ba7af222633bdf52ed8d8fb7a4 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 10 Mar 2025 15:36:04 +0000 Subject: [PATCH 06/19] Update test harness to support transferring --- IntegrationTests/lib.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/IntegrationTests/lib.js b/IntegrationTests/lib.js index 0172250d4..a2f10e565 100644 --- a/IntegrationTests/lib.js +++ b/IntegrationTests/lib.js @@ -79,7 +79,9 @@ export async function startWasiChildThread(event) { const swift = new SwiftRuntime({ sharedMemory: true, threadChannel: { - postMessageToMainThread: parentPort.postMessage.bind(parentPort), + postMessageToMainThread: (message, transfer) => { + parentPort.postMessage(message, transfer); + }, listenMessageFromMainThread: (listener) => { parentPort.on("message", listener) } @@ -139,9 +141,9 @@ class ThreadRegistry { return this.workers.get(tid); } - wakeUpWorkerThread(tid, message) { + wakeUpWorkerThread(tid, message, transfer) { const worker = this.workers.get(tid); - worker.postMessage(message); + worker.postMessage(message, transfer); } } From c4816141c529318bfdff8fe71a8e4e4d44eef154 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 10 Mar 2025 15:36:32 +0000 Subject: [PATCH 07/19] Fix JSObject lifetime issue while transferring --- .../JSObject+Transferring.swift | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift index c1be7185b..c6d5b14cb 100644 --- a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift +++ b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift @@ -13,25 +13,39 @@ extension JSObject { /// intended to be shared across threads. public struct Transferring: @unchecked Sendable { fileprivate struct CriticalState { - var continuation: CheckedContinuation? + var continuation: CheckedContinuation? } fileprivate class Storage { - let sourceTid: Int32 - let idInSource: JavaScriptObjectRef + /// The original ``JSObject`` that is transferred. + /// + /// Retain it here to prevent it from being released before the transfer is complete. + let sourceObject: JSObject #if compiler(>=6.1) && _runtime(_multithreaded) let criticalState: Mutex = .init(CriticalState()) #endif - init(sourceTid: Int32, id: JavaScriptObjectRef) { - self.sourceTid = sourceTid - self.idInSource = id + var idInSource: JavaScriptObjectRef { + sourceObject.id + } + + var sourceTid: Int32 { + #if compiler(>=6.1) && _runtime(_multithreaded) + sourceObject.ownerTid + #else + // On single-threaded runtime, source and destination threads are always the main thread (TID = -1). + -1 + #endif + } + + init(sourceObject: JSObject) { + self.sourceObject = sourceObject } } private let storage: Storage - fileprivate init(sourceTid: Int32, id: JavaScriptObjectRef) { - self.init(storage: Storage(sourceTid: sourceTid, id: id)) + fileprivate init(sourceObject: JSObject) { + self.init(storage: Storage(sourceObject: sourceObject)) } fileprivate init(storage: Storage) { @@ -63,7 +77,7 @@ extension JSObject { self.storage.sourceTid, Unmanaged.passRetained(self.storage).toOpaque() ) - return try await withCheckedThrowingContinuation { continuation in + let idInDestination = try await withCheckedThrowingContinuation { continuation in self.storage.criticalState.withLock { criticalState in guard criticalState.continuation == nil else { // This is a programming error, `receive` should be called only once. @@ -72,6 +86,7 @@ extension JSObject { criticalState.continuation = continuation } } + return JSObject(id: idInDestination) #else return JSObject(id: storage.idInSource) #endif @@ -85,12 +100,7 @@ extension JSObject { /// - Parameter object: The ``JSObject`` to be transferred. /// - Returns: A ``Transferring`` instance that can be shared across threads. public static func transfer(_ object: JSObject) -> Transferring { - #if compiler(>=6.1) && _runtime(_multithreaded) - Transferring(sourceTid: object.ownerTid, id: object.id) - #else - // On single-threaded runtime, source and destination threads are always the main thread (TID = -1). - Transferring(sourceTid: -1, id: object.id) - #endif + return Transferring(sourceObject: object) } } @@ -110,7 +120,7 @@ func _swjs_receive_object(_ object: JavaScriptObjectRef, _ transferring: UnsafeR let storage = Unmanaged.fromOpaque(transferring).takeRetainedValue() storage.criticalState.withLock { criticalState in assert(criticalState.continuation != nil, "JSObject.Transferring object is not yet received!?") - criticalState.continuation?.resume(returning: JSObject(id: object)) + criticalState.continuation?.resume(returning: object) } #endif } From 65ddcd36b318aee5f973ac82ef6658f1c62d7520 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 10 Mar 2025 15:36:46 +0000 Subject: [PATCH 08/19] Add basic tests for transferring objects between threads --- .../WebWorkerTaskExecutorTests.swift | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift index 3848ba4cc..7d79c39fa 100644 --- a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift +++ b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift @@ -9,7 +9,7 @@ func isMainThread() -> Bool final class WebWorkerTaskExecutorTests: XCTestCase { override func setUp() async { - await WebWorkerTaskExecutor.installGlobalExecutor() + WebWorkerTaskExecutor.installGlobalExecutor() } func testTaskRunOnMainThread() async throws { @@ -264,6 +264,37 @@ final class WebWorkerTaskExecutorTests: XCTestCase { executor.terminate() } + func testTransfer() async throws { + let Uint8Array = JSObject.global.Uint8Array.function! + let buffer = Uint8Array.new(100).buffer.object! + let transferring = JSObject.transfer(buffer) + let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + let task = Task(executorPreference: executor) { + let buffer = try await transferring.receive() + return buffer.byteLength.number! + } + let byteLength = try await task.value + XCTAssertEqual(byteLength, 100) + // Deinit the transferring object on the thread that was created + withExtendedLifetime(transferring) {} + } + + func testTransferNonTransferable() async throws { + let object = JSObject.global.Object.function!.new() + let transferring = JSObject.transfer(object) + let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + let task = Task(executorPreference: executor) { + _ = try await transferring.receive() + return + } + do { + try await task.value + XCTFail("Should throw an error") + } catch {} + // Deinit the transferring object on the thread that was created + withExtendedLifetime(transferring) {} + } + /* func testDeinitJSObjectOnDifferentThread() async throws { let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) From f0bd60cd9315158f5f5a44750de9f1245457eefc Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 10 Mar 2025 15:38:14 +0000 Subject: [PATCH 09/19] Fix native build --- Sources/JavaScriptEventLoop/JSObject+Transferring.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift index c6d5b14cb..0bab8bd0f 100644 --- a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift +++ b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift @@ -11,6 +11,7 @@ extension JSObject { /// /// ``JSObject`` itself is not `Sendable`, but ``Transferring`` is `Sendable` because it's /// intended to be shared across threads. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) public struct Transferring: @unchecked Sendable { fileprivate struct CriticalState { var continuation: CheckedContinuation? @@ -70,6 +71,7 @@ extension JSObject { /// let canvas = try await transferring.receive() /// } /// ``` + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) public func receive(isolation: isolated (any Actor)? = #isolation, file: StaticString = #file, line: UInt = #line) async throws -> JSObject { #if compiler(>=6.1) && _runtime(_multithreaded) swjs_request_transferring_object( @@ -99,6 +101,7 @@ extension JSObject { /// /// - Parameter object: The ``JSObject`` to be transferred. /// - Returns: A ``Transferring`` instance that can be shared across threads. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) public static func transfer(_ object: JSObject) -> Transferring { return Transferring(sourceObject: object) } @@ -115,6 +118,7 @@ extension JSObject { @_expose(wasm, "swjs_receive_object") @_cdecl("swjs_receive_object") #endif +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) func _swjs_receive_object(_ object: JavaScriptObjectRef, _ transferring: UnsafeRawPointer) { #if compiler(>=6.1) && _runtime(_multithreaded) let storage = Unmanaged.fromOpaque(transferring).takeRetainedValue() From 8d4bba6188826ff5ab6059fb37cb96c3cd34de28 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 10 Mar 2025 15:55:43 +0000 Subject: [PATCH 10/19] Add cautionary notes to the documentation of `JSObject.transfer()`. --- Sources/JavaScriptEventLoop/JSObject+Transferring.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift index 0bab8bd0f..859587f31 100644 --- a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift +++ b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift @@ -97,7 +97,9 @@ extension JSObject { /// Transfers the ownership of a `JSObject` to be sent to another thread. /// - /// Note that the original ``JSObject`` should not be accessed after calling this method. + /// - Precondition: The thread calling this method should have the ownership of the `JSObject`. + /// - Postcondition: The original `JSObject` is no longer owned by the thread, further access to it + /// on the thread that called this method is invalid and will result in undefined behavior. /// /// - Parameter object: The ``JSObject`` to be transferred. /// - Returns: A ``Transferring`` instance that can be shared across threads. From 09d5311dcf5d6c3206f448b5eee4661ef85b24b9 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Tue, 11 Mar 2025 02:26:23 +0000 Subject: [PATCH 11/19] Rename `JSObject.Transferring` to `JSTransferring` This change makes the transferring object to be used with a typed object like `JSDate` or something else in the future. --- .../OffscrenCanvas/Sources/MyApp/main.swift | 4 +- .../JSObject+Transferring.swift | 195 ++++++++++-------- .../WebWorkerTaskExecutorTests.swift | 4 +- 3 files changed, 115 insertions(+), 88 deletions(-) diff --git a/Examples/OffscrenCanvas/Sources/MyApp/main.swift b/Examples/OffscrenCanvas/Sources/MyApp/main.swift index 9d169f39b..b6e5b6df9 100644 --- a/Examples/OffscrenCanvas/Sources/MyApp/main.swift +++ b/Examples/OffscrenCanvas/Sources/MyApp/main.swift @@ -11,9 +11,9 @@ protocol CanvasRenderer { struct BackgroundRenderer: CanvasRenderer { func render(canvas: JSObject, size: Int) async throws { let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) - let transferringCanvas = JSObject.transfer(canvas) + let transfer = JSTransferring(canvas) let renderingTask = Task(executorPreference: executor) { - let canvas = try await transferringCanvas.receive() + let canvas = try await transfer.receive() try await renderAnimation(canvas: canvas, size: size) } await withTaskCancellationHandler { diff --git a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift index 859587f31..58f9aaf5b 100644 --- a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift +++ b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift @@ -5,95 +5,109 @@ import _CJavaScriptKit import Synchronization #endif -extension JSObject { +/// A temporary object intended to transfer an object from one thread to another. +/// +/// ``JSTransferring`` is `Sendable` and it's intended to be shared across threads. +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +public struct JSTransferring: @unchecked Sendable { + fileprivate struct Storage { + /// The original object that is transferred. + /// + /// Retain it here to prevent it from being released before the transfer is complete. + let sourceObject: T + /// A function that constructs an object from a JavaScript object reference. + let construct: (_ id: JavaScriptObjectRef) -> T + /// The JavaScript object reference of the original object. + let idInSource: JavaScriptObjectRef + /// The TID of the thread that owns the original object. + let sourceTid: Int32 - /// A temporary object intended to transfer a ``JSObject`` from one thread to another. - /// - /// ``JSObject`` itself is not `Sendable`, but ``Transferring`` is `Sendable` because it's - /// intended to be shared across threads. - @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) - public struct Transferring: @unchecked Sendable { - fileprivate struct CriticalState { - var continuation: CheckedContinuation? - } - fileprivate class Storage { - /// The original ``JSObject`` that is transferred. - /// - /// Retain it here to prevent it from being released before the transfer is complete. - let sourceObject: JSObject - #if compiler(>=6.1) && _runtime(_multithreaded) - let criticalState: Mutex = .init(CriticalState()) - #endif + #if compiler(>=6.1) && _runtime(_multithreaded) + /// A shared context for transferring objects across threads. + let context: _JSTransferringContext = _JSTransferringContext() + #endif + } - var idInSource: JavaScriptObjectRef { - sourceObject.id - } + private let storage: Storage - var sourceTid: Int32 { - #if compiler(>=6.1) && _runtime(_multithreaded) - sourceObject.ownerTid - #else - // On single-threaded runtime, source and destination threads are always the main thread (TID = -1). - -1 - #endif - } + fileprivate init( + sourceObject: T, + construct: @escaping (_ id: JavaScriptObjectRef) -> T, + deconstruct: @escaping (_ object: T) -> JavaScriptObjectRef, + getSourceTid: @escaping (_ object: T) -> Int32 + ) { + self.storage = Storage( + sourceObject: sourceObject, + construct: construct, + idInSource: deconstruct(sourceObject), + sourceTid: getSourceTid(sourceObject) + ) + } - init(sourceObject: JSObject) { - self.sourceObject = sourceObject + /// Receives a transferred ``JSObject`` from a thread. + /// + /// The original ``JSObject`` is ["Transferred"](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects) + /// to the receiving thread. + /// + /// Note that this method should be called only once for each ``Transferring`` instance + /// on the receiving thread. + /// + /// ### Example + /// + /// ```swift + /// let canvas = JSObject.global.document.createElement("canvas").object! + /// let transferring = JSObject.transfer(canvas.transferControlToOffscreen().object!) + /// let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + /// Task(executorPreference: executor) { + /// let canvas = try await transferring.receive() + /// } + /// ``` + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public func receive(isolation: isolated (any Actor)? = #isolation, file: StaticString = #file, line: UInt = #line) async throws -> T { + #if compiler(>=6.1) && _runtime(_multithreaded) + // The following sequence of events happens when a `JSObject` is transferred from + // the owner thread to the receiver thread: + // + // [Owner Thread] [Receiver Thread] + // <-----requestTransfer------ swjs_request_transferring_object + // ---------transfer---------> swjs_receive_object + let idInDestination = try await withCheckedThrowingContinuation { continuation in + self.storage.context.withLock { context in + guard context.continuation == nil else { + // This is a programming error, `receive` should be called only once. + fatalError("JSObject.Transferring object is already received", file: file, line: line) + } + // The continuation will be resumed by `swjs_receive_object`. + context.continuation = continuation } - } - - private let storage: Storage - - fileprivate init(sourceObject: JSObject) { - self.init(storage: Storage(sourceObject: sourceObject)) - } - - fileprivate init(storage: Storage) { - self.storage = storage - } - - /// Receives a transferred ``JSObject`` from a thread. - /// - /// The original ``JSObject`` is ["Transferred"](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects) - /// to the receiving thread. - /// - /// Note that this method should be called only once for each ``Transferring`` instance - /// on the receiving thread. - /// - /// ### Example - /// - /// ```swift - /// let canvas = JSObject.global.document.createElement("canvas").object! - /// let transferring = JSObject.transfer(canvas.transferControlToOffscreen().object!) - /// let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) - /// Task(executorPreference: executor) { - /// let canvas = try await transferring.receive() - /// } - /// ``` - @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) - public func receive(isolation: isolated (any Actor)? = #isolation, file: StaticString = #file, line: UInt = #line) async throws -> JSObject { - #if compiler(>=6.1) && _runtime(_multithreaded) swjs_request_transferring_object( self.storage.idInSource, self.storage.sourceTid, - Unmanaged.passRetained(self.storage).toOpaque() + Unmanaged.passRetained(self.storage.context).toOpaque() ) - let idInDestination = try await withCheckedThrowingContinuation { continuation in - self.storage.criticalState.withLock { criticalState in - guard criticalState.continuation == nil else { - // This is a programming error, `receive` should be called only once. - fatalError("JSObject.Transferring object is already received", file: file, line: line) - } - criticalState.continuation = continuation - } - } - return JSObject(id: idInDestination) - #else - return JSObject(id: storage.idInSource) - #endif } + return storage.construct(idInDestination) + #else + return storage.construct(storage.idInSource) + #endif + } +} + +fileprivate final class _JSTransferringContext: Sendable { + struct State { + var continuation: CheckedContinuation? } + private let state: Mutex = .init(State()) + + func withLock(_ body: (inout State) -> R) -> R { + return state.withLock { state in + body(&state) + } + } +} + + +extension JSTransferring where T == JSObject { /// Transfers the ownership of a `JSObject` to be sent to another thread. /// @@ -104,8 +118,21 @@ extension JSObject { /// - Parameter object: The ``JSObject`` to be transferred. /// - Returns: A ``Transferring`` instance that can be shared across threads. @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) - public static func transfer(_ object: JSObject) -> Transferring { - return Transferring(sourceObject: object) + public init(_ object: JSObject) { + self.init( + sourceObject: object, + construct: { JSObject(id: $0) }, + deconstruct: { $0.id }, + getSourceTid: { + #if compiler(>=6.1) && _runtime(_multithreaded) + return $0.ownerTid + #else + _ = $0 + // On single-threaded runtime, source and destination threads are always the main thread (TID = -1). + return -1 + #endif + } + ) } } @@ -123,10 +150,10 @@ extension JSObject { @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) func _swjs_receive_object(_ object: JavaScriptObjectRef, _ transferring: UnsafeRawPointer) { #if compiler(>=6.1) && _runtime(_multithreaded) - let storage = Unmanaged.fromOpaque(transferring).takeRetainedValue() - storage.criticalState.withLock { criticalState in - assert(criticalState.continuation != nil, "JSObject.Transferring object is not yet received!?") - criticalState.continuation?.resume(returning: object) + let context = Unmanaged<_JSTransferringContext>.fromOpaque(transferring).takeRetainedValue() + context.withLock { state in + assert(state.continuation != nil, "JSObject.Transferring object is not yet received!?") + state.continuation?.resume(returning: object) } #endif } diff --git a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift index 7d79c39fa..4892df591 100644 --- a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift +++ b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift @@ -267,7 +267,7 @@ final class WebWorkerTaskExecutorTests: XCTestCase { func testTransfer() async throws { let Uint8Array = JSObject.global.Uint8Array.function! let buffer = Uint8Array.new(100).buffer.object! - let transferring = JSObject.transfer(buffer) + let transferring = JSTransferring(buffer) let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) let task = Task(executorPreference: executor) { let buffer = try await transferring.receive() @@ -281,7 +281,7 @@ final class WebWorkerTaskExecutorTests: XCTestCase { func testTransferNonTransferable() async throws { let object = JSObject.global.Object.function!.new() - let transferring = JSObject.transfer(object) + let transferring = JSTransferring(object) let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) let task = Task(executorPreference: executor) { _ = try await transferring.receive() From f25bfec40071881d648038eb9fd41f5f99a57035 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Tue, 11 Mar 2025 04:33:50 +0000 Subject: [PATCH 12/19] MessageBroker --- Runtime/src/index.ts | 266 +++++------------- Runtime/src/itc.ts | 235 ++++++++++++++++ Runtime/src/js-value.ts | 76 +++++ Runtime/src/types.ts | 3 +- .../JSObject+Transferring.swift | 27 +- Sources/JavaScriptKit/Runtime/index.js | 251 +++++++++++------ Sources/JavaScriptKit/Runtime/index.mjs | 251 +++++++++++------ .../WebWorkerTaskExecutorTests.swift | 17 +- 8 files changed, 752 insertions(+), 374 deletions(-) create mode 100644 Runtime/src/itc.ts diff --git a/Runtime/src/index.ts b/Runtime/src/index.ts index 25d6e92f5..5cb1acfc2 100644 --- a/Runtime/src/index.ts +++ b/Runtime/src/index.ts @@ -10,120 +10,7 @@ import { } from "./types.js"; import * as JSValue from "./js-value.js"; import { Memory } from "./memory.js"; - -type TransferMessage = { - type: "transfer"; - data: { - object: any; - transferring: pointer; - destinationTid: number; - }; -}; - -type RequestTransferMessage = { - type: "requestTransfer"; - data: { - objectRef: ref; - objectSourceTid: number; - transferring: pointer; - destinationTid: number; - }; -}; - -type TransferErrorMessage = { - type: "transferError"; - data: { - error: string; - }; -}; - -type MainToWorkerMessage = { - type: "wake"; -} | RequestTransferMessage | TransferMessage | TransferErrorMessage; - -type WorkerToMainMessage = { - type: "job"; - data: number; -} | RequestTransferMessage | TransferMessage | TransferErrorMessage; - -/** - * A thread channel is a set of functions that are used to communicate between - * the main thread and the worker thread. The main thread and the worker thread - * can send messages to each other using these functions. - * - * @example - * ```javascript - * // worker.js - * const runtime = new SwiftRuntime({ - * threadChannel: { - * postMessageToMainThread: postMessage, - * listenMessageFromMainThread: (listener) => { - * self.onmessage = (event) => { - * listener(event.data); - * }; - * } - * } - * }); - * - * // main.js - * const worker = new Worker("worker.js"); - * const runtime = new SwiftRuntime({ - * threadChannel: { - * postMessageToWorkerThread: (tid, data) => { - * worker.postMessage(data); - * }, - * listenMessageFromWorkerThread: (tid, listener) => { - * worker.onmessage = (event) => { - listener(event.data); - * }; - * } - * } - * }); - * ``` - */ -export type SwiftRuntimeThreadChannel = - | { - /** - * This function is used to send messages from the worker thread to the main thread. - * The message submitted by this function is expected to be listened by `listenMessageFromWorkerThread`. - * @param message The message to be sent to the main thread. - * @param transfer The array of objects to be transferred to the main thread. - */ - postMessageToMainThread: (message: WorkerToMainMessage, transfer: any[]) => void; - /** - * This function is expected to be set in the worker thread and should listen - * to messages from the main thread sent by `postMessageToWorkerThread`. - * @param listener The listener function to be called when a message is received from the main thread. - */ - listenMessageFromMainThread: (listener: (message: MainToWorkerMessage) => void) => void; - } - | { - /** - * This function is expected to be set in the main thread. - * The message submitted by this function is expected to be listened by `listenMessageFromMainThread`. - * @param tid The thread ID of the worker thread. - * @param message The message to be sent to the worker thread. - * @param transfer The array of objects to be transferred to the worker thread. - */ - postMessageToWorkerThread: (tid: number, message: MainToWorkerMessage, transfer: any[]) => void; - /** - * This function is expected to be set in the main thread and should listen - * to messages sent by `postMessageToMainThread` from the worker thread. - * @param tid The thread ID of the worker thread. - * @param listener The listener function to be called when a message is received from the worker thread. - */ - listenMessageFromWorkerThread: ( - tid: number, - listener: (message: WorkerToMainMessage) => void - ) => void; - - /** - * This function is expected to be set in the main thread and called - * when the worker thread is terminated. - * @param tid The thread ID of the worker thread. - */ - terminateWorkerThread?: (tid: number) => void; - }; +import { deserializeError, MainToWorkerMessage, MessageBroker, ResponseMessage, ITCInterface, serializeError, SwiftRuntimeThreadChannel, WorkerToMainMessage } from "./itc.js"; export type SwiftRuntimeOptions = { /** @@ -294,6 +181,51 @@ export class SwiftRuntime { importObjects = () => this.wasmImports; get wasmImports(): ImportedFunctions { + let broker: MessageBroker | null = null; + const getMessageBroker = (threadChannel: SwiftRuntimeThreadChannel) => { + if (broker) return broker; + const itcInterface = new ITCInterface(this.memory); + const newBroker = new MessageBroker(this.tid ?? -1, threadChannel, { + onRequest: (message) => { + let returnValue: ResponseMessage["data"]["response"]; + try { + const result = itcInterface[message.data.request.method](...message.data.request.parameters); + returnValue = { ok: true, value: result }; + } catch (error) { + returnValue = { ok: false, error: serializeError(error) }; + } + const responseMessage: ResponseMessage = { + type: "response", + data: { + sourceTid: message.data.sourceTid, + context: message.data.context, + response: returnValue, + }, + } + try { + newBroker.reply(responseMessage); + } catch (error) { + responseMessage.data.response = { + ok: false, + error: serializeError(new TypeError(`Failed to serialize response message: ${error}`)) + }; + newBroker.reply(responseMessage); + } + }, + 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); + } + } + }) + broker = newBroker; + return newBroker; + } return { swjs_set_prop: ( ref: ref, @@ -634,38 +566,18 @@ export class SwiftRuntime { "listenMessageFromMainThread is not set in options given to SwiftRuntime. Please set it to listen to wake events from the main thread." ); } + const broker = getMessageBroker(threadChannel); threadChannel.listenMessageFromMainThread((message) => { switch (message.type) { case "wake": this.exports.swjs_wake_worker_thread(); break; - case "requestTransfer": { - const object = this.memory.getObject(message.data.objectRef); - const messageToMainThread: TransferMessage = { - type: "transfer", - data: { - object, - destinationTid: message.data.destinationTid, - transferring: message.data.transferring, - }, - }; - try { - this.postMessageToMainThread(messageToMainThread, [object]); - } catch (error) { - this.postMessageToMainThread({ - type: "transferError", - data: { error: String(error) }, - }); - } - break; - } - case "transfer": { - const objectRef = this.memory.retain(message.data.object); - this.exports.swjs_receive_object(objectRef, message.data.transferring); + case "request": { + broker.onReceivingRequest(message); break; } - case "transferError": { - console.error(message.data.error); // TODO: Handle the error + case "response": { + broker.onReceivingResponse(message); break; } default: @@ -684,59 +596,19 @@ export class SwiftRuntime { "listenMessageFromWorkerThread is not set in options given to SwiftRuntime. Please set it to listen to jobs from worker threads." ); } + const broker = getMessageBroker(threadChannel); threadChannel.listenMessageFromWorkerThread( tid, (message) => { switch (message.type) { case "job": this.exports.swjs_enqueue_main_job_from_worker(message.data); break; - case "requestTransfer": { - if (message.data.objectSourceTid == MAIN_THREAD_TID) { - const object = this.memory.getObject(message.data.objectRef); - if (message.data.destinationTid != tid) { - throw new Error("Invariant violation: The destination tid of the transfer request must be the same as the tid of the worker thread that received the request."); - } - this.postMessageToWorkerThread(message.data.destinationTid, { - type: "transfer", - data: { - object, - transferring: message.data.transferring, - destinationTid: message.data.destinationTid, - }, - }, [object]); - } else { - // Proxy the transfer request to the worker thread that owns the object - this.postMessageToWorkerThread(message.data.objectSourceTid, { - type: "requestTransfer", - data: { - objectRef: message.data.objectRef, - objectSourceTid: tid, - transferring: message.data.transferring, - destinationTid: message.data.destinationTid, - }, - }); - } + case "request": { + broker.onReceivingRequest(message); break; } - case "transfer": { - if (message.data.destinationTid == MAIN_THREAD_TID) { - const objectRef = this.memory.retain(message.data.object); - this.exports.swjs_receive_object(objectRef, message.data.transferring); - } else { - // Proxy the transfer response to the destination worker thread - this.postMessageToWorkerThread(message.data.destinationTid, { - type: "transfer", - data: { - object: message.data.object, - transferring: message.data.transferring, - destinationTid: message.data.destinationTid, - }, - }, [message.data.object]); - } - break; - } - case "transferError": { - console.error(message.data.error); // TODO: Handle the error + case "response": { + broker.onReceivingResponse(message); break; } default: @@ -761,20 +633,22 @@ export class SwiftRuntime { object_source_tid: number, transferring: pointer, ) => { - if (this.tid == object_source_tid) { - // Fast path: The object is already in the same thread - this.exports.swjs_receive_object(object_ref, transferring); - return; + if (!this.options.threadChannel) { + throw new Error("threadChannel is not set in options given to SwiftRuntime. Please set it to request transferring objects."); } - this.postMessageToMainThread({ - type: "requestTransfer", + const broker = getMessageBroker(this.options.threadChannel); + broker.request({ + type: "request", data: { - objectRef: object_ref, - objectSourceTid: object_source_tid, - transferring, - destinationTid: this.tid ?? MAIN_THREAD_TID, - }, - }); + sourceTid: this.tid ?? MAIN_THREAD_TID, + targetTid: object_source_tid, + context: transferring, + request: { + method: "transfer", + parameters: [object_ref, transferring], + } + } + }) }, }; } diff --git a/Runtime/src/itc.ts b/Runtime/src/itc.ts new file mode 100644 index 000000000..44b37c7be --- /dev/null +++ b/Runtime/src/itc.ts @@ -0,0 +1,235 @@ +// This file defines the interface for the inter-thread communication. +import type { ref, pointer } from "./types.js"; +import { Memory } from "./memory.js"; + +/** + * A thread channel is a set of functions that are used to communicate between + * the main thread and the worker thread. The main thread and the worker thread + * can send messages to each other using these functions. + * + * @example + * ```javascript + * // worker.js + * const runtime = new SwiftRuntime({ + * threadChannel: { + * postMessageToMainThread: postMessage, + * listenMessageFromMainThread: (listener) => { + * self.onmessage = (event) => { + * listener(event.data); + * }; + * } + * } + * }); + * + * // main.js + * const worker = new Worker("worker.js"); + * const runtime = new SwiftRuntime({ + * threadChannel: { + * postMessageToWorkerThread: (tid, data) => { + * worker.postMessage(data); + * }, + * listenMessageFromWorkerThread: (tid, listener) => { + * worker.onmessage = (event) => { + listener(event.data); + * }; + * } + * } + * }); + * ``` + */ +export type SwiftRuntimeThreadChannel = + | { + /** + * This function is used to send messages from the worker thread to the main thread. + * The message submitted by this function is expected to be listened by `listenMessageFromWorkerThread`. + * @param message The message to be sent to the main thread. + * @param transfer The array of objects to be transferred to the main thread. + */ + postMessageToMainThread: (message: WorkerToMainMessage, transfer: any[]) => void; + /** + * This function is expected to be set in the worker thread and should listen + * to messages from the main thread sent by `postMessageToWorkerThread`. + * @param listener The listener function to be called when a message is received from the main thread. + */ + listenMessageFromMainThread: (listener: (message: MainToWorkerMessage) => void) => void; + } + | { + /** + * This function is expected to be set in the main thread. + * The message submitted by this function is expected to be listened by `listenMessageFromMainThread`. + * @param tid The thread ID of the worker thread. + * @param message The message to be sent to the worker thread. + * @param transfer The array of objects to be transferred to the worker thread. + */ + postMessageToWorkerThread: (tid: number, message: MainToWorkerMessage, transfer: any[]) => void; + /** + * This function is expected to be set in the main thread and should listen + * to messages sent by `postMessageToMainThread` from the worker thread. + * @param tid The thread ID of the worker thread. + * @param listener The listener function to be called when a message is received from the worker thread. + */ + listenMessageFromWorkerThread: ( + tid: number, + listener: (message: WorkerToMainMessage) => void + ) => void; + + /** + * This function is expected to be set in the main thread and called + * when the worker thread is terminated. + * @param tid The thread ID of the worker thread. + */ + terminateWorkerThread?: (tid: number) => void; + }; + + +export class ITCInterface { + constructor(private memory: Memory) {} + + transfer(objectRef: ref, transferring: pointer): { object: any, transferring: pointer, transfer: Transferable[] } { + const object = this.memory.getObject(objectRef); + return { object, transferring, transfer: [object] }; + } +} + +type AllRequests> = { + [K in keyof Interface]: { + method: K, + parameters: Parameters, + } +} + +type ITCRequest> = AllRequests[keyof AllRequests]; +type AllResponses> = { + [K in keyof Interface]: ReturnType +} +type ITCResponse> = AllResponses[keyof AllResponses]; + +export type RequestMessage = { + type: "request"; + data: { + /** The TID of the thread that sent the request */ + sourceTid: number; + /** The TID of the thread that should respond to the request */ + targetTid: number; + /** The context pointer of the request */ + context: pointer; + /** The request content */ + request: ITCRequest; + } +} + +type SerializedError = { isError: true; value: Error } | { isError: false; value: unknown } + +export type ResponseMessage = { + type: "response"; + data: { + /** The TID of the thread that sent the response */ + sourceTid: number; + /** The context pointer of the request */ + context: pointer; + /** The response content */ + response: { + ok: true, + value: ITCResponse; + } | { + ok: false, + error: SerializedError; + }; + } +} + +export type MainToWorkerMessage = { + type: "wake"; +} | RequestMessage | ResponseMessage; + +export type WorkerToMainMessage = { + type: "job"; + data: number; +} | RequestMessage | ResponseMessage; + + +export class MessageBroker { + constructor( + private selfTid: number, + private threadChannel: SwiftRuntimeThreadChannel, + private handlers: { + onRequest: (message: RequestMessage) => void, + onResponse: (message: ResponseMessage) => void, + } + ) { + } + + request(message: RequestMessage) { + if (message.data.targetTid == this.selfTid) { + // The request is for the current thread + this.handlers.onRequest(message); + } else if ("postMessageToWorkerThread" in this.threadChannel) { + // The request is for another worker thread sent from the main thread + this.threadChannel.postMessageToWorkerThread(message.data.targetTid, message, []); + } else if ("postMessageToMainThread" in this.threadChannel) { + // The request is for other worker threads or the main thread sent from a worker thread + this.threadChannel.postMessageToMainThread(message, []); + } else { + throw new Error("unreachable"); + } + } + + reply(message: ResponseMessage) { + if (message.data.sourceTid == this.selfTid) { + // The response is for the current thread + this.handlers.onResponse(message); + return; + } + const transfer = message.data.response.ok ? message.data.response.value.transfer : []; + if ("postMessageToWorkerThread" in this.threadChannel) { + // The response is for another worker thread sent from the main thread + this.threadChannel.postMessageToWorkerThread(message.data.sourceTid, message, transfer); + } else if ("postMessageToMainThread" in this.threadChannel) { + // The response is for other worker threads or the main thread sent from a worker thread + this.threadChannel.postMessageToMainThread(message, transfer); + } else { + throw new Error("unreachable"); + } + } + + onReceivingRequest(message: RequestMessage) { + if (message.data.targetTid == this.selfTid) { + this.handlers.onRequest(message); + } else if ("postMessageToWorkerThread" in this.threadChannel) { + // Receive a request from a worker thread to other worker on main thread. + // Proxy the request to the target worker thread. + this.threadChannel.postMessageToWorkerThread(message.data.targetTid, message, []); + } else if ("postMessageToMainThread" in this.threadChannel) { + // A worker thread won't receive a request for other worker threads + throw new Error("unreachable"); + } + } + + onReceivingResponse(message: ResponseMessage) { + if (message.data.sourceTid == this.selfTid) { + this.handlers.onResponse(message); + } else if ("postMessageToWorkerThread" in this.threadChannel) { + // Receive a response from a worker thread to other worker on main thread. + // Proxy the response to the target worker thread. + const transfer = message.data.response.ok ? message.data.response.value.transfer : []; + this.threadChannel.postMessageToWorkerThread(message.data.sourceTid, message, transfer); + } else if ("postMessageToMainThread" in this.threadChannel) { + // A worker thread won't receive a response for other worker threads + throw new Error("unreachable"); + } + } +} + +export function serializeError(error: unknown): SerializedError { + if (error instanceof Error) { + return { isError: true, value: { message: error.message, name: error.name, stack: error.stack } }; + } + return { isError: false, value: error }; +} + +export function deserializeError(error: SerializedError): unknown { + if (error.isError) { + return Object.assign(new Error(error.value.message), error.value); + } + return error.value; +} diff --git a/Runtime/src/js-value.ts b/Runtime/src/js-value.ts index 1b142de05..29e4a42a4 100644 --- a/Runtime/src/js-value.ts +++ b/Runtime/src/js-value.ts @@ -92,6 +92,82 @@ export const write = ( memory.writeUint32(kind_ptr, kind); }; +export function decompose(value: any, memory: Memory): { + kind: JavaScriptValueKindAndFlags; + payload1: number; + payload2: number; +} { + if (value === null) { + return { + kind: Kind.Null, + payload1: 0, + payload2: 0, + } + } + const type = typeof value; + switch (type) { + case "boolean": { + return { + kind: Kind.Boolean, + payload1: value ? 1 : 0, + payload2: 0, + } + } + case "number": { + return { + kind: Kind.Number, + payload1: 0, + payload2: value, + } + } + case "string": { + return { + kind: Kind.String, + payload1: memory.retain(value), + payload2: 0, + } + } + case "undefined": { + return { + kind: Kind.Undefined, + payload1: 0, + payload2: 0, + } + } + case "object": { + return { + kind: Kind.Object, + payload1: memory.retain(value), + payload2: 0, + } + } + case "function": { + return { + kind: Kind.Function, + payload1: memory.retain(value), + payload2: 0, + } + } + case "symbol": { + return { + kind: Kind.Symbol, + payload1: memory.retain(value), + payload2: 0, + } + } + case "bigint": { + return { + kind: Kind.BigInt, + payload1: memory.retain(value), + payload2: 0, + } + } + default: + assertNever(type, `Type "${type}" is not supported yet`); + } + throw new Error("unreachable"); +} + export const writeAndReturnKindBits = ( value: any, payload1_ptr: pointer, diff --git a/Runtime/src/types.ts b/Runtime/src/types.ts index 4e311ef80..a83a74f0c 100644 --- a/Runtime/src/types.ts +++ b/Runtime/src/types.ts @@ -22,7 +22,8 @@ export interface ExportedFunctions { swjs_enqueue_main_job_from_worker(unowned_job: number): void; swjs_wake_worker_thread(): void; - swjs_receive_object(object: ref, transferring: pointer): void; + swjs_receive_response(object: ref, transferring: pointer): void; + swjs_receive_error(error: ref, context: number): void; } export interface ImportedFunctions { diff --git a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift index 58f9aaf5b..6deee6598 100644 --- a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift +++ b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift @@ -144,11 +144,11 @@ extension JSTransferring where T == JSObject { /// - object: The `JSObject` to be received. /// - transferring: A pointer to the `Transferring.Storage` instance. #if compiler(>=6.1) // @_expose and @_extern are only available in Swift 6.1+ -@_expose(wasm, "swjs_receive_object") -@_cdecl("swjs_receive_object") +@_expose(wasm, "swjs_receive_response") +@_cdecl("swjs_receive_response") #endif @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) -func _swjs_receive_object(_ object: JavaScriptObjectRef, _ transferring: UnsafeRawPointer) { +func _swjs_receive_response(_ object: JavaScriptObjectRef, _ transferring: UnsafeRawPointer) { #if compiler(>=6.1) && _runtime(_multithreaded) let context = Unmanaged<_JSTransferringContext>.fromOpaque(transferring).takeRetainedValue() context.withLock { state in @@ -157,3 +157,24 @@ func _swjs_receive_object(_ object: JavaScriptObjectRef, _ transferring: UnsafeR } #endif } + +/// A function that should be called when an object source thread sends an error to a +/// destination thread. +/// +/// - Parameters: +/// - error: The error to be received. +/// - transferring: A pointer to the `Transferring.Storage` instance. +#if compiler(>=6.1) // @_expose and @_extern are only available in Swift 6.1+ +@_expose(wasm, "swjs_receive_error") +@_cdecl("swjs_receive_error") +#endif +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +func _swjs_receive_error(_ error: JavaScriptObjectRef, _ transferring: UnsafeRawPointer) { + #if compiler(>=6.1) && _runtime(_multithreaded) + let context = Unmanaged<_JSTransferringContext>.fromOpaque(transferring).takeRetainedValue() + context.withLock { state in + assert(state.continuation != nil, "JSObject.Transferring object is not yet received!?") + state.continuation?.resume(throwing: JSException(JSObject(id: error).jsValue)) + } + #endif +} diff --git a/Sources/JavaScriptKit/Runtime/index.js b/Sources/JavaScriptKit/Runtime/index.js index 8027593e5..206251a11 100644 --- a/Sources/JavaScriptKit/Runtime/index.js +++ b/Sources/JavaScriptKit/Runtime/index.js @@ -196,6 +196,100 @@ } } + class ITCInterface { + constructor(memory) { + this.memory = memory; + } + transfer(objectRef, transferring) { + const object = this.memory.getObject(objectRef); + return { object, transferring, transfer: [object] }; + } + } + class MessageBroker { + constructor(selfTid, threadChannel, handlers) { + this.selfTid = selfTid; + this.threadChannel = threadChannel; + this.handlers = handlers; + } + request(message) { + if (message.data.targetTid == this.selfTid) { + // The request is for the current thread + this.handlers.onRequest(message); + } + else if ("postMessageToWorkerThread" in this.threadChannel) { + // The request is for another worker thread sent from the main thread + this.threadChannel.postMessageToWorkerThread(message.data.targetTid, message, []); + } + else if ("postMessageToMainThread" in this.threadChannel) { + // The request is for other worker threads or the main thread sent from a worker thread + this.threadChannel.postMessageToMainThread(message, []); + } + else { + throw new Error("unreachable"); + } + } + reply(message) { + if (message.data.sourceTid == this.selfTid) { + // The response is for the current thread + this.handlers.onResponse(message); + return; + } + const transfer = message.data.response.ok ? message.data.response.value.transfer : []; + if ("postMessageToWorkerThread" in this.threadChannel) { + // The response is for another worker thread sent from the main thread + this.threadChannel.postMessageToWorkerThread(message.data.sourceTid, message, transfer); + } + else if ("postMessageToMainThread" in this.threadChannel) { + // The response is for other worker threads or the main thread sent from a worker thread + this.threadChannel.postMessageToMainThread(message, transfer); + } + else { + throw new Error("unreachable"); + } + } + onReceivingRequest(message) { + if (message.data.targetTid == this.selfTid) { + this.handlers.onRequest(message); + } + else if ("postMessageToWorkerThread" in this.threadChannel) { + // Receive a request from a worker thread to other worker on main thread. + // Proxy the request to the target worker thread. + this.threadChannel.postMessageToWorkerThread(message.data.targetTid, message, []); + } + else if ("postMessageToMainThread" in this.threadChannel) { + // A worker thread won't receive a request for other worker threads + throw new Error("unreachable"); + } + } + onReceivingResponse(message) { + if (message.data.sourceTid == this.selfTid) { + this.handlers.onResponse(message); + } + else if ("postMessageToWorkerThread" in this.threadChannel) { + // Receive a response from a worker thread to other worker on main thread. + // Proxy the response to the target worker thread. + const transfer = message.data.response.ok ? message.data.response.value.transfer : []; + this.threadChannel.postMessageToWorkerThread(message.data.sourceTid, message, transfer); + } + else if ("postMessageToMainThread" in this.threadChannel) { + // A worker thread won't receive a response for other worker threads + throw new Error("unreachable"); + } + } + } + function serializeError(error) { + if (error instanceof Error) { + return { isError: true, value: { message: error.message, name: error.name, stack: error.stack } }; + } + return { isError: false, value: error }; + } + function deserializeError(error) { + if (error.isError) { + return Object.assign(new Error(error.value.message), error.value); + } + return error.value; + } + class SwiftRuntime { constructor(options) { this.version = 708; @@ -313,6 +407,56 @@ return output; } get wasmImports() { + let broker = null; + const getMessageBroker = (threadChannel) => { + var _a; + if (broker) + return broker; + const itcInterface = new ITCInterface(this.memory); + const newBroker = new MessageBroker((_a = this.tid) !== null && _a !== void 0 ? _a : -1, threadChannel, { + onRequest: (message) => { + let returnValue; + try { + const result = itcInterface[message.data.request.method](...message.data.request.parameters); + returnValue = { ok: true, value: result }; + } + catch (error) { + returnValue = { ok: false, error: serializeError(error) }; + } + const responseMessage = { + type: "response", + data: { + sourceTid: message.data.sourceTid, + context: message.data.context, + response: returnValue, + }, + }; + try { + newBroker.reply(responseMessage); + } + catch (error) { + responseMessage.data.response = { + ok: false, + error: serializeError(new TypeError(`Failed to serialize response message: ${error}`)) + }; + newBroker.reply(responseMessage); + } + }, + 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); + } + } + }); + broker = newBroker; + return newBroker; + }; return { swjs_set_prop: (ref, name, kind, payload1, payload2) => { const memory = this.memory; @@ -508,39 +652,18 @@ if (!(threadChannel && "listenMessageFromMainThread" in threadChannel)) { throw new Error("listenMessageFromMainThread is not set in options given to SwiftRuntime. Please set it to listen to wake events from the main thread."); } + const broker = getMessageBroker(threadChannel); threadChannel.listenMessageFromMainThread((message) => { switch (message.type) { case "wake": this.exports.swjs_wake_worker_thread(); break; - case "requestTransfer": { - const object = this.memory.getObject(message.data.objectRef); - const messageToMainThread = { - type: "transfer", - data: { - object, - destinationTid: message.data.destinationTid, - transferring: message.data.transferring, - }, - }; - try { - this.postMessageToMainThread(messageToMainThread, [object]); - } - catch (error) { - this.postMessageToMainThread({ - type: "transferError", - data: { error: String(error) }, - }); - } - break; - } - case "transfer": { - const objectRef = this.memory.retain(message.data.object); - this.exports.swjs_receive_object(objectRef, message.data.transferring); + case "request": { + broker.onReceivingRequest(message); break; } - case "transferError": { - console.error(message.data.error); // TODO: Handle the error + case "response": { + broker.onReceivingResponse(message); break; } default: @@ -557,60 +680,18 @@ if (!(threadChannel && "listenMessageFromWorkerThread" in threadChannel)) { throw new Error("listenMessageFromWorkerThread is not set in options given to SwiftRuntime. Please set it to listen to jobs from worker threads."); } + const broker = getMessageBroker(threadChannel); threadChannel.listenMessageFromWorkerThread(tid, (message) => { switch (message.type) { case "job": this.exports.swjs_enqueue_main_job_from_worker(message.data); break; - case "requestTransfer": { - if (message.data.objectSourceTid == MAIN_THREAD_TID) { - const object = this.memory.getObject(message.data.objectRef); - if (message.data.destinationTid != tid) { - throw new Error("Invariant violation: The destination tid of the transfer request must be the same as the tid of the worker thread that received the request."); - } - this.postMessageToWorkerThread(message.data.destinationTid, { - type: "transfer", - data: { - object, - transferring: message.data.transferring, - destinationTid: message.data.destinationTid, - }, - }, [object]); - } - else { - // Proxy the transfer request to the worker thread that owns the object - this.postMessageToWorkerThread(message.data.objectSourceTid, { - type: "requestTransfer", - data: { - objectRef: message.data.objectRef, - objectSourceTid: tid, - transferring: message.data.transferring, - destinationTid: message.data.destinationTid, - }, - }); - } - break; - } - case "transfer": { - if (message.data.destinationTid == MAIN_THREAD_TID) { - const objectRef = this.memory.retain(message.data.object); - this.exports.swjs_receive_object(objectRef, message.data.transferring); - } - else { - // Proxy the transfer response to the destination worker thread - this.postMessageToWorkerThread(message.data.destinationTid, { - type: "transfer", - data: { - object: message.data.object, - transferring: message.data.transferring, - destinationTid: message.data.destinationTid, - }, - }, [message.data.object]); - } + case "request": { + broker.onReceivingRequest(message); break; } - case "transferError": { - console.error(message.data.error); // TODO: Handle the error + case "response": { + broker.onReceivingResponse(message); break; } default: @@ -632,19 +713,21 @@ }, swjs_request_transferring_object: (object_ref, object_source_tid, transferring) => { var _a; - if (this.tid == object_source_tid) { - // Fast path: The object is already in the same thread - this.exports.swjs_receive_object(object_ref, transferring); - return; + if (!this.options.threadChannel) { + throw new Error("threadChannel is not set in options given to SwiftRuntime. Please set it to request transferring objects."); } - this.postMessageToMainThread({ - type: "requestTransfer", + const broker = getMessageBroker(this.options.threadChannel); + broker.request({ + type: "request", data: { - objectRef: object_ref, - objectSourceTid: object_source_tid, - transferring, - destinationTid: (_a = this.tid) !== null && _a !== void 0 ? _a : MAIN_THREAD_TID, - }, + sourceTid: (_a = this.tid) !== null && _a !== void 0 ? _a : MAIN_THREAD_TID, + targetTid: object_source_tid, + context: transferring, + request: { + method: "transfer", + parameters: [object_ref, transferring], + } + } }); }, }; diff --git a/Sources/JavaScriptKit/Runtime/index.mjs b/Sources/JavaScriptKit/Runtime/index.mjs index 6a3df7477..62d9558ee 100644 --- a/Sources/JavaScriptKit/Runtime/index.mjs +++ b/Sources/JavaScriptKit/Runtime/index.mjs @@ -190,6 +190,100 @@ class Memory { } } +class ITCInterface { + constructor(memory) { + this.memory = memory; + } + transfer(objectRef, transferring) { + const object = this.memory.getObject(objectRef); + return { object, transferring, transfer: [object] }; + } +} +class MessageBroker { + constructor(selfTid, threadChannel, handlers) { + this.selfTid = selfTid; + this.threadChannel = threadChannel; + this.handlers = handlers; + } + request(message) { + if (message.data.targetTid == this.selfTid) { + // The request is for the current thread + this.handlers.onRequest(message); + } + else if ("postMessageToWorkerThread" in this.threadChannel) { + // The request is for another worker thread sent from the main thread + this.threadChannel.postMessageToWorkerThread(message.data.targetTid, message, []); + } + else if ("postMessageToMainThread" in this.threadChannel) { + // The request is for other worker threads or the main thread sent from a worker thread + this.threadChannel.postMessageToMainThread(message, []); + } + else { + throw new Error("unreachable"); + } + } + reply(message) { + if (message.data.sourceTid == this.selfTid) { + // The response is for the current thread + this.handlers.onResponse(message); + return; + } + const transfer = message.data.response.ok ? message.data.response.value.transfer : []; + if ("postMessageToWorkerThread" in this.threadChannel) { + // The response is for another worker thread sent from the main thread + this.threadChannel.postMessageToWorkerThread(message.data.sourceTid, message, transfer); + } + else if ("postMessageToMainThread" in this.threadChannel) { + // The response is for other worker threads or the main thread sent from a worker thread + this.threadChannel.postMessageToMainThread(message, transfer); + } + else { + throw new Error("unreachable"); + } + } + onReceivingRequest(message) { + if (message.data.targetTid == this.selfTid) { + this.handlers.onRequest(message); + } + else if ("postMessageToWorkerThread" in this.threadChannel) { + // Receive a request from a worker thread to other worker on main thread. + // Proxy the request to the target worker thread. + this.threadChannel.postMessageToWorkerThread(message.data.targetTid, message, []); + } + else if ("postMessageToMainThread" in this.threadChannel) { + // A worker thread won't receive a request for other worker threads + throw new Error("unreachable"); + } + } + onReceivingResponse(message) { + if (message.data.sourceTid == this.selfTid) { + this.handlers.onResponse(message); + } + else if ("postMessageToWorkerThread" in this.threadChannel) { + // Receive a response from a worker thread to other worker on main thread. + // Proxy the response to the target worker thread. + const transfer = message.data.response.ok ? message.data.response.value.transfer : []; + this.threadChannel.postMessageToWorkerThread(message.data.sourceTid, message, transfer); + } + else if ("postMessageToMainThread" in this.threadChannel) { + // A worker thread won't receive a response for other worker threads + throw new Error("unreachable"); + } + } +} +function serializeError(error) { + if (error instanceof Error) { + return { isError: true, value: { message: error.message, name: error.name, stack: error.stack } }; + } + return { isError: false, value: error }; +} +function deserializeError(error) { + if (error.isError) { + return Object.assign(new Error(error.value.message), error.value); + } + return error.value; +} + class SwiftRuntime { constructor(options) { this.version = 708; @@ -307,6 +401,56 @@ class SwiftRuntime { return output; } get wasmImports() { + let broker = null; + const getMessageBroker = (threadChannel) => { + var _a; + if (broker) + return broker; + const itcInterface = new ITCInterface(this.memory); + const newBroker = new MessageBroker((_a = this.tid) !== null && _a !== void 0 ? _a : -1, threadChannel, { + onRequest: (message) => { + let returnValue; + try { + const result = itcInterface[message.data.request.method](...message.data.request.parameters); + returnValue = { ok: true, value: result }; + } + catch (error) { + returnValue = { ok: false, error: serializeError(error) }; + } + const responseMessage = { + type: "response", + data: { + sourceTid: message.data.sourceTid, + context: message.data.context, + response: returnValue, + }, + }; + try { + newBroker.reply(responseMessage); + } + catch (error) { + responseMessage.data.response = { + ok: false, + error: serializeError(new TypeError(`Failed to serialize response message: ${error}`)) + }; + newBroker.reply(responseMessage); + } + }, + 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); + } + } + }); + broker = newBroker; + return newBroker; + }; return { swjs_set_prop: (ref, name, kind, payload1, payload2) => { const memory = this.memory; @@ -502,39 +646,18 @@ class SwiftRuntime { if (!(threadChannel && "listenMessageFromMainThread" in threadChannel)) { throw new Error("listenMessageFromMainThread is not set in options given to SwiftRuntime. Please set it to listen to wake events from the main thread."); } + const broker = getMessageBroker(threadChannel); threadChannel.listenMessageFromMainThread((message) => { switch (message.type) { case "wake": this.exports.swjs_wake_worker_thread(); break; - case "requestTransfer": { - const object = this.memory.getObject(message.data.objectRef); - const messageToMainThread = { - type: "transfer", - data: { - object, - destinationTid: message.data.destinationTid, - transferring: message.data.transferring, - }, - }; - try { - this.postMessageToMainThread(messageToMainThread, [object]); - } - catch (error) { - this.postMessageToMainThread({ - type: "transferError", - data: { error: String(error) }, - }); - } - break; - } - case "transfer": { - const objectRef = this.memory.retain(message.data.object); - this.exports.swjs_receive_object(objectRef, message.data.transferring); + case "request": { + broker.onReceivingRequest(message); break; } - case "transferError": { - console.error(message.data.error); // TODO: Handle the error + case "response": { + broker.onReceivingResponse(message); break; } default: @@ -551,60 +674,18 @@ class SwiftRuntime { if (!(threadChannel && "listenMessageFromWorkerThread" in threadChannel)) { throw new Error("listenMessageFromWorkerThread is not set in options given to SwiftRuntime. Please set it to listen to jobs from worker threads."); } + const broker = getMessageBroker(threadChannel); threadChannel.listenMessageFromWorkerThread(tid, (message) => { switch (message.type) { case "job": this.exports.swjs_enqueue_main_job_from_worker(message.data); break; - case "requestTransfer": { - if (message.data.objectSourceTid == MAIN_THREAD_TID) { - const object = this.memory.getObject(message.data.objectRef); - if (message.data.destinationTid != tid) { - throw new Error("Invariant violation: The destination tid of the transfer request must be the same as the tid of the worker thread that received the request."); - } - this.postMessageToWorkerThread(message.data.destinationTid, { - type: "transfer", - data: { - object, - transferring: message.data.transferring, - destinationTid: message.data.destinationTid, - }, - }, [object]); - } - else { - // Proxy the transfer request to the worker thread that owns the object - this.postMessageToWorkerThread(message.data.objectSourceTid, { - type: "requestTransfer", - data: { - objectRef: message.data.objectRef, - objectSourceTid: tid, - transferring: message.data.transferring, - destinationTid: message.data.destinationTid, - }, - }); - } - break; - } - case "transfer": { - if (message.data.destinationTid == MAIN_THREAD_TID) { - const objectRef = this.memory.retain(message.data.object); - this.exports.swjs_receive_object(objectRef, message.data.transferring); - } - else { - // Proxy the transfer response to the destination worker thread - this.postMessageToWorkerThread(message.data.destinationTid, { - type: "transfer", - data: { - object: message.data.object, - transferring: message.data.transferring, - destinationTid: message.data.destinationTid, - }, - }, [message.data.object]); - } + case "request": { + broker.onReceivingRequest(message); break; } - case "transferError": { - console.error(message.data.error); // TODO: Handle the error + case "response": { + broker.onReceivingResponse(message); break; } default: @@ -626,19 +707,21 @@ class SwiftRuntime { }, swjs_request_transferring_object: (object_ref, object_source_tid, transferring) => { var _a; - if (this.tid == object_source_tid) { - // Fast path: The object is already in the same thread - this.exports.swjs_receive_object(object_ref, transferring); - return; + if (!this.options.threadChannel) { + throw new Error("threadChannel is not set in options given to SwiftRuntime. Please set it to request transferring objects."); } - this.postMessageToMainThread({ - type: "requestTransfer", + const broker = getMessageBroker(this.options.threadChannel); + broker.request({ + type: "request", data: { - objectRef: object_ref, - objectSourceTid: object_source_tid, - transferring, - destinationTid: (_a = this.tid) !== null && _a !== void 0 ? _a : MAIN_THREAD_TID, - }, + sourceTid: (_a = this.tid) !== null && _a !== void 0 ? _a : MAIN_THREAD_TID, + targetTid: object_source_tid, + context: transferring, + request: { + method: "transfer", + parameters: [object_ref, transferring], + } + } }); }, }; diff --git a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift index 4892df591..c6cb2be36 100644 --- a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift +++ b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift @@ -283,14 +283,19 @@ final class WebWorkerTaskExecutorTests: XCTestCase { let object = JSObject.global.Object.function!.new() let transferring = JSTransferring(object) let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) - let task = Task(executorPreference: executor) { - _ = try await transferring.receive() - return + let task = Task(executorPreference: executor) { + do { + _ = try await transferring.receive() + return nil + } catch let error as JSException { + return error.thrownValue.description + } } - do { - try await task.value + guard let jsErrorMessage = try await task.value else { XCTFail("Should throw an error") - } catch {} + return + } + XCTAssertTrue(jsErrorMessage.contains("Failed to serialize response message")) // Deinit the transferring object on the thread that was created withExtendedLifetime(transferring) {} } From 58f91c35c6eecc5750c061972ac439dfd8dcbd49 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Tue, 11 Mar 2025 05:53:01 +0000 Subject: [PATCH 13/19] Relax deinit requirement --- Runtime/src/index.ts | 20 ++++++++++ Runtime/src/itc.ts | 5 +++ Runtime/src/types.ts | 1 + .../JSObject+Transferring.swift | 14 +++---- .../FundamentalObjects/JSObject.swift | 8 +++- Sources/JavaScriptKit/Runtime/index.js | 24 ++++++++++++ Sources/JavaScriptKit/Runtime/index.mjs | 24 ++++++++++++ .../_CJavaScriptKit/include/_CJavaScriptKit.h | 6 +++ .../WebWorkerTaskExecutorTests.swift | 37 ++++++++++++++++--- 9 files changed, 124 insertions(+), 15 deletions(-) diff --git a/Runtime/src/index.ts b/Runtime/src/index.ts index 5cb1acfc2..67f478321 100644 --- a/Runtime/src/index.ts +++ b/Runtime/src/index.ts @@ -189,6 +189,7 @@ export class SwiftRuntime { 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 }; } catch (error) { @@ -526,6 +527,25 @@ export class SwiftRuntime { this.memory.release(ref); }, + swjs_release_remote: (tid: number, ref: ref) => { + if (!this.options.threadChannel) { + throw new Error("threadChannel is not set in options given to SwiftRuntime. Please set it to release objects on remote threads."); + } + const broker = getMessageBroker(this.options.threadChannel); + broker.request({ + type: "request", + data: { + sourceTid: this.tid ?? MAIN_THREAD_TID, + targetTid: tid, + context: 0, + request: { + method: "release", + parameters: [ref], + } + } + }) + }, + swjs_i64_to_bigint: (value: bigint, signed: number) => { return this.memory.retain( signed ? value : BigInt.asUintN(64, value) diff --git a/Runtime/src/itc.ts b/Runtime/src/itc.ts index 44b37c7be..f7e951787 100644 --- a/Runtime/src/itc.ts +++ b/Runtime/src/itc.ts @@ -89,6 +89,11 @@ export class ITCInterface { const object = this.memory.getObject(objectRef); return { object, transferring, transfer: [object] }; } + + release(objectRef: ref): { object: undefined, transfer: Transferable[] } { + this.memory.release(objectRef); + return { object: undefined, transfer: [] }; + } } type AllRequests> = { diff --git a/Runtime/src/types.ts b/Runtime/src/types.ts index a83a74f0c..6cfc05d38 100644 --- a/Runtime/src/types.ts +++ b/Runtime/src/types.ts @@ -104,6 +104,7 @@ export interface ImportedFunctions { ): number; swjs_load_typed_array(ref: ref, buffer: pointer): void; swjs_release(ref: number): void; + swjs_release_remote(tid: number, ref: number): void; swjs_i64_to_bigint(value: bigint, signed: bool): ref; swjs_bigint_to_i64(ref: ref, signed: bool): bigint; swjs_i64_to_bigint_slow(lower: number, upper: number, signed: bool): ref; diff --git a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift index 6deee6598..024d4250f 100644 --- a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift +++ b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift @@ -56,7 +56,7 @@ public struct JSTransferring: @unchecked Sendable { /// /// ```swift /// let canvas = JSObject.global.document.createElement("canvas").object! - /// let transferring = JSObject.transfer(canvas.transferControlToOffscreen().object!) + /// let transferring = JSTransferring(canvas.transferControlToOffscreen().object!) /// let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) /// Task(executorPreference: executor) { /// let canvas = try await transferring.receive() @@ -65,12 +65,6 @@ public struct JSTransferring: @unchecked Sendable { @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) public func receive(isolation: isolated (any Actor)? = #isolation, file: StaticString = #file, line: UInt = #line) async throws -> T { #if compiler(>=6.1) && _runtime(_multithreaded) - // The following sequence of events happens when a `JSObject` is transferred from - // the owner thread to the receiver thread: - // - // [Owner Thread] [Receiver Thread] - // <-----requestTransfer------ swjs_request_transferring_object - // ---------transfer---------> swjs_receive_object let idInDestination = try await withCheckedThrowingContinuation { continuation in self.storage.context.withLock { context in guard context.continuation == nil else { @@ -148,8 +142,9 @@ extension JSTransferring where T == JSObject { @_cdecl("swjs_receive_response") #endif @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) -func _swjs_receive_response(_ object: JavaScriptObjectRef, _ transferring: UnsafeRawPointer) { +func _swjs_receive_response(_ object: JavaScriptObjectRef, _ transferring: UnsafeRawPointer?) { #if compiler(>=6.1) && _runtime(_multithreaded) + guard let transferring = transferring else { return } let context = Unmanaged<_JSTransferringContext>.fromOpaque(transferring).takeRetainedValue() context.withLock { state in assert(state.continuation != nil, "JSObject.Transferring object is not yet received!?") @@ -169,8 +164,9 @@ func _swjs_receive_response(_ object: JavaScriptObjectRef, _ transferring: Unsaf @_cdecl("swjs_receive_error") #endif @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) -func _swjs_receive_error(_ error: JavaScriptObjectRef, _ transferring: UnsafeRawPointer) { +func _swjs_receive_error(_ error: JavaScriptObjectRef, _ transferring: UnsafeRawPointer?) { #if compiler(>=6.1) && _runtime(_multithreaded) + guard let transferring = transferring else { return } let context = Unmanaged<_JSTransferringContext>.fromOpaque(transferring).takeRetainedValue() context.withLock { state in assert(state.continuation != nil, "JSObject.Transferring object is not yet received!?") diff --git a/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift b/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift index 18c683682..0958b33f4 100644 --- a/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift +++ b/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift @@ -203,7 +203,13 @@ public class JSObject: Equatable { }) deinit { - assertOnOwnerThread(hint: "deinitializing") + #if compiler(>=6.1) && _runtime(_multithreaded) + if ownerTid != swjs_get_worker_thread_id_cached() { + // If the object is not owned by the current thread + swjs_release_remote(ownerTid, id) + return + } + #endif swjs_release(id) } diff --git a/Sources/JavaScriptKit/Runtime/index.js b/Sources/JavaScriptKit/Runtime/index.js index 206251a11..ede43514c 100644 --- a/Sources/JavaScriptKit/Runtime/index.js +++ b/Sources/JavaScriptKit/Runtime/index.js @@ -204,6 +204,10 @@ const object = this.memory.getObject(objectRef); return { object, transferring, transfer: [object] }; } + release(objectRef) { + this.memory.release(objectRef); + return { object: undefined, transfer: [] }; + } } class MessageBroker { constructor(selfTid, threadChannel, handlers) { @@ -417,6 +421,7 @@ onRequest: (message) => { let returnValue; try { + // @ts-ignore const result = itcInterface[message.data.request.method](...message.data.request.parameters); returnValue = { ok: true, value: result }; } @@ -618,6 +623,25 @@ swjs_release: (ref) => { this.memory.release(ref); }, + swjs_release_remote: (tid, ref) => { + var _a; + if (!this.options.threadChannel) { + throw new Error("threadChannel is not set in options given to SwiftRuntime. Please set it to release objects on remote threads."); + } + const broker = getMessageBroker(this.options.threadChannel); + broker.request({ + type: "request", + data: { + sourceTid: (_a = this.tid) !== null && _a !== void 0 ? _a : MAIN_THREAD_TID, + targetTid: tid, + context: 0, + request: { + method: "release", + parameters: [ref], + } + } + }); + }, swjs_i64_to_bigint: (value, signed) => { return this.memory.retain(signed ? value : BigInt.asUintN(64, value)); }, diff --git a/Sources/JavaScriptKit/Runtime/index.mjs b/Sources/JavaScriptKit/Runtime/index.mjs index 62d9558ee..f95aee940 100644 --- a/Sources/JavaScriptKit/Runtime/index.mjs +++ b/Sources/JavaScriptKit/Runtime/index.mjs @@ -198,6 +198,10 @@ class ITCInterface { const object = this.memory.getObject(objectRef); return { object, transferring, transfer: [object] }; } + release(objectRef) { + this.memory.release(objectRef); + return { object: undefined, transfer: [] }; + } } class MessageBroker { constructor(selfTid, threadChannel, handlers) { @@ -411,6 +415,7 @@ class SwiftRuntime { onRequest: (message) => { let returnValue; try { + // @ts-ignore const result = itcInterface[message.data.request.method](...message.data.request.parameters); returnValue = { ok: true, value: result }; } @@ -612,6 +617,25 @@ class SwiftRuntime { swjs_release: (ref) => { this.memory.release(ref); }, + swjs_release_remote: (tid, ref) => { + var _a; + if (!this.options.threadChannel) { + throw new Error("threadChannel is not set in options given to SwiftRuntime. Please set it to release objects on remote threads."); + } + const broker = getMessageBroker(this.options.threadChannel); + broker.request({ + type: "request", + data: { + sourceTid: (_a = this.tid) !== null && _a !== void 0 ? _a : MAIN_THREAD_TID, + targetTid: tid, + context: 0, + request: { + method: "release", + parameters: [ref], + } + } + }); + }, swjs_i64_to_bigint: (value, signed) => { return this.memory.retain(signed ? value : BigInt.asUintN(64, value)); }, diff --git a/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h b/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h index 575c0e6fd..12e07048a 100644 --- a/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h +++ b/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h @@ -290,6 +290,12 @@ IMPORT_JS_FUNCTION(swjs_load_typed_array, void, (const JavaScriptObjectRef ref, /// @param ref The target JavaScript object. IMPORT_JS_FUNCTION(swjs_release, void, (const JavaScriptObjectRef ref)) +/// Decrements reference count of `ref` retained by `SwiftRuntimeHeap` in `object_tid` thread. +/// +/// @param object_tid The TID of the thread that owns the target object. +/// @param ref The target JavaScript object. +IMPORT_JS_FUNCTION(swjs_release_remote, void, (int object_tid, const JavaScriptObjectRef ref)) + /// Yields current program control by throwing `UnsafeEventLoopYield` JavaScript exception. /// See note on `UnsafeEventLoopYield` for more details /// diff --git a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift index c6cb2be36..8ed179f2a 100644 --- a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift +++ b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift @@ -264,7 +264,7 @@ final class WebWorkerTaskExecutorTests: XCTestCase { executor.terminate() } - func testTransfer() async throws { + func testTransferMainToWorker() async throws { let Uint8Array = JSObject.global.Uint8Array.function! let buffer = Uint8Array.new(100).buffer.object! let transferring = JSTransferring(buffer) @@ -275,8 +275,19 @@ final class WebWorkerTaskExecutorTests: XCTestCase { } let byteLength = try await task.value XCTAssertEqual(byteLength, 100) - // Deinit the transferring object on the thread that was created - withExtendedLifetime(transferring) {} + } + + func testTransferWorkerToMain() async throws { + let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + let task = Task(executorPreference: executor) { + let Uint8Array = JSObject.global.Uint8Array.function! + let buffer = Uint8Array.new(100).buffer.object! + let transferring = JSTransferring(buffer) + return transferring + } + let transferring = await task.value + let buffer = try await transferring.receive() + XCTAssertEqual(buffer.byteLength.number!, 100) } func testTransferNonTransferable() async throws { @@ -296,8 +307,24 @@ final class WebWorkerTaskExecutorTests: XCTestCase { return } XCTAssertTrue(jsErrorMessage.contains("Failed to serialize response message")) - // Deinit the transferring object on the thread that was created - withExtendedLifetime(transferring) {} + } + + func testTransferBetweenWorkers() async throws { + let executor1 = try await WebWorkerTaskExecutor(numberOfThreads: 1) + let executor2 = try await WebWorkerTaskExecutor(numberOfThreads: 1) + let task = Task(executorPreference: executor1) { + let Uint8Array = JSObject.global.Uint8Array.function! + let buffer = Uint8Array.new(100).buffer.object! + let transferring = JSTransferring(buffer) + return transferring + } + let transferring = await task.value + let task2 = Task(executorPreference: executor2) { + let buffer = try await transferring.receive() + return buffer.byteLength.number! + } + let byteLength = try await task2.value + XCTAssertEqual(byteLength, 100) } /* From 2a081de36a2b58718e092e3205f1ebb2f0c3b649 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Tue, 11 Mar 2025 05:56:41 +0000 Subject: [PATCH 14/19] Remove dead code and fix error message --- Runtime/src/js-value.ts | 76 ------------------- .../JSObject+Transferring.swift | 6 +- 2 files changed, 3 insertions(+), 79 deletions(-) diff --git a/Runtime/src/js-value.ts b/Runtime/src/js-value.ts index 29e4a42a4..1b142de05 100644 --- a/Runtime/src/js-value.ts +++ b/Runtime/src/js-value.ts @@ -92,82 +92,6 @@ export const write = ( memory.writeUint32(kind_ptr, kind); }; -export function decompose(value: any, memory: Memory): { - kind: JavaScriptValueKindAndFlags; - payload1: number; - payload2: number; -} { - if (value === null) { - return { - kind: Kind.Null, - payload1: 0, - payload2: 0, - } - } - const type = typeof value; - switch (type) { - case "boolean": { - return { - kind: Kind.Boolean, - payload1: value ? 1 : 0, - payload2: 0, - } - } - case "number": { - return { - kind: Kind.Number, - payload1: 0, - payload2: value, - } - } - case "string": { - return { - kind: Kind.String, - payload1: memory.retain(value), - payload2: 0, - } - } - case "undefined": { - return { - kind: Kind.Undefined, - payload1: 0, - payload2: 0, - } - } - case "object": { - return { - kind: Kind.Object, - payload1: memory.retain(value), - payload2: 0, - } - } - case "function": { - return { - kind: Kind.Function, - payload1: memory.retain(value), - payload2: 0, - } - } - case "symbol": { - return { - kind: Kind.Symbol, - payload1: memory.retain(value), - payload2: 0, - } - } - case "bigint": { - return { - kind: Kind.BigInt, - payload1: memory.retain(value), - payload2: 0, - } - } - default: - assertNever(type, `Type "${type}" is not supported yet`); - } - throw new Error("unreachable"); -} - export const writeAndReturnKindBits = ( value: any, payload1_ptr: pointer, diff --git a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift index 024d4250f..68e8c013c 100644 --- a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift +++ b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift @@ -69,7 +69,7 @@ public struct JSTransferring: @unchecked Sendable { self.storage.context.withLock { context in guard context.continuation == nil else { // This is a programming error, `receive` should be called only once. - fatalError("JSObject.Transferring object is already received", file: file, line: line) + fatalError("JSTransferring object is already received", file: file, line: line) } // The continuation will be resumed by `swjs_receive_object`. context.continuation = continuation @@ -147,7 +147,7 @@ func _swjs_receive_response(_ object: JavaScriptObjectRef, _ transferring: Unsaf guard let transferring = transferring else { return } let context = Unmanaged<_JSTransferringContext>.fromOpaque(transferring).takeRetainedValue() context.withLock { state in - assert(state.continuation != nil, "JSObject.Transferring object is not yet received!?") + assert(state.continuation != nil, "JSTransferring object is not yet received!?") state.continuation?.resume(returning: object) } #endif @@ -169,7 +169,7 @@ func _swjs_receive_error(_ error: JavaScriptObjectRef, _ transferring: UnsafeRaw guard let transferring = transferring else { return } let context = Unmanaged<_JSTransferringContext>.fromOpaque(transferring).takeRetainedValue() context.withLock { state in - assert(state.continuation != nil, "JSObject.Transferring object is not yet received!?") + assert(state.continuation != nil, "JSTransferring object is not yet received!?") state.continuation?.resume(throwing: JSException(JSObject(id: error).jsValue)) } #endif From 4fe37e7ae8b19d0242a01945e3e2be274ec8be6c Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Tue, 11 Mar 2025 06:23:09 +0000 Subject: [PATCH 15/19] Rename JSTransferring to JSSending --- .../JSObject+Transferring.swift | 14 +++++++------- .../WebWorkerTaskExecutorTests.swift | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift index 68e8c013c..b5c3a14bf 100644 --- a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift +++ b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift @@ -5,11 +5,11 @@ import _CJavaScriptKit import Synchronization #endif -/// A temporary object intended to transfer an object from one thread to another. +/// A temporary object intended to send an object from one thread to another. /// -/// ``JSTransferring`` is `Sendable` and it's intended to be shared across threads. +/// ``JSSending`` is `Sendable` and it's intended to be shared across threads. @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) -public struct JSTransferring: @unchecked Sendable { +public struct JSSending: @unchecked Sendable { fileprivate struct Storage { /// The original object that is transferred. /// @@ -101,9 +101,9 @@ fileprivate final class _JSTransferringContext: Sendable { } -extension JSTransferring where T == JSObject { +extension JSSending where T == JSObject { - /// Transfers the ownership of a `JSObject` to be sent to another thread. + /// Sends a `JSObject` to another thread. /// /// - Precondition: The thread calling this method should have the ownership of the `JSObject`. /// - Postcondition: The original `JSObject` is no longer owned by the thread, further access to it @@ -112,8 +112,8 @@ extension JSTransferring where T == JSObject { /// - Parameter object: The ``JSObject`` to be transferred. /// - Returns: A ``Transferring`` instance that can be shared across threads. @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) - public init(_ object: JSObject) { - self.init( + public static func transfer(_ object: JSObject) -> JSSending { + JSSending( sourceObject: object, construct: { JSObject(id: $0) }, deconstruct: { $0.id }, diff --git a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift index 8ed179f2a..1dd0f1dd1 100644 --- a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift +++ b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift @@ -267,7 +267,7 @@ final class WebWorkerTaskExecutorTests: XCTestCase { func testTransferMainToWorker() async throws { let Uint8Array = JSObject.global.Uint8Array.function! let buffer = Uint8Array.new(100).buffer.object! - let transferring = JSTransferring(buffer) + let transferring = JSSending.transfer(buffer) let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) let task = Task(executorPreference: executor) { let buffer = try await transferring.receive() @@ -282,7 +282,7 @@ final class WebWorkerTaskExecutorTests: XCTestCase { let task = Task(executorPreference: executor) { let Uint8Array = JSObject.global.Uint8Array.function! let buffer = Uint8Array.new(100).buffer.object! - let transferring = JSTransferring(buffer) + let transferring = JSSending.transfer(buffer) return transferring } let transferring = await task.value @@ -292,7 +292,7 @@ final class WebWorkerTaskExecutorTests: XCTestCase { func testTransferNonTransferable() async throws { let object = JSObject.global.Object.function!.new() - let transferring = JSTransferring(object) + let transferring = JSSending.transfer(object) let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) let task = Task(executorPreference: executor) { do { @@ -315,7 +315,7 @@ final class WebWorkerTaskExecutorTests: XCTestCase { let task = Task(executorPreference: executor1) { let Uint8Array = JSObject.global.Uint8Array.function! let buffer = Uint8Array.new(100).buffer.object! - let transferring = JSTransferring(buffer) + let transferring = JSSending.transfer(buffer) return transferring } let transferring = await task.value From eeff111bc7f1eceee8a1be8627d48fed6d5620e7 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Tue, 11 Mar 2025 07:47:22 +0000 Subject: [PATCH 16/19] Add `JSSending.receive(...)` to receive multiple objects at once --- .../OffscrenCanvas/Sources/MyApp/main.swift | 2 +- Runtime/src/index.ts | 47 ++- Runtime/src/itc.ts | 13 +- Runtime/src/js-value.ts | 10 +- Runtime/src/types.ts | 16 +- .../JSObject+Transferring.swift | 364 +++++++++++++----- .../WebWorkerTaskExecutor.swift | 153 +++++++- Sources/JavaScriptKit/Runtime/index.js | 53 ++- Sources/JavaScriptKit/Runtime/index.mjs | 53 ++- .../_CJavaScriptKit/include/_CJavaScriptKit.h | 17 +- .../WebWorkerTaskExecutorTests.swift | 103 ++++- 11 files changed, 688 insertions(+), 143 deletions(-) diff --git a/Examples/OffscrenCanvas/Sources/MyApp/main.swift b/Examples/OffscrenCanvas/Sources/MyApp/main.swift index b6e5b6df9..67e087122 100644 --- a/Examples/OffscrenCanvas/Sources/MyApp/main.swift +++ b/Examples/OffscrenCanvas/Sources/MyApp/main.swift @@ -11,7 +11,7 @@ protocol CanvasRenderer { struct BackgroundRenderer: CanvasRenderer { func render(canvas: JSObject, size: Int) async throws { let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) - let transfer = JSTransferring(canvas) + let transfer = JSSending.transfer(canvas) let renderingTask = Task(executorPreference: executor) { let canvas = try await transfer.receive() try await renderAnimation(canvas: canvas, size: size) diff --git a/Runtime/src/index.ts b/Runtime/src/index.ts index 67f478321..3f23ed753 100644 --- a/Runtime/src/index.ts +++ b/Runtime/src/index.ts @@ -11,6 +11,7 @@ import { import * as JSValue from "./js-value.js"; import { Memory } from "./memory.js"; import { deserializeError, MainToWorkerMessage, MessageBroker, ResponseMessage, ITCInterface, serializeError, SwiftRuntimeThreadChannel, WorkerToMainMessage } from "./itc.js"; +import { decodeObjectRefs } from "./js-value.js"; export type SwiftRuntimeOptions = { /** @@ -208,7 +209,7 @@ export class SwiftRuntime { } catch (error) { responseMessage.data.response = { ok: false, - error: serializeError(new TypeError(`Failed to serialize response message: ${error}`)) + error: serializeError(new TypeError(`Failed to serialize message: ${error}`)) }; newBroker.reply(responseMessage); } @@ -648,24 +649,56 @@ export class SwiftRuntime { // Main thread's tid is always -1 return this.tid || -1; }, - swjs_request_transferring_object: ( - object_ref: ref, + swjs_request_sending_object: ( + sending_object: ref, + transferring_objects: pointer, + transferring_objects_count: number, object_source_tid: number, - transferring: pointer, + sending_context: pointer, ) => { if (!this.options.threadChannel) { throw new Error("threadChannel is not set in options given to SwiftRuntime. Please set it to request transferring objects."); } const broker = getMessageBroker(this.options.threadChannel); + const memory = this.memory; + const transferringObjects = decodeObjectRefs(transferring_objects, transferring_objects_count, memory); + broker.request({ + type: "request", + data: { + sourceTid: this.tid ?? MAIN_THREAD_TID, + targetTid: object_source_tid, + context: sending_context, + request: { + method: "send", + parameters: [sending_object, transferringObjects, sending_context], + } + } + }) + }, + swjs_request_sending_objects: ( + sending_objects: pointer, + sending_objects_count: number, + transferring_objects: pointer, + transferring_objects_count: number, + object_source_tid: number, + sending_context: pointer, + ) => { + if (!this.options.threadChannel) { + throw new Error("threadChannel is not set in options given to SwiftRuntime. Please set it to request transferring objects."); + } + const broker = getMessageBroker(this.options.threadChannel); + const memory = this.memory; + const sendingObjects = decodeObjectRefs(sending_objects, sending_objects_count, memory); + const transferringObjects = decodeObjectRefs(transferring_objects, transferring_objects_count, memory); broker.request({ type: "request", data: { sourceTid: this.tid ?? MAIN_THREAD_TID, targetTid: object_source_tid, - context: transferring, + context: sending_context, request: { - method: "transfer", - parameters: [object_ref, transferring], + method: "sendObjects", + parameters: [sendingObjects, transferringObjects, sending_context], } } }) diff --git a/Runtime/src/itc.ts b/Runtime/src/itc.ts index f7e951787..e2c93622a 100644 --- a/Runtime/src/itc.ts +++ b/Runtime/src/itc.ts @@ -85,9 +85,16 @@ export type SwiftRuntimeThreadChannel = export class ITCInterface { constructor(private memory: Memory) {} - transfer(objectRef: ref, transferring: pointer): { object: any, transferring: pointer, transfer: Transferable[] } { - const object = this.memory.getObject(objectRef); - return { object, transferring, transfer: [object] }; + send(sendingObject: ref, transferringObjects: ref[], sendingContext: pointer): { object: any, sendingContext: pointer, transfer: Transferable[] } { + const object = this.memory.getObject(sendingObject); + const transfer = transferringObjects.map(ref => this.memory.getObject(ref)); + return { object, sendingContext, transfer }; + } + + sendObjects(sendingObjects: ref[], transferringObjects: ref[], sendingContext: pointer): { object: any[], sendingContext: pointer, transfer: Transferable[] } { + const objects = sendingObjects.map(ref => this.memory.getObject(ref)); + const transfer = transferringObjects.map(ref => this.memory.getObject(ref)); + return { object: objects, sendingContext, transfer }; } release(objectRef: ref): { object: undefined, transfer: Transferable[] } { diff --git a/Runtime/src/js-value.ts b/Runtime/src/js-value.ts index 1b142de05..dcc378f61 100644 --- a/Runtime/src/js-value.ts +++ b/Runtime/src/js-value.ts @@ -1,5 +1,5 @@ import { Memory } from "./memory.js"; -import { assertNever, JavaScriptValueKindAndFlags, pointer } from "./types.js"; +import { assertNever, JavaScriptValueKindAndFlags, pointer, ref } from "./types.js"; export const enum Kind { Boolean = 0, @@ -142,3 +142,11 @@ export const writeAndReturnKindBits = ( } throw new Error("Unreachable"); }; + +export function decodeObjectRefs(ptr: pointer, length: number, memory: Memory): ref[] { + const result: ref[] = new Array(length); + for (let i = 0; i < length; i++) { + result[i] = memory.readUint32(ptr + 4 * i); + } + return result; +} diff --git a/Runtime/src/types.ts b/Runtime/src/types.ts index 6cfc05d38..587b60770 100644 --- a/Runtime/src/types.ts +++ b/Runtime/src/types.ts @@ -115,10 +115,20 @@ export interface ImportedFunctions { swjs_listen_message_from_worker_thread: (tid: number) => void; swjs_terminate_worker_thread: (tid: number) => void; swjs_get_worker_thread_id: () => number; - swjs_request_transferring_object: ( - object_ref: ref, + swjs_request_sending_object: ( + sending_object: ref, + transferring_objects: pointer, + transferring_objects_count: number, object_source_tid: number, - transferring: pointer, + sending_context: pointer, + ) => void; + swjs_request_sending_objects: ( + sending_objects: pointer, + sending_objects_count: number, + transferring_objects: pointer, + transferring_objects_count: number, + object_source_tid: number, + sending_context: pointer, ) => void; } diff --git a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift index b5c3a14bf..c573939e9 100644 --- a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift +++ b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift @@ -5,131 +5,327 @@ import _CJavaScriptKit import Synchronization #endif -/// A temporary object intended to send an object from one thread to another. +/// A temporary object intended to send a JavaScript object from one thread to another. /// -/// ``JSSending`` is `Sendable` and it's intended to be shared across threads. +/// `JSSending` provides a way to safely transfer or clone JavaScript objects between threads +/// in a multi-threaded WebAssembly environment. +/// +/// There are two primary ways to use `JSSending`: +/// 1. Transfer an object (`JSSending.transfer`) - The original object becomes unusable +/// 2. Clone an object (`JSSending.init`) - Creates a copy, original remains usable +/// +/// To receive a sent object on the destination thread, call the `receive()` method. +/// +/// - Note: `JSSending` is `Sendable` and can be safely shared across thread boundaries. +/// +/// ## Example +/// +/// ```swift +/// // Transfer an object to another thread +/// let buffer = JSObject.global.Uint8Array.function!.new(100).buffer.object! +/// let transferring = JSSending.transfer(buffer) +/// +/// // Receive the object on a worker thread +/// let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) +/// Task(executorPreference: executor) { +/// let receivedBuffer = try await transferring.receive() +/// // Use the received buffer +/// } +/// +/// // Clone an object for use in another thread +/// let object = JSObject.global.Object.function!.new() +/// object["test"] = "Hello, World!" +/// let cloning = JSSending(object) +/// +/// Task(executorPreference: executor) { +/// let receivedObject = try await cloning.receive() +/// // Use the received object +/// } +/// ``` @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) public struct JSSending: @unchecked Sendable { fileprivate struct Storage { - /// The original object that is transferred. + /// The original object that is sent. /// - /// Retain it here to prevent it from being released before the transfer is complete. - let sourceObject: T + /// Retain it here to prevent it from being released before the sending is complete. + let sourceObject: JSObject /// A function that constructs an object from a JavaScript object reference. - let construct: (_ id: JavaScriptObjectRef) -> T + let construct: (_ object: JSObject) -> T /// The JavaScript object reference of the original object. let idInSource: JavaScriptObjectRef /// The TID of the thread that owns the original object. let sourceTid: Int32 - - #if compiler(>=6.1) && _runtime(_multithreaded) - /// A shared context for transferring objects across threads. - let context: _JSTransferringContext = _JSTransferringContext() - #endif + /// Whether the object should be "transferred" or "cloned". + let transferring: Bool } private let storage: Storage fileprivate init( sourceObject: T, - construct: @escaping (_ id: JavaScriptObjectRef) -> T, - deconstruct: @escaping (_ object: T) -> JavaScriptObjectRef, - getSourceTid: @escaping (_ object: T) -> Int32 + construct: @escaping (_ object: JSObject) -> T, + deconstruct: @escaping (_ object: T) -> JSObject, + getSourceTid: @escaping (_ object: T) -> Int32, + transferring: Bool ) { + let object = deconstruct(sourceObject) self.storage = Storage( - sourceObject: sourceObject, + sourceObject: object, construct: construct, - idInSource: deconstruct(sourceObject), - sourceTid: getSourceTid(sourceObject) + idInSource: object.id, + sourceTid: getSourceTid(sourceObject), + transferring: transferring + ) + } +} + +extension JSSending where T == JSObject { + private init(_ object: JSObject, transferring: Bool) { + self.init( + sourceObject: object, + construct: { $0 }, + deconstruct: { $0 }, + getSourceTid: { + #if compiler(>=6.1) && _runtime(_multithreaded) + return $0.ownerTid + #else + _ = $0 + // On single-threaded runtime, source and destination threads are always the main thread (TID = -1). + return -1 + #endif + }, + transferring: transferring ) } - /// Receives a transferred ``JSObject`` from a thread. + /// Transfers a `JSObject` to another thread. + /// + /// The original `JSObject` is ["transferred"](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects) + /// to the receiving thread, which means its ownership is completely moved. After transferring, + /// the original object becomes neutered (unusable) in the source thread. /// - /// The original ``JSObject`` is ["Transferred"](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects) - /// to the receiving thread. + /// This is more efficient than cloning for large objects like `ArrayBuffer` because no copying + /// is involved, but the original object can no longer be accessed. /// - /// Note that this method should be called only once for each ``Transferring`` instance - /// on the receiving thread. + /// Only objects that implement the JavaScript [Transferable](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects) + /// interface can be transferred. Common transferable objects include: + /// - `ArrayBuffer` + /// - `MessagePort` + /// - `ImageBitmap` + /// - `OffscreenCanvas` /// - /// ### Example + /// ## Example + /// + /// ```swift + /// let buffer = JSObject.global.Uint8Array.function!.new(100).buffer.object! + /// let transferring = JSSending.transfer(buffer) + /// + /// // After transfer, the original buffer is neutered + /// // buffer.byteLength.number! will be 0 + /// ``` + /// + /// - Precondition: The thread calling this method should have the ownership of the `JSObject`. + /// - Postcondition: The original `JSObject` is no longer owned by the thread, further access to it + /// on the thread that called this method is invalid and will result in undefined behavior. + /// + /// - Parameter object: The `JSObject` to be transferred. + /// - Returns: A `JSSending` instance that can be shared across threads. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public static func transfer(_ object: JSObject) -> JSSending { + JSSending(object, transferring: true) + } + + /// Clones a `JSObject` to another thread. + /// + /// Creates a copy of the object that can be sent to another thread. The original object + /// remains usable in the source thread. This is safer than transferring when you need + /// to continue using the original object, but has higher memory overhead since it creates + /// a complete copy. + /// + /// Most JavaScript objects can be cloned, but some complex objects including closures may + /// not be clonable. + /// + /// ## Example + /// + /// ```swift + /// let object = JSObject.global.Object.function!.new() + /// object["test"] = "Hello, World!" + /// let cloning = JSSending(object) + /// + /// // Original object is still valid and usable + /// // object["test"].string! is still "Hello, World!" + /// ``` + /// + /// - Precondition: The thread calling this method should have the ownership of the `JSObject`. + /// - Parameter object: The `JSObject` to be cloned. + /// - Returns: A `JSSending` instance that can be shared across threads. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + public init(_ object: JSObject) { + self.init(object, transferring: false) + } +} + +extension JSSending { + + /// Receives a sent `JSObject` from a thread. + /// + /// This method completes the transfer or clone operation, making the object available + /// in the receiving thread. It must be called on the destination thread where you want + /// to use the object. + /// + /// - Important: This method should be called only once for each `JSSending` instance. + /// Attempting to receive the same object multiple times will result in an error. + /// + /// ## Example - Transferring /// /// ```swift /// let canvas = JSObject.global.document.createElement("canvas").object! - /// let transferring = JSTransferring(canvas.transferControlToOffscreen().object!) + /// let transferring = JSSending.transfer(canvas.transferControlToOffscreen().object!) + /// /// let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) /// Task(executorPreference: executor) { /// let canvas = try await transferring.receive() + /// // Use the canvas in the worker thread /// } /// ``` + /// + /// ## Example - Cloning + /// + /// ```swift + /// let data = JSObject.global.Object.function!.new() + /// data["value"] = 42 + /// let cloning = JSSending(data) + /// + /// let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + /// Task(executorPreference: executor) { + /// let data = try await cloning.receive() + /// print(data["value"].number!) // 42 + /// } + /// ``` + /// + /// - Parameter isolation: The actor isolation context for this call, used in Swift concurrency. + /// - Returns: The received object of type `T`. + /// - Throws: `JSSendingError` if the sending operation fails, or `JSException` if a JavaScript error occurs. @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) public func receive(isolation: isolated (any Actor)? = #isolation, file: StaticString = #file, line: UInt = #line) async throws -> T { #if compiler(>=6.1) && _runtime(_multithreaded) let idInDestination = try await withCheckedThrowingContinuation { continuation in - self.storage.context.withLock { context in - guard context.continuation == nil else { - // This is a programming error, `receive` should be called only once. - fatalError("JSTransferring object is already received", file: file, line: line) - } - // The continuation will be resumed by `swjs_receive_object`. - context.continuation = continuation - } - swjs_request_transferring_object( - self.storage.idInSource, + let context = _JSSendingContext(continuation: continuation) + let idInSource = self.storage.idInSource + let transferring = self.storage.transferring ? [idInSource] : [] + swjs_request_sending_object( + idInSource, + transferring, + Int32(transferring.count), self.storage.sourceTid, - Unmanaged.passRetained(self.storage.context).toOpaque() + Unmanaged.passRetained(context).toOpaque() ) } - return storage.construct(idInDestination) + return storage.construct(JSObject(id: idInDestination)) #else - return storage.construct(storage.idInSource) + return storage.construct(storage.sourceObject) #endif } -} -fileprivate final class _JSTransferringContext: Sendable { - struct State { - var continuation: CheckedContinuation? - } - private let state: Mutex = .init(State()) - - func withLock(_ body: (inout State) -> R) -> R { - return state.withLock { state in - body(&state) + /// Receives multiple `JSSending` instances from a thread in a single operation. + /// + /// This method is more efficient than receiving multiple objects individually, as it + /// batches the receive operations. It's especially useful when transferring or cloning + /// multiple related objects that need to be received together. + /// + /// - Important: All objects being received must come from the same source thread. + /// + /// ## Example + /// + /// ```swift + /// // Create and transfer multiple objects + /// let buffer1 = Uint8Array.new(10).buffer.object! + /// let buffer2 = Uint8Array.new(20).buffer.object! + /// let transferring1 = JSSending.transfer(buffer1) + /// let transferring2 = JSSending.transfer(buffer2) + /// + /// // Receive both objects in a single operation + /// let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + /// Task(executorPreference: executor) { + /// let (receivedBuffer1, receivedBuffer2) = try await JSSending.receive(transferring1, transferring2) + /// // Use both buffers in the worker thread + /// } + /// ``` + /// + /// - Parameters: + /// - sendings: The `JSSending` instances to receive. + /// - isolation: The actor isolation context for this call, used in Swift concurrency. + /// - Returns: A tuple containing the received objects. + /// - Throws: `JSSendingError` if any sending operation fails, or `JSException` if a JavaScript error occurs. + public static func receive( + _ sendings: repeat JSSending, + isolation: isolated (any Actor)? = #isolation, file: StaticString = #file, line: UInt = #line + ) async throws -> (repeat each U) where T == (repeat each U) { + var sendingObjects: [JavaScriptObjectRef] = [] + var transferringObjects: [JavaScriptObjectRef] = [] + var sourceTid: Int32? + for object in repeat each sendings { + sendingObjects.append(object.storage.idInSource) + if object.storage.transferring { + transferringObjects.append(object.storage.idInSource) + } + if sourceTid == nil { + sourceTid = object.storage.sourceTid + } else { + guard sourceTid == object.storage.sourceTid else { + throw JSSendingError("All objects sent at once must be from the same thread") + } + } + } + let objects = try await withCheckedThrowingContinuation { continuation in + let context = _JSSendingContext(continuation: continuation) + sendingObjects.withUnsafeBufferPointer { sendingObjects in + transferringObjects.withUnsafeBufferPointer { transferringObjects in + swjs_request_sending_objects( + sendingObjects.baseAddress!, + Int32(sendingObjects.count), + transferringObjects.baseAddress!, + Int32(transferringObjects.count), + sourceTid!, + Unmanaged.passRetained(context).toOpaque() + ) + } + } + } + guard let objectsArray = JSArray(JSObject(id: objects)) else { + fatalError("Non-array object received!?") } + var index = 0 + func extract(_ sending: JSSending) -> R { + let result = objectsArray[index] + index += 1 + return sending.storage.construct(result.object!) + } + return (repeat extract(each sendings)) } } +fileprivate final class _JSSendingContext: Sendable { + let continuation: CheckedContinuation -extension JSSending where T == JSObject { - - /// Sends a `JSObject` to another thread. - /// - /// - Precondition: The thread calling this method should have the ownership of the `JSObject`. - /// - Postcondition: The original `JSObject` is no longer owned by the thread, further access to it - /// on the thread that called this method is invalid and will result in undefined behavior. - /// - /// - Parameter object: The ``JSObject`` to be transferred. - /// - Returns: A ``Transferring`` instance that can be shared across threads. - @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) - public static func transfer(_ object: JSObject) -> JSSending { - JSSending( - sourceObject: object, - construct: { JSObject(id: $0) }, - deconstruct: { $0.id }, - getSourceTid: { - #if compiler(>=6.1) && _runtime(_multithreaded) - return $0.ownerTid - #else - _ = $0 - // On single-threaded runtime, source and destination threads are always the main thread (TID = -1). - return -1 - #endif - } - ) + init(continuation: CheckedContinuation) { + self.continuation = continuation } } +/// Error type representing failures during JavaScript object sending operations. +/// +/// This error is thrown when a problem occurs during object transfer or cloning +/// between threads, such as attempting to send objects from different threads +/// in a batch operation or other sending-related failures. +public struct JSSendingError: Error, CustomStringConvertible { + /// A description of the error that occurred. + public let description: String + + init(_ message: String) { + self.description = message + } +} /// A function that should be called when an object source thread sends an object to a /// destination thread. @@ -142,14 +338,11 @@ extension JSSending where T == JSObject { @_cdecl("swjs_receive_response") #endif @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) -func _swjs_receive_response(_ object: JavaScriptObjectRef, _ transferring: UnsafeRawPointer?) { +func _swjs_receive_response(_ object: JavaScriptObjectRef, _ contextPtr: UnsafeRawPointer?) { #if compiler(>=6.1) && _runtime(_multithreaded) - guard let transferring = transferring else { return } - let context = Unmanaged<_JSTransferringContext>.fromOpaque(transferring).takeRetainedValue() - context.withLock { state in - assert(state.continuation != nil, "JSTransferring object is not yet received!?") - state.continuation?.resume(returning: object) - } + guard let contextPtr = contextPtr else { return } + let context = Unmanaged<_JSSendingContext>.fromOpaque(contextPtr).takeRetainedValue() + context.continuation.resume(returning: object) #endif } @@ -164,13 +357,10 @@ func _swjs_receive_response(_ object: JavaScriptObjectRef, _ transferring: Unsaf @_cdecl("swjs_receive_error") #endif @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) -func _swjs_receive_error(_ error: JavaScriptObjectRef, _ transferring: UnsafeRawPointer?) { +func _swjs_receive_error(_ error: JavaScriptObjectRef, _ contextPtr: UnsafeRawPointer?) { #if compiler(>=6.1) && _runtime(_multithreaded) - guard let transferring = transferring else { return } - let context = Unmanaged<_JSTransferringContext>.fromOpaque(transferring).takeRetainedValue() - context.withLock { state in - assert(state.continuation != nil, "JSTransferring object is not yet received!?") - state.continuation?.resume(throwing: JSException(JSObject(id: error).jsValue)) - } + guard let contextPtr = contextPtr else { return } + let context = Unmanaged<_JSSendingContext>.fromOpaque(contextPtr).takeRetainedValue() + context.continuation.resume(throwing: JSException(JSObject(id: error).jsValue)) #endif } diff --git a/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift b/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift index 14b13eee9..7373b9604 100644 --- a/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift +++ b/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift @@ -16,6 +16,34 @@ import _CJavaScriptEventLoop /// A task executor that runs tasks on Web Worker threads. /// +/// The `WebWorkerTaskExecutor` provides a way to execute Swift tasks in parallel across multiple +/// Web Worker threads, enabling true multi-threaded execution in WebAssembly environments. +/// This allows CPU-intensive tasks to be offloaded from the main thread, keeping the user +/// interface responsive. +/// +/// ## Multithreading Model +/// +/// Each task submitted to the executor runs on one of the available worker threads. By default, +/// child tasks created within a worker thread continue to run on the same worker thread, +/// maintaining thread locality and avoiding excessive context switching. +/// +/// ## Object Sharing Between Threads +/// +/// When working with JavaScript objects across threads, you must use the `JSSending` API to +/// explicitly transfer or clone objects: +/// +/// ```swift +/// // Create and transfer an object to a worker thread +/// let buffer = JSObject.global.ArrayBuffer.function!.new(1024).object! +/// let transferring = JSSending.transfer(buffer) +/// +/// let task = Task(executorPreference: executor) { +/// // Receive the transferred buffer in the worker +/// let workerBuffer = try await transferring.receive() +/// // Use the buffer in the worker thread +/// } +/// ``` +/// /// ## Prerequisites /// /// This task executor is designed to work with [wasi-threads](https://github.com/WebAssembly/wasi-threads) @@ -24,22 +52,40 @@ import _CJavaScriptEventLoop /// from spawned Web Workers, and forward the message to the main thread /// by calling `_swjs_enqueue_main_job_from_worker`. /// -/// ## Usage +/// ## Basic Usage /// /// ```swift -/// let executor = WebWorkerTaskExecutor(numberOfThreads: 4) +/// // Create an executor with 4 worker threads +/// let executor = try await WebWorkerTaskExecutor(numberOfThreads: 4) /// defer { executor.terminate() } /// +/// // Execute a task on a worker thread +/// let task = Task(executorPreference: executor) { +/// // This runs on a worker thread +/// return performHeavyComputation() +/// } +/// let result = await task.value +/// +/// // Run a block on a worker thread /// await withTaskExecutorPreference(executor) { -/// // This block runs on the Web Worker thread. -/// await withTaskGroup(of: Int.self) { group in +/// // This entire block runs on a worker thread +/// performHeavyComputation() +/// } +/// +/// // Execute multiple tasks in parallel +/// await withTaskGroup(of: Int.self) { group in /// for i in 0..<10 { -/// // Structured child works are executed on the Web Worker thread. -/// group.addTask { fibonacci(of: i) } +/// group.addTask(executorPreference: executor) { +/// // Each task runs on a worker thread +/// return fibonacci(i) +/// } +/// } +/// +/// for await result in group { +/// // Process results as they complete /// } -/// } /// } -/// ```` +/// ``` /// /// ## Known limitations /// @@ -359,36 +405,89 @@ public final class WebWorkerTaskExecutor: TaskExecutor { private let executor: Executor - /// Create a new Web Worker task executor. + /// Creates a new Web Worker task executor with the specified number of worker threads. + /// + /// This initializer creates a pool of Web Worker threads that can execute Swift tasks + /// in parallel. The initialization is asynchronous because it waits for all worker + /// threads to be properly initialized before returning. + /// + /// The number of threads should typically match the number of available CPU cores + /// for CPU-bound workloads. For I/O-bound workloads, you might benefit from more + /// threads than CPU cores. + /// + /// ## Example + /// + /// ```swift + /// // Create an executor with 4 worker threads + /// let executor = try await WebWorkerTaskExecutor(numberOfThreads: 4) + /// + /// // Always terminate the executor when you're done with it + /// defer { executor.terminate() } + /// + /// // Use the executor... + /// ``` /// /// - Parameters: /// - numberOfThreads: The number of Web Worker threads to spawn. - /// - timeout: The timeout to wait for all worker threads to be started. - /// - checkInterval: The interval to check if all worker threads are started. + /// - timeout: The maximum time to wait for all worker threads to be started. Default is 3 seconds. + /// - checkInterval: The interval to check if all worker threads are started. Default is 5 microseconds. + /// - Throws: An error if any worker thread fails to initialize within the timeout period. public init(numberOfThreads: Int, timeout: Duration = .seconds(3), checkInterval: Duration = .microseconds(5)) async throws { self.executor = Executor(numberOfThreads: numberOfThreads) try await self.executor.start(timeout: timeout, checkInterval: checkInterval) } - /// Terminate child Web Worker threads. - /// Jobs enqueued to the executor after calling this method will be ignored. + /// Terminates all worker threads managed by this executor. + /// + /// This method should be called when the executor is no longer needed to free up + /// resources. After calling this method, any tasks enqueued to this executor will + /// be ignored and may never complete. + /// + /// It's recommended to use a `defer` statement immediately after creating the executor + /// to ensure it's properly terminated when it goes out of scope. + /// + /// ## Example + /// + /// ```swift + /// do { + /// let executor = try await WebWorkerTaskExecutor(numberOfThreads: 4) + /// defer { executor.terminate() } + /// + /// // Use the executor... + /// } + /// // Executor is automatically terminated when exiting the scope + /// ``` /// - /// NOTE: This method must be called after all tasks that prefer this executor are done. - /// Otherwise, the tasks may stuck forever. + /// - Important: This method must be called after all tasks that prefer this executor are done. + /// Otherwise, the tasks may stuck forever. public func terminate() { executor.terminate() } - /// The number of Web Worker threads. + /// Returns the number of worker threads managed by this executor. + /// + /// This property reflects the value provided during initialization and doesn't change + /// during the lifetime of the executor. + /// + /// ## Example + /// + /// ```swift + /// let executor = try await WebWorkerTaskExecutor(numberOfThreads: 4) + /// print("Executor is running with \(executor.numberOfThreads) threads") + /// // Prints: "Executor is running with 4 threads" + /// ``` public var numberOfThreads: Int { executor.numberOfThreads } // MARK: TaskExecutor conformance - /// Enqueue a job to the executor. + /// Enqueues a job to be executed by one of the worker threads. + /// + /// This method is part of the `TaskExecutor` protocol and is called by the Swift + /// Concurrency runtime. You typically don't need to call this method directly. /// - /// NOTE: Called from the Swift Concurrency runtime. + /// - Parameter job: The job to enqueue. public func enqueue(_ job: UnownedJob) { Self.traceStatsIncrement(\.enqueueExecutor) executor.enqueue(job) @@ -431,9 +530,23 @@ public final class WebWorkerTaskExecutor: TaskExecutor { @MainActor private static var _swift_task_enqueueGlobalWithDelay_hook_original: UnsafeMutableRawPointer? @MainActor private static var _swift_task_enqueueGlobalWithDeadline_hook_original: UnsafeMutableRawPointer? - /// Install a global executor that forwards jobs from Web Worker threads to the main thread. + /// Installs a global executor that forwards jobs from Web Worker threads to the main thread. + /// + /// This method sets up the necessary hooks to ensure proper task scheduling between + /// the main thread and worker threads. It must be called once (typically at application + /// startup) before using any `WebWorkerTaskExecutor` instances. + /// + /// ## Example + /// + /// ```swift + /// // At application startup + /// WebWorkerTaskExecutor.installGlobalExecutor() + /// + /// // Later, create and use executor instances + /// let executor = try await WebWorkerTaskExecutor(numberOfThreads: 4) + /// ``` /// - /// This function must be called once before using the Web Worker task executor. + /// - Important: This method must be called from the main thread. public static func installGlobalExecutor() { MainActor.assumeIsolated { installGlobalExecutorIsolated() diff --git a/Sources/JavaScriptKit/Runtime/index.js b/Sources/JavaScriptKit/Runtime/index.js index ede43514c..25b6af3c9 100644 --- a/Sources/JavaScriptKit/Runtime/index.js +++ b/Sources/JavaScriptKit/Runtime/index.js @@ -122,6 +122,13 @@ } throw new Error("Unreachable"); }; + function decodeObjectRefs(ptr, length, memory) { + const result = new Array(length); + for (let i = 0; i < length; i++) { + result[i] = memory.readUint32(ptr + 4 * i); + } + return result; + } let globalVariable; if (typeof globalThis !== "undefined") { @@ -200,9 +207,15 @@ constructor(memory) { this.memory = memory; } - transfer(objectRef, transferring) { - const object = this.memory.getObject(objectRef); - return { object, transferring, transfer: [object] }; + send(sendingObject, transferringObjects, sendingContext) { + const object = this.memory.getObject(sendingObject); + const transfer = transferringObjects.map(ref => this.memory.getObject(ref)); + return { object, sendingContext, transfer }; + } + sendObjects(sendingObjects, transferringObjects, sendingContext) { + const objects = sendingObjects.map(ref => this.memory.getObject(ref)); + const transfer = transferringObjects.map(ref => this.memory.getObject(ref)); + return { object: objects, sendingContext, transfer }; } release(objectRef) { this.memory.release(objectRef); @@ -442,7 +455,7 @@ catch (error) { responseMessage.data.response = { ok: false, - error: serializeError(new TypeError(`Failed to serialize response message: ${error}`)) + error: serializeError(new TypeError(`Failed to serialize message: ${error}`)) }; newBroker.reply(responseMessage); } @@ -735,21 +748,45 @@ // Main thread's tid is always -1 return this.tid || -1; }, - swjs_request_transferring_object: (object_ref, object_source_tid, transferring) => { + swjs_request_sending_object: (sending_object, transferring_objects, transferring_objects_count, object_source_tid, sending_context) => { var _a; if (!this.options.threadChannel) { throw new Error("threadChannel is not set in options given to SwiftRuntime. Please set it to request transferring objects."); } const broker = getMessageBroker(this.options.threadChannel); + const memory = this.memory; + const transferringObjects = decodeObjectRefs(transferring_objects, transferring_objects_count, memory); + broker.request({ + type: "request", + data: { + sourceTid: (_a = this.tid) !== null && _a !== void 0 ? _a : MAIN_THREAD_TID, + targetTid: object_source_tid, + context: sending_context, + request: { + method: "send", + parameters: [sending_object, transferringObjects, sending_context], + } + } + }); + }, + swjs_request_sending_objects: (sending_objects, sending_objects_count, transferring_objects, transferring_objects_count, object_source_tid, sending_context) => { + var _a; + if (!this.options.threadChannel) { + throw new Error("threadChannel is not set in options given to SwiftRuntime. Please set it to request transferring objects."); + } + const broker = getMessageBroker(this.options.threadChannel); + const memory = this.memory; + const sendingObjects = decodeObjectRefs(sending_objects, sending_objects_count, memory); + const transferringObjects = decodeObjectRefs(transferring_objects, transferring_objects_count, memory); broker.request({ type: "request", data: { sourceTid: (_a = this.tid) !== null && _a !== void 0 ? _a : MAIN_THREAD_TID, targetTid: object_source_tid, - context: transferring, + context: sending_context, request: { - method: "transfer", - parameters: [object_ref, transferring], + method: "sendObjects", + parameters: [sendingObjects, transferringObjects, sending_context], } } }); diff --git a/Sources/JavaScriptKit/Runtime/index.mjs b/Sources/JavaScriptKit/Runtime/index.mjs index f95aee940..668368203 100644 --- a/Sources/JavaScriptKit/Runtime/index.mjs +++ b/Sources/JavaScriptKit/Runtime/index.mjs @@ -116,6 +116,13 @@ const writeAndReturnKindBits = (value, payload1_ptr, payload2_ptr, is_exception, } throw new Error("Unreachable"); }; +function decodeObjectRefs(ptr, length, memory) { + const result = new Array(length); + for (let i = 0; i < length; i++) { + result[i] = memory.readUint32(ptr + 4 * i); + } + return result; +} let globalVariable; if (typeof globalThis !== "undefined") { @@ -194,9 +201,15 @@ class ITCInterface { constructor(memory) { this.memory = memory; } - transfer(objectRef, transferring) { - const object = this.memory.getObject(objectRef); - return { object, transferring, transfer: [object] }; + send(sendingObject, transferringObjects, sendingContext) { + const object = this.memory.getObject(sendingObject); + const transfer = transferringObjects.map(ref => this.memory.getObject(ref)); + return { object, sendingContext, transfer }; + } + sendObjects(sendingObjects, transferringObjects, sendingContext) { + const objects = sendingObjects.map(ref => this.memory.getObject(ref)); + const transfer = transferringObjects.map(ref => this.memory.getObject(ref)); + return { object: objects, sendingContext, transfer }; } release(objectRef) { this.memory.release(objectRef); @@ -436,7 +449,7 @@ class SwiftRuntime { catch (error) { responseMessage.data.response = { ok: false, - error: serializeError(new TypeError(`Failed to serialize response message: ${error}`)) + error: serializeError(new TypeError(`Failed to serialize message: ${error}`)) }; newBroker.reply(responseMessage); } @@ -729,21 +742,45 @@ class SwiftRuntime { // Main thread's tid is always -1 return this.tid || -1; }, - swjs_request_transferring_object: (object_ref, object_source_tid, transferring) => { + swjs_request_sending_object: (sending_object, transferring_objects, transferring_objects_count, object_source_tid, sending_context) => { var _a; if (!this.options.threadChannel) { throw new Error("threadChannel is not set in options given to SwiftRuntime. Please set it to request transferring objects."); } const broker = getMessageBroker(this.options.threadChannel); + const memory = this.memory; + const transferringObjects = decodeObjectRefs(transferring_objects, transferring_objects_count, memory); + broker.request({ + type: "request", + data: { + sourceTid: (_a = this.tid) !== null && _a !== void 0 ? _a : MAIN_THREAD_TID, + targetTid: object_source_tid, + context: sending_context, + request: { + method: "send", + parameters: [sending_object, transferringObjects, sending_context], + } + } + }); + }, + swjs_request_sending_objects: (sending_objects, sending_objects_count, transferring_objects, transferring_objects_count, object_source_tid, sending_context) => { + var _a; + if (!this.options.threadChannel) { + throw new Error("threadChannel is not set in options given to SwiftRuntime. Please set it to request transferring objects."); + } + const broker = getMessageBroker(this.options.threadChannel); + const memory = this.memory; + const sendingObjects = decodeObjectRefs(sending_objects, sending_objects_count, memory); + const transferringObjects = decodeObjectRefs(transferring_objects, transferring_objects_count, memory); broker.request({ type: "request", data: { sourceTid: (_a = this.tid) !== null && _a !== void 0 ? _a : MAIN_THREAD_TID, targetTid: object_source_tid, - context: transferring, + context: sending_context, request: { - method: "transfer", - parameters: [object_ref, transferring], + method: "sendObjects", + parameters: [sendingObjects, transferringObjects, sending_context], } } }); diff --git a/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h b/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h index 12e07048a..2b96a81ea 100644 --- a/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h +++ b/Sources/_CJavaScriptKit/include/_CJavaScriptKit.h @@ -316,11 +316,20 @@ IMPORT_JS_FUNCTION(swjs_get_worker_thread_id, int, (void)) int swjs_get_worker_thread_id_cached(void); -/// Requests transferring a JavaScript object to another worker thread. +/// Requests sending a JavaScript object to another worker thread. /// /// This must be called from the destination thread of the transfer. -IMPORT_JS_FUNCTION(swjs_request_transferring_object, void, (JavaScriptObjectRef object, - int object_source_tid, - void * _Nonnull transferring)) +IMPORT_JS_FUNCTION(swjs_request_sending_object, void, (JavaScriptObjectRef sending_object, + const JavaScriptObjectRef * _Nonnull transferring_objects, + int transferring_objects_count, + int object_source_tid, + void * _Nonnull sending_context)) + +IMPORT_JS_FUNCTION(swjs_request_sending_objects, void, (const JavaScriptObjectRef * _Nonnull sending_objects, + int sending_objects_count, + const JavaScriptObjectRef * _Nonnull transferring_objects, + int transferring_objects_count, + int object_source_tid, + void * _Nonnull sending_context)) #endif /* _CJavaScriptKit_h */ diff --git a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift index 1dd0f1dd1..31d1593f3 100644 --- a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift +++ b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift @@ -264,6 +264,12 @@ final class WebWorkerTaskExecutorTests: XCTestCase { executor.terminate() } + func testSendingWithoutReceiving() async throws { + let object = JSObject.global.Object.function!.new() + _ = JSSending.transfer(object) + _ = JSSending(object) + } + func testTransferMainToWorker() async throws { let Uint8Array = JSObject.global.Uint8Array.function! let buffer = Uint8Array.new(100).buffer.object! @@ -275,6 +281,9 @@ final class WebWorkerTaskExecutorTests: XCTestCase { } let byteLength = try await task.value XCTAssertEqual(byteLength, 100) + + // Transferred Uint8Array should have 0 byteLength + XCTAssertEqual(buffer.byteLength.number!, 0) } func testTransferWorkerToMain() async throws { @@ -306,7 +315,50 @@ final class WebWorkerTaskExecutorTests: XCTestCase { XCTFail("Should throw an error") return } - XCTAssertTrue(jsErrorMessage.contains("Failed to serialize response message")) + XCTAssertTrue(jsErrorMessage.contains("Failed to serialize message"), jsErrorMessage) + } + + func testTransferMultipleTimes() async throws { + let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + let Uint8Array = JSObject.global.Uint8Array.function! + let buffer = Uint8Array.new(100).buffer.object! + let transferring = JSSending.transfer(buffer) + let task1 = Task(executorPreference: executor) { + let buffer = try await transferring.receive() + return buffer.byteLength.number! + } + let byteLength1 = try await task1.value + XCTAssertEqual(byteLength1, 100) + + let task2 = Task(executorPreference: executor) { + do { + _ = try await transferring.receive() + return nil + } catch { + return String(describing: error) + } + } + guard let jsErrorMessage = await task2.value else { + XCTFail("Should throw an error") + return + } + XCTAssertTrue(jsErrorMessage.contains("Failed to serialize message")) + } + + func testCloneMultipleTimes() async throws { + let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + let object = JSObject.global.Object.function!.new() + object["test"] = "Hello, World!" + + for _ in 0..<2 { + let cloning = JSSending(object) + let task = Task(executorPreference: executor) { + let object = try await cloning.receive() + return object["test"].string! + } + let result = try await task.value + XCTAssertEqual(result, "Hello, World!") + } } func testTransferBetweenWorkers() async throws { @@ -327,6 +379,55 @@ final class WebWorkerTaskExecutorTests: XCTestCase { XCTAssertEqual(byteLength, 100) } + func testTransferMultipleItems() async throws { + let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + let Uint8Array = JSObject.global.Uint8Array.function! + let buffer1 = Uint8Array.new(10).buffer.object! + let buffer2 = Uint8Array.new(11).buffer.object! + let transferring1 = JSSending.transfer(buffer1) + let transferring2 = JSSending.transfer(buffer2) + let task = Task(executorPreference: executor) { + let (buffer1, buffer2) = try await JSSending.receive(transferring1, transferring2) + return (buffer1.byteLength.number!, buffer2.byteLength.number!) + } + let (byteLength1, byteLength2) = try await task.value + XCTAssertEqual(byteLength1, 10) + XCTAssertEqual(byteLength2, 11) + XCTAssertEqual(buffer1.byteLength.number!, 0) + XCTAssertEqual(buffer2.byteLength.number!, 0) + + // Mix transferring and cloning + let buffer3 = Uint8Array.new(12).buffer.object! + let buffer4 = Uint8Array.new(13).buffer.object! + let transferring3 = JSSending.transfer(buffer3) + let cloning4 = JSSending(buffer4) + let task2 = Task(executorPreference: executor) { + let (buffer3, buffer4) = try await JSSending.receive(transferring3, cloning4) + return (buffer3.byteLength.number!, buffer4.byteLength.number!) + } + let (byteLength3, byteLength4) = try await task2.value + XCTAssertEqual(byteLength3, 12) + XCTAssertEqual(byteLength4, 13) + XCTAssertEqual(buffer3.byteLength.number!, 0) + XCTAssertEqual(buffer4.byteLength.number!, 13) + } + + func testCloneObjectToWorker() async throws { + let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + let object = JSObject.global.Object.function!.new() + object["test"] = "Hello, World!" + let cloning = JSSending(object) + let task = Task(executorPreference: executor) { + let object = try await cloning.receive() + return object["test"].string! + } + let result = try await task.value + XCTAssertEqual(result, "Hello, World!") + + // Further access to the original object is valid + XCTAssertEqual(object["test"].string!, "Hello, World!") + } + /* func testDeinitJSObjectOnDifferentThread() async throws { let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) From 44a5dba7d3c8f929d49f9c2522a4a88c63beda26 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Tue, 11 Mar 2025 09:42:50 +0000 Subject: [PATCH 17/19] Build fix --- .../JavaScriptEventLoop/JSObject+Transferring.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift index c573939e9..615dadce6 100644 --- a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift +++ b/Sources/JavaScriptEventLoop/JSObject+Transferring.swift @@ -79,6 +79,7 @@ public struct JSSending: @unchecked Sendable { } } +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) extension JSSending where T == JSObject { private init(_ object: JSObject, transferring: Bool) { self.init( @@ -165,6 +166,7 @@ extension JSSending where T == JSObject { } } +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) extension JSSending { /// Receives a sent `JSObject` from a thread. @@ -227,6 +229,8 @@ extension JSSending { #endif } + // 6.0 and below can't compile the following without a compiler crash. + #if compiler(>=6.1) /// Receives multiple `JSSending` instances from a thread in a single operation. /// /// This method is more efficient than receiving multiple objects individually, as it @@ -257,10 +261,12 @@ extension JSSending { /// - isolation: The actor isolation context for this call, used in Swift concurrency. /// - Returns: A tuple containing the received objects. /// - Throws: `JSSendingError` if any sending operation fails, or `JSException` if a JavaScript error occurs. + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) public static func receive( _ sendings: repeat JSSending, isolation: isolated (any Actor)? = #isolation, file: StaticString = #file, line: UInt = #line ) async throws -> (repeat each U) where T == (repeat each U) { + #if compiler(>=6.1) && _runtime(_multithreaded) var sendingObjects: [JavaScriptObjectRef] = [] var transferringObjects: [JavaScriptObjectRef] = [] var sourceTid: Int32? @@ -302,9 +308,14 @@ extension JSSending { return sending.storage.construct(result.object!) } return (repeat extract(each sendings)) + #else + return try await (repeat (each sendings).receive()) + #endif } + #endif // compiler(>=6.1) } +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) fileprivate final class _JSSendingContext: Sendable { let continuation: CheckedContinuation From b678f71b632631ea8c7d782e08ed5a786cf962ee Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Tue, 11 Mar 2025 10:03:06 +0000 Subject: [PATCH 18/19] Skip multi-transfer tests --- .../WebWorkerTaskExecutorTests.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift index 31d1593f3..16cfd6374 100644 --- a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift +++ b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift @@ -318,6 +318,10 @@ final class WebWorkerTaskExecutorTests: XCTestCase { XCTAssertTrue(jsErrorMessage.contains("Failed to serialize message"), jsErrorMessage) } + /* + // Node.js 20 and below doesn't throw exception when transferring the same ArrayBuffer + // multiple times. + // See https://github.com/nodejs/node/commit/38dee8a1c04237bd231a01410f42e9d172f4c162 func testTransferMultipleTimes() async throws { let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) let Uint8Array = JSObject.global.Uint8Array.function! @@ -344,6 +348,7 @@ final class WebWorkerTaskExecutorTests: XCTestCase { } XCTAssertTrue(jsErrorMessage.contains("Failed to serialize message")) } + */ func testCloneMultipleTimes() async throws { let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) From f5e3a95412cda11df093fe8485ca81a8c26487fb Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Tue, 11 Mar 2025 10:05:52 +0000 Subject: [PATCH 19/19] Rename JSObject+Transferring.swift to JSSending.swift --- .../{JSObject+Transferring.swift => JSSending.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Sources/JavaScriptEventLoop/{JSObject+Transferring.swift => JSSending.swift} (100%) diff --git a/Sources/JavaScriptEventLoop/JSObject+Transferring.swift b/Sources/JavaScriptEventLoop/JSSending.swift similarity index 100% rename from Sources/JavaScriptEventLoop/JSObject+Transferring.swift rename to Sources/JavaScriptEventLoop/JSSending.swift