Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a mechanism to "Transfer" JSObject between Workers #292

Merged
merged 19 commits into from
Mar 11, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
28d5ec0
Add `JSObject.transfer` and `JSObject.receive` APIs
kateinoigakukun Mar 10, 2025
e406cd3
Stop hardcoding the Swift toolchain version in the Multithreading exa…
kateinoigakukun Mar 10, 2025
cfa1b2d
Update Multithreading example to support transferable objects
kateinoigakukun Mar 10, 2025
9d335a8
Add OffscreenCanvas example
kateinoigakukun Mar 10, 2025
98cec71
Rename `JSObject.receive` to `JSObject.Transferring.receive`
kateinoigakukun Mar 10, 2025
9b84176
Update test harness to support transferring
kateinoigakukun Mar 10, 2025
c481614
Fix JSObject lifetime issue while transferring
kateinoigakukun Mar 10, 2025
65ddcd3
Add basic tests for transferring objects between threads
kateinoigakukun Mar 10, 2025
f0bd60c
Fix native build
kateinoigakukun Mar 10, 2025
8d4bba6
Add cautionary notes to the documentation of `JSObject.transfer()`.
kateinoigakukun Mar 10, 2025
09d5311
Rename `JSObject.Transferring` to `JSTransferring<T>`
kateinoigakukun Mar 11, 2025
f25bfec
MessageBroker
kateinoigakukun Mar 11, 2025
58f91c3
Relax deinit requirement
kateinoigakukun Mar 11, 2025
2a081de
Remove dead code and fix error message
kateinoigakukun Mar 11, 2025
4fe37e7
Rename JSTransferring to JSSending
kateinoigakukun Mar 11, 2025
eeff111
Add `JSSending.receive(...)` to receive multiple objects at once
kateinoigakukun Mar 11, 2025
44a5dba
Build fix
kateinoigakukun Mar 11, 2025
b678f71
Skip multi-transfer tests
kateinoigakukun Mar 11, 2025
f5e3a95
Rename JSObject+Transferring.swift to JSSending.swift
kateinoigakukun Mar 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Relax deinit requirement
  • Loading branch information
kateinoigakukun committed Mar 11, 2025
commit 58f91c35c6eecc5750c061972ac439dfd8dcbd49
20 changes: 20 additions & 0 deletions Runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions Runtime/src/itc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Interface extends Record<string, any>> = {
Expand Down
1 change: 1 addition & 0 deletions Runtime/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 5 additions & 9 deletions Sources/JavaScriptEventLoop/JSObject+Transferring.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public struct JSTransferring<T>: @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()
Expand All @@ -65,12 +65,6 @@ public struct JSTransferring<T>: @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 {
Expand Down Expand Up @@ -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!?")
Expand All @@ -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!?")
Expand Down
8 changes: 7 additions & 1 deletion Sources/JavaScriptKit/FundamentalObjects/JSObject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
24 changes: 24 additions & 0 deletions Sources/JavaScriptKit/Runtime/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions Sources/JavaScriptKit/Runtime/index.mjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions Sources/_CJavaScriptKit/include/_CJavaScriptKit.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand Down
37 changes: 32 additions & 5 deletions Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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)
}

/*
Expand Down
Loading