diff --git a/README.md b/README.md index 433091a..bb8f591 100644 --- a/README.md +++ b/README.md @@ -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() +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: diff --git a/Sources/DBThreadSafe/DBThreadSafeWeakContainer.swift b/Sources/DBThreadSafe/DBThreadSafeWeakContainer.swift new file mode 100644 index 0000000..4dec809 --- /dev/null +++ b/Sources/DBThreadSafe/DBThreadSafeWeakContainer.swift @@ -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()`. +public final class DBThreadSafeWeakContainer: Sendable { + private let storage: any WeakLockStorage + + /// 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 { + #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 { + 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(_ closure: (_ value: inout T?) throws -> U) rethrows -> U { + try storage.withLock(closure) + } +} + +protocol WeakLockStorage: AnyObject, Sendable { + associatedtype Value + + var lockType: DBThreadSafeLock { get } + func withLock(_ closure: (_ value: inout Value?) throws -> U) rethrows -> U +} + +final class PThreadRWLockWeakStorage: 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(_ 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: WeakLockStorage, @unchecked Sendable { + nonisolated(unsafe) private weak var object: AnyObject? + private let mutex: Mutex + + var lockType: DBThreadSafeLock { + .mutex + } + + init(_ value: T?) { + self.object = value as AnyObject? + self.mutex = Mutex(()) + } + + func withLock(_ 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 diff --git a/Tests/DBThreadSafeTests/DBThreadSafeWeakContainerTests.swift b/Tests/DBThreadSafeTests/DBThreadSafeWeakContainerTests.swift new file mode 100644 index 0000000..03b4bfc --- /dev/null +++ b/Tests/DBThreadSafeTests/DBThreadSafeWeakContainerTests.swift @@ -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(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(lock: .mutex) + + #expect(container.lockType == .mutex) + } +#endif + + @Test("Default initializer prefers mutex backend when available") + func defaultInitializerPrefersMutexBackendWhenAvailable() { + let container = DBThreadSafeWeakContainer() + + #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() + + #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() + 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() + + 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() + + container.withLock { value in + value = delegate + } + + #expect((container.withLock { $0 } as AnyObject?) === delegate) + } +} + +private protocol TestDelegate: AnyObject {}