Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,23 @@ container.withLock { value in

The existing `read` and `write` APIs remain available. When the mutex backend is active, both of them route through the same exclusive critical section as `withLock`.

### Weak references

Use `DBThreadSafeWeakContainer` when you need a weak reference that still keeps the enclosing type `Sendable`:

```swift
protocol LoggerDelegate: AnyObject {}

let delegate = DBThreadSafeWeakContainer<any LoggerDelegate>()
delegate.withLock { value in
value = loggerDelegate
}
```

`DBThreadSafeWeakContainer` exposes only `withLock`. Its payload is optional because the weak reference can disappear at any time.

The weak container follows the same lock backend selection as `DBThreadSafeContainer` and exposes the active backend through `lockType`.

### Reading the value

To read the value stored in the container, use the `read()` method:
Expand Down
114 changes: 114 additions & 0 deletions Sources/DBThreadSafe/DBThreadSafeWeakContainer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import Foundation
#if canImport(Synchronization)
import Synchronization
#endif

/// Thread-safe Sendable container for a weak reference.
///
/// Use when `weak var` prevents `Sendable` conformance of the enclosing type.
/// The stored object is held weakly and becomes `nil` when deallocated.
///
/// Generic parameter `T` is intentionally unconstrained (no `T: AnyObject`),
/// because class-bound protocol existentials don't fit that generic constraint
/// well in call sites like `DBThreadSafeWeakContainer<any MyDelegate>()`.
public final class DBThreadSafeWeakContainer<T>: Sendable {
private let storage: any WeakLockStorage<T>

/// The concrete lock backend currently used by the container.
public var lockType: DBThreadSafeLock {
storage.lockType
}

public init(_ value: T? = nil) {
self.storage = Self.makeDefaultStorage(value)
}

public init(_ value: T? = nil, lock: DBThreadSafeLock) {
self.storage = Self.makeStorage(value, lock: lock)
}

private static func makeDefaultStorage(_ value: T?) -> any WeakLockStorage<T> {
#if canImport(Synchronization)
if #available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) {
return MutexWeakStorage(value)
}
#endif

return PThreadRWLockWeakStorage(value)
}

private static func makeStorage(_ value: T?, lock: DBThreadSafeLock) -> any WeakLockStorage<T> {
switch lock {
case .pthreadRWLock:
return PThreadRWLockWeakStorage(value)
#if canImport(Synchronization)
case .mutex:
if #available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) {
return MutexWeakStorage(value)
} else {
preconditionFailure("DBThreadSafeLock.mutex requires a supported OS version")
}
#endif
}
}

/// Executes a closure while holding an exclusive lock on the stored weak reference.
public func withLock<U>(_ closure: (_ value: inout T?) throws -> U) rethrows -> U {
try storage.withLock(closure)
}
}

protocol WeakLockStorage<Value>: AnyObject, Sendable {
associatedtype Value

var lockType: DBThreadSafeLock { get }
func withLock<U>(_ closure: (_ value: inout Value?) throws -> U) rethrows -> U
}

final class PThreadRWLockWeakStorage<T>: WeakLockStorage, @unchecked Sendable {
nonisolated(unsafe) private weak var object: AnyObject?
private let lock = Lock()

var lockType: DBThreadSafeLock {
.pthreadRWLock
}

init(_ value: T?) {
self.object = value as AnyObject?
}

func withLock<U>(_ closure: (_ value: inout T?) throws -> U) rethrows -> U {
lock.writeLock()
var value = object as? T
defer {
object = value as AnyObject?
lock.unlock()
}
return try closure(&value)
}
}

#if canImport(Synchronization)
@available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *)
final class MutexWeakStorage<T>: WeakLockStorage, @unchecked Sendable {
nonisolated(unsafe) private weak var object: AnyObject?
private let mutex: Mutex<Void>

var lockType: DBThreadSafeLock {
.mutex
}

init(_ value: T?) {
self.object = value as AnyObject?
self.mutex = Mutex(())
}

func withLock<U>(_ closure: (_ value: inout T?) throws -> U) rethrows -> U {
try mutex.withLock { _ in
var value = object as? T
defer { object = value as AnyObject? }
return try closure(&value)
}
}
}
#endif
146 changes: 146 additions & 0 deletions Tests/DBThreadSafeTests/DBThreadSafeWeakContainerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import DBThreadSafe
import Foundation
import Testing

