From d3f96f43bfd91b027d18086aa645673cb4804d85 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 18:29:34 +0000 Subject: [PATCH] Fix cross-thread JSString deinit by wrapping JSObject instead of raw ref JSString.Guts previously held a raw JavaScriptObjectRef and released it unconditionally on whatever thread ran deinit. In the multithreaded Wasm runtime each thread has its own JSObjectSpace, so releasing a ref on the wrong thread caused a TypeError crash (BugSnag: rc property of undefined). JSObject already handles this correctly by capturing ownerTid at init time and routing deinit through swjs_release_remote when destroyed off-thread. This change makes Guts hold a JSObject instead of a raw ref, delegating the correct cross-thread release behaviour to the existing JSObject.deinit path. Adds a regression test testDeinitJSStringOnDifferentThread that reproduces the crash deterministically: it forces JS ref allocation on the main thread via asInternalJSRef(), then drops the last reference on a worker thread. Fixes the crash seen in v292745-rc4 after upgrading to JavaScriptKit 0.50.2. https://claude.ai/code/session_01Qhg5GLXZYNJtH63Gs1SwJH --- .../FundamentalObjects/JSString.swift | 27 ++++++++----------- .../WebWorkerTaskExecutorTests.swift | 25 +++++++++++++++++ 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/Sources/JavaScriptKit/FundamentalObjects/JSString.swift b/Sources/JavaScriptKit/FundamentalObjects/JSString.swift index 4e6a0a085..0eabfa78d 100644 --- a/Sources/JavaScriptKit/FundamentalObjects/JSString.swift +++ b/Sources/JavaScriptKit/FundamentalObjects/JSString.swift @@ -16,25 +16,26 @@ import _CJavaScriptKit /// public struct JSString: LosslessStringConvertible, Equatable { /// The internal representation of JS compatible string - /// The initializers of this type must initialize `jsRef` or `buffer`. + /// The initializers of this type must initialize `jsObject` or `buffer`. /// And the uninitialized one will be lazily initialized class Guts { - var shouldDeallocateRef: Bool = false - lazy var jsRef: JavaScriptObjectRef = { - self.shouldDeallocateRef = true - return buffer.withUTF8 { bufferPtr in + // Owns the JS-side ref via JSObject, whose deinit routes the release to + // the correct thread via swjs_release_remote when destroyed off-owner-thread. + lazy var jsObject: JSObject = { + let ref = buffer.withUTF8 { bufferPtr in return swjs_decode_string(bufferPtr.baseAddress!, Int32(bufferPtr.count)) } + return JSObject(id: ref) // captures ownerTid = current thread here }() lazy var buffer: String = { var bytesRef: JavaScriptObjectRef = 0 - let bytesLength = Int(swjs_encode_string(jsRef, &bytesRef)) + let bytesLength = Int(swjs_encode_string(jsObject.id, &bytesRef)) // +1 for null terminator let buffer = UnsafeMutablePointer.allocate(capacity: bytesLength + 1) defer { buffer.deallocate() - swjs_release(bytesRef) + swjs_release(bytesRef) // bytesRef is a same-thread temporary } swjs_load_string(bytesRef, buffer) buffer[bytesLength] = 0 @@ -46,13 +47,7 @@ public struct JSString: LosslessStringConvertible, Equatable { } init(from jsRef: JavaScriptObjectRef) { - self.jsRef = jsRef - self.shouldDeallocateRef = true - } - - deinit { - guard shouldDeallocateRef else { return } - swjs_release(jsRef) + self.jsObject = JSObject(id: jsRef) } } @@ -79,7 +74,7 @@ public struct JSString: LosslessStringConvertible, Equatable { public static func == (lhs: JSString, rhs: JSString) -> Bool { withExtendedLifetime(lhs.guts) { lhsGuts in withExtendedLifetime(rhs.guts) { rhsGuts in - return swjs_value_equals(lhsGuts.jsRef, rhsGuts.jsRef) + return swjs_value_equals(lhsGuts.jsObject.id, rhsGuts.jsObject.id) } } } @@ -95,6 +90,6 @@ extension JSString: ExpressibleByStringLiteral { extension JSString { func asInternalJSRef() -> JavaScriptObjectRef { - guts.jsRef + guts.jsObject.id } } diff --git a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift index 54559f3d8..69b3390dc 100644 --- a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift +++ b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift @@ -789,5 +789,30 @@ final class WebWorkerTaskExecutorTests: XCTestCase { // await task.value // executor.terminate() // } + + func testDeinitJSStringOnDifferentThread() async throws { + final class Box: @unchecked Sendable { + var string: JSString? + init(_ string: JSString) { self.string = string } + } + + let executor = try await WebWorkerTaskExecutor(numberOfThreads: 1) + defer { executor.terminate() } + + // Force JS ref allocation on the main thread so ownerTid = main thread. + var string: JSString? = JSString("main-thread-owned-key") + _ = string!.asInternalJSRef() + + let box = Box(string!) + string = nil + + // Drop the last reference on a worker — deinit fires on the worker. + // Before the fix this crashed: TypeError: Cannot read properties of undefined (reading 'rc') + let task = Task(executorPreference: executor) { + XCTAssertFalse(isMainThread()) + box.string = nil + } + await task.value + } } #endif