Skip to content

Commit

Permalink
Assert that JSObject is being accessed only from the owner thread
Browse files Browse the repository at this point in the history
  • Loading branch information
kateinoigakukun committed Nov 28, 2024
1 parent afada10 commit 45206f7
Show file tree
Hide file tree
Showing 4 changed files with 224 additions and 14 deletions.
5 changes: 5 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ let package = Package(
]
),
.target(name: "_CJavaScriptEventLoopTestSupport"),

.testTarget(
name: "JavaScriptKitTests",
dependencies: ["JavaScriptKit"]
),
.testTarget(
name: "JavaScriptEventLoopTestSupportTests",
dependencies: [
Expand Down
96 changes: 82 additions & 14 deletions Sources/JavaScriptKit/FundamentalObjects/JSObject.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import _CJavaScriptKit

#if canImport(wasi_pthread)
import wasi_pthread
#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.
Expand All @@ -18,9 +24,35 @@ import _CJavaScriptKit
public class JSObject: Equatable {
@_spi(JSObject_id)
public var id: JavaScriptObjectRef

#if _runtime(_multithreaded)
private let ownerThread: pthread_t
#endif

@_spi(JSObject_id)
public init(id: JavaScriptObjectRef) {
self.id = id
self.ownerThread = pthread_self()
}

/// Asserts that the object is being accessed from the owner thread.
///
/// - Parameter hint: A string to provide additional context for debugging.
///
/// NOTE: Accessing a `JSObject` from a thread other than the thread it was created on
/// is a programmer error and will result in a runtime assertion failure because JavaScript
/// object spaces are not shared across threads backed by Web Workers.
private func assertOnOwnerThread(hint: @autoclosure () -> String) {
#if _runtime(_multithreaded)
precondition(pthread_equal(ownerThread, pthread_self()) != 0, "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 _runtime(_multithreaded)
precondition(pthread_equal(lhs.ownerThread, rhs.ownerThread) != 0, "JSObject is being accessed from a thread other than the owner thread: \(hint())")
#endif
}

#if !hasFeature(Embedded)
Expand Down Expand Up @@ -79,32 +111,56 @@ public class JSObject: Equatable {
/// - Parameter name: The name of this object's member to access.
/// - Returns: The value of the `name` member of this object.
public subscript(_ name: String) -> JSValue {
get { getJSValue(this: self, name: JSString(name)) }
set { setJSValue(this: self, name: JSString(name), value: newValue) }
get {
assertOnOwnerThread(hint: "reading '\(name)' property")
return getJSValue(this: self, name: JSString(name))
}
set {
assertOnOwnerThread(hint: "writing '\(name)' property")
setJSValue(this: self, name: JSString(name), value: newValue)
}
}

/// Access the `name` member dynamically through JavaScript and Swift runtime bridge library.
/// - Parameter name: The name of this object's member to access.
/// - Returns: The value of the `name` member of this object.
public subscript(_ name: JSString) -> JSValue {
get { getJSValue(this: self, name: name) }
set { setJSValue(this: self, name: name, value: newValue) }
get {
assertOnOwnerThread(hint: "reading '<<JSString>>' property")
return getJSValue(this: self, name: name)
}
set {
assertOnOwnerThread(hint: "writing '<<JSString>>' property")
setJSValue(this: self, name: name, value: newValue)
}
}

/// Access the `index` member dynamically through JavaScript and Swift runtime bridge library.
/// - Parameter index: The index of this object's member to access.
/// - Returns: The value of the `index` member of this object.
public subscript(_ index: Int) -> JSValue {
get { getJSValue(this: self, index: Int32(index)) }
set { setJSValue(this: self, index: Int32(index), value: newValue) }
get {
assertOnOwnerThread(hint: "reading '\(index)' property")
return getJSValue(this: self, index: Int32(index))
}
set {
assertOnOwnerThread(hint: "writing '\(index)' property")
setJSValue(this: self, index: Int32(index), value: newValue)
}
}

/// Access the `symbol` member dynamically through JavaScript and Swift runtime bridge library.
/// - Parameter symbol: The name of this object's member to access.
/// - Returns: The value of the `name` member of this object.
public subscript(_ name: JSSymbol) -> JSValue {
get { getJSValue(this: self, symbol: name) }
set { setJSValue(this: self, symbol: name, value: newValue) }
get {
assertOnOwnerThread(hint: "reading '<<JSSymbol>>' property")
return getJSValue(this: self, symbol: name)
}
set {
assertOnOwnerThread(hint: "writing '<<JSSymbol>>' property")
setJSValue(this: self, symbol: name, value: newValue)
}
}

#if !hasFeature(Embedded)
Expand Down Expand Up @@ -134,7 +190,8 @@ public class JSObject: Equatable {
/// - Parameter constructor: The constructor function to check.
/// - Returns: The result of `instanceof` in the JavaScript environment.
public func isInstanceOf(_ constructor: JSFunction) -> Bool {
swjs_instanceof(id, constructor.id)
assertOnOwnerThread(hint: "calling 'isInstanceOf'")
return swjs_instanceof(id, constructor.id)
}

static let _JS_Predef_Value_Global: JavaScriptObjectRef = 0
Expand All @@ -145,21 +202,32 @@ public class JSObject: Equatable {

// `JSObject` storage itself is immutable, and use of `JSObject.global` from other
// threads maintains the same semantics as `globalThis` in JavaScript.
#if compiler(>=5.10)
nonisolated(unsafe)
static let _global = JSObject(id: _JS_Predef_Value_Global)
#if _runtime(_multithreaded)
@LazyThreadLocal(initialize: {
return JSObject(id: _JS_Predef_Value_Global)
})
private static var _global: JSObject
#else
static let _global = JSObject(id: _JS_Predef_Value_Global)
#if compiler(>=5.10)
nonisolated(unsafe)
static let _global = JSObject(id: _JS_Predef_Value_Global)
#else
static let _global = JSObject(id: _JS_Predef_Value_Global)
#endif
#endif

deinit { swjs_release(id) }
deinit {
assertOnOwnerThread(hint: "deinitializing")
swjs_release(id)
}

/// Returns a Boolean value indicating whether two values point to same objects.
///
/// - Parameters:
/// - lhs: A object to compare.
/// - rhs: Another object to compare.
public static func == (lhs: JSObject, rhs: JSObject) -> Bool {
assertSameOwnerThread(lhs: lhs, rhs: rhs, hint: "comparing two JSObjects for equality")
return lhs.id == rhs.id
}

Expand Down
103 changes: 103 additions & 0 deletions Sources/JavaScriptKit/ThreadLocal.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#if _runtime(_multithreaded)
#if canImport(wasi_pthread)
import wasi_pthread
#elseif canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#else
#error("Unsupported platform")
#endif

@propertyWrapper
final class ThreadLocal<Value>: Sendable {
var wrappedValue: Value? {
get {
guard let pointer = pthread_getspecific(key) else {
return nil
}
return fromPointer(pointer)
}
set {
if let oldPointer = pthread_getspecific(key) {
release(oldPointer)
}
if let newValue = newValue {
let pointer = toPointer(newValue)
pthread_setspecific(key, pointer)
}
}
}

private let key: pthread_key_t
private let toPointer: @Sendable (Value) -> UnsafeMutableRawPointer
private let fromPointer: @Sendable (UnsafeMutableRawPointer) -> Value
private let release: @Sendable (UnsafeMutableRawPointer) -> Void

init() where Value: AnyObject {
var key = pthread_key_t()
pthread_key_create(&key, nil)
self.key = key
self.toPointer = { Unmanaged.passRetained($0).toOpaque() }
self.fromPointer = { Unmanaged<Value>.fromOpaque($0).takeUnretainedValue() }
self.release = { Unmanaged<Value>.fromOpaque($0).release() }
}

class Box {
let value: Value
init(_ value: Value) {
self.value = value
}
}

init(boxing _: Void) {
var key = pthread_key_t()
pthread_key_create(&key, nil)
self.key = key
self.toPointer = {
let box = Box($0)
let pointer = Unmanaged.passRetained(box).toOpaque()
return pointer
}
self.fromPointer = {
let box = Unmanaged<Box>.fromOpaque($0).takeUnretainedValue()
return box.value
}
self.release = { Unmanaged<Box>.fromOpaque($0).release() }
}

deinit {
if let oldPointer = pthread_getspecific(key) {
release(oldPointer)
}
pthread_key_delete(key)
}
}

@propertyWrapper
final class LazyThreadLocal<Value>: Sendable {
private let storage: ThreadLocal<Value>

var wrappedValue: Value {
if let value = storage.wrappedValue {
return value
}
let value = initialValue()
storage.wrappedValue = value
return value
}

private let initialValue: @Sendable () -> Value

init(initialize: @Sendable @escaping () -> Value) where Value: AnyObject {
self.storage = ThreadLocal()
self.initialValue = initialize
}

init(initialize: @Sendable @escaping () -> Value) {
self.storage = ThreadLocal(boxing: ())
self.initialValue = initialize
}
}

#endif
34 changes: 34 additions & 0 deletions Tests/JavaScriptKitTests/ThreadLocalTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import XCTest
@testable import JavaScriptKit

final class ThreadLocalTests: XCTestCase {
class MyHeapObject {}

func testLeak() throws {
struct Check {
@ThreadLocal
var value: MyHeapObject?
}
weak var weakObject: MyHeapObject?
do {
let object = MyHeapObject()
weakObject = object
let check = Check()
check.value = object
XCTAssertNotNil(check.value)
XCTAssertTrue(check.value === object)
}
XCTAssertNil(weakObject)
}

func testLazyThreadLocal() throws {
struct Check {
@LazyThreadLocal(initialize: { MyHeapObject() })
var value: MyHeapObject
}
let check = Check()
let object1 = check.value
let object2 = check.value
XCTAssertTrue(object1 === object2)
}
}

0 comments on commit 45206f7

Please sign in to comment.