@Suite("DBThreadSafeWeakContainer Tests")
struct DBThreadSafeWeakContainerTests {
@Test("Explicit pthread rwlock selection reports pthread backend")
func explicitPThreadRWLockSelection() {
let container = DBThreadSafeWeakContainer<NSObject>(lock: .pthreadRWLock)

#expect(container.lockType == .pthreadRWLock)
}

#if canImport(Synchronization)
@available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *)
@Test("Explicit mutex selection reports mutex backend")
func explicitMutexSelection() {
let container = DBThreadSafeWeakContainer<NSObject>(lock: .mutex)

#expect(container.lockType == .mutex)
}
#endif

@Test("Default initializer prefers mutex backend when available")
func defaultInitializerPrefersMutexBackendWhenAvailable() {
let container = DBThreadSafeWeakContainer<NSObject>()

#if canImport(Synchronization)
if #available(iOS 18, macCatalyst 18, macOS 15, tvOS 18, watchOS 11, visionOS 2, *) {
#expect(container.lockType == .mutex)
} else {
#expect(container.lockType == .pthreadRWLock)
}
#else
#expect(container.lockType == .pthreadRWLock)
#endif
}

@Test("Init without value stores nil")
func initWithoutValueStoresNil() {
let container = DBThreadSafeWeakContainer<NSObject>()

#expect(container.withLock { $0 } == nil)
}

@Test("Init with value stores reference")
func initWithValueStoresReference() {
let object = NSObject()
let container = DBThreadSafeWeakContainer(object)

#expect(container.withLock { $0 } === object)
}

@Test("withLock can write and read value")
func withLockCanWriteAndReadValue() {
let container = DBThreadSafeWeakContainer<NSObject>()
let object = NSObject()

container.withLock { value in
value = object
}

#expect(container.withLock { $0 } === object)
}

@Test("withLock can clear value")
func withLockCanClearValue() {
let object = NSObject()
let container = DBThreadSafeWeakContainer(object)

container.withLock { value in
value = nil
}

#expect(container.withLock { $0 } == nil)
}

@Test("Value becomes nil when referenced object deallocates")
func valueBecomesNilWhenReferencedObjectDeallocates() {
let container = DBThreadSafeWeakContainer<NSObject>()

autoreleasepool {
let object = NSObject()
container.withLock { value in
value = object
}
#expect(container.withLock { $0 } != nil)
}

#expect(container.withLock { $0 } == nil)
}

@Test("withLock replaces value with another object")
func withLockReplacesValueWithAnotherObject() {
let first = NSObject()
let second = NSObject()
let container = DBThreadSafeWeakContainer(first)

container.withLock { value in
value = second
}

#expect(container.withLock { $0 } === second)
}

@Test("withLock updates stored value")
func withLockUpdatesStoredValue() {
let first = NSObject()
let second = NSObject()
let container = DBThreadSafeWeakContainer(first)

container.withLock { value in
value = second
}

#expect(container.withLock { $0 } === second)
}

@Test("withLock returns transformed value")
func withLockReturnsTransformedValue() {
let object = NSObject()
let container = DBThreadSafeWeakContainer(object)

let identity = container.withLock { value in
ObjectIdentifier(value!)
}

#expect(identity == ObjectIdentifier(object))
}

@Test("Class-bound protocol existentials are supported")
func classBoundProtocolExistentialsAreSupported() {
final class Delegate: TestDelegate {}

let delegate = Delegate()
let container = DBThreadSafeWeakContainer<any TestDelegate>()

container.withLock { value in
value = delegate
}

#expect((container.withLock { $0 } as AnyObject?) === delegate)
}
}

private protocol TestDelegate: AnyObject {}
Loading