From 8f9e8e9f288b42fec4066150cbff0e7b77750d4d Mon Sep 17 00:00:00 2001 From: Aleksey Berezka Date: Mon, 4 May 2026 20:27:11 +0500 Subject: [PATCH 1/3] feat: add thread-safe weak container Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 13 ++ .../DBThreadSafeWeakContainer.swift | 126 ++++++++++++++++++ .../DBThreadSafeWeakContainerTests.swift | 111 +++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 Sources/DBThreadSafe/DBThreadSafeWeakContainer.swift create mode 100644 Tests/DBThreadSafeTests/DBThreadSafeWeakContainerTests.swift diff --git a/README.md b/README.md index 433091a..e4213ce 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,19 @@ 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.value = loggerDelegate +``` + +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..fa1d77f --- /dev/null +++ b/Sources/DBThreadSafe/DBThreadSafeWeakContainer.swift @@ -0,0 +1,126 @@ +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 + } + } + + public var value: T? { + get { + storage.read() + } + set { + storage.write(newValue) + } + } +} + +protocol WeakLockStorage: AnyObject, Sendable { + associatedtype Value + + var lockType: DBThreadSafeLock { get } + + func read() -> Value? + func write(_ newValue: Value?) +} + +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 read() -> T? { + lock.readLock() + defer { lock.unlock() } + return object as? T + } + + func write(_ newValue: T?) { + lock.writeLock() + defer { lock.unlock() } + object = newValue as AnyObject? + } +} + +#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 read() -> T? { + mutex.withLock { _ in + object as? T + } + } + + func write(_ newValue: T?) { + mutex.withLock { _ in + object = newValue as AnyObject? + } + } +} +#endif diff --git a/Tests/DBThreadSafeTests/DBThreadSafeWeakContainerTests.swift b/Tests/DBThreadSafeTests/DBThreadSafeWeakContainerTests.swift new file mode 100644 index 0000000..7a90306 --- /dev/null +++ b/Tests/DBThreadSafeTests/DBThreadSafeWeakContainerTests.swift @@ -0,0 +1,111 @@ +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.value == nil) + } + + @Test("Init with value stores reference") + func initWithValueStoresReference() { + let object = NSObject() + let container = DBThreadSafeWeakContainer(object) + + #expect(container.value === object) + } + + @Test("Set and get value") + func setAndGetValue() { + let container = DBThreadSafeWeakContainer() + let object = NSObject() + + container.value = object + + #expect(container.value === object) + } + + @Test("Set nil clears value") + func setNilClearsValue() { + let object = NSObject() + let container = DBThreadSafeWeakContainer(object) + + container.value = nil + + #expect(container.value == nil) + } + + @Test("Value becomes nil when referenced object deallocates") + func valueBecomesNilWhenReferencedObjectDeallocates() { + let container = DBThreadSafeWeakContainer() + + autoreleasepool { + let object = NSObject() + container.value = object + #expect(container.value != nil) + } + + #expect(container.value == nil) + } + + @Test("Replace value with another object") + func replaceValueWithAnotherObject() { + let first = NSObject() + let second = NSObject() + let container = DBThreadSafeWeakContainer(first) + + container.value = second + + #expect(container.value === second) + } + + @Test("Class-bound protocol existentials are supported") + func classBoundProtocolExistentialsAreSupported() { + final class Delegate: TestDelegate {} + + let delegate = Delegate() + let container = DBThreadSafeWeakContainer() + + container.value = delegate + + #expect((container.value as AnyObject?) === delegate) + } +} + +private protocol TestDelegate: AnyObject {} From ee8dd5d0d925845a532c5217df23ba1fcb70ade5 Mon Sep 17 00:00:00 2001 From: Aleksey Berezka Date: Mon, 4 May 2026 20:30:27 +0500 Subject: [PATCH 2/3] refactor: mirror weak container API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 + .../DBThreadSafeWeakContainer.swift | 75 ++++++++++++++++++- .../DBThreadSafeWeakContainerTests.swift | 75 ++++++++++++++----- 3 files changed, 131 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e4213ce..20d3335 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ let delegate = DBThreadSafeWeakContainer() delegate.value = loggerDelegate ``` +`DBThreadSafeWeakContainer` mirrors the `DBThreadSafeContainer` access patterns with `read`, `write`, and `withLock`, but 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 diff --git a/Sources/DBThreadSafe/DBThreadSafeWeakContainer.swift b/Sources/DBThreadSafe/DBThreadSafeWeakContainer.swift index fa1d77f..b545f18 100644 --- a/Sources/DBThreadSafe/DBThreadSafeWeakContainer.swift +++ b/Sources/DBThreadSafe/DBThreadSafeWeakContainer.swift @@ -52,12 +52,42 @@ public final class DBThreadSafeWeakContainer: Sendable { } } + /// Reads the value stored. + /// - Returns: The weakly stored value, or `nil` if it was deallocated. + public func read() -> T? { + storage.read() + } + + public func read(_ closure: (_ value: T?) throws -> Void) rethrows { + try storage.read(closure) + } + + public func read(_ closure: (_ value: T?) throws -> U) rethrows -> U { + try storage.read(closure) + } + + /// 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) + } + + /// Replaces current weakly stored value with a new one. + /// - Parameter newValue: The new value to be stored in the container. + public func write(_ newValue: T?) { + storage.write(newValue) + } + + /// Returns current weakly stored value in a closure with possibility to replace it inside a single lock. + public func write(_ closure: (_ value: inout T?) throws -> Void) rethrows { + try storage.write(closure) + } + public var value: T? { get { - storage.read() + read() } set { - storage.write(newValue) + write(newValue) } } } @@ -68,7 +98,10 @@ protocol WeakLockStorage: AnyObject, Sendable { var lockType: DBThreadSafeLock { get } func read() -> Value? + func read(_ closure: (_ value: Value?) throws -> U) rethrows -> U func write(_ newValue: Value?) + func write(_ closure: (_ value: inout Value?) throws -> Void) rethrows + func withLock(_ closure: (_ value: inout Value?) throws -> U) rethrows -> U } final class PThreadRWLockWeakStorage: WeakLockStorage, @unchecked Sendable { @@ -89,11 +122,31 @@ final class PThreadRWLockWeakStorage: WeakLockStorage, @unchecked Sendable { return object as? T } + func read(_ closure: (_ value: T?) throws -> U) rethrows -> U { + lock.readLock() + defer { lock.unlock() } + return try closure(object as? T) + } + func write(_ newValue: T?) { lock.writeLock() defer { lock.unlock() } object = newValue as AnyObject? } + + func write(_ closure: (_ value: inout T?) throws -> Void) rethrows { + try withLock(closure) + } + + 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) @@ -117,10 +170,28 @@ final class MutexWeakStorage: WeakLockStorage, @unchecked Sendable { } } + func read(_ closure: (_ value: T?) throws -> U) rethrows -> U { + try mutex.withLock { _ in + try closure(object as? T) + } + } + func write(_ newValue: T?) { mutex.withLock { _ in object = newValue as AnyObject? } } + + func write(_ closure: (_ value: inout T?) throws -> Void) rethrows { + try withLock(closure) + } + + 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 index 7a90306..e1bd72a 100644 --- a/Tests/DBThreadSafeTests/DBThreadSafeWeakContainerTests.swift +++ b/Tests/DBThreadSafeTests/DBThreadSafeWeakContainerTests.swift @@ -40,7 +40,7 @@ struct DBThreadSafeWeakContainerTests { func initWithoutValueStoresNil() { let container = DBThreadSafeWeakContainer() - #expect(container.value == nil) + #expect(container.read() == nil) } @Test("Init with value stores reference") @@ -48,27 +48,27 @@ struct DBThreadSafeWeakContainerTests { let object = NSObject() let container = DBThreadSafeWeakContainer(object) - #expect(container.value === object) + #expect(container.read() === object) } - @Test("Set and get value") - func setAndGetValue() { + @Test("Write and read value") + func writeAndReadValue() { let container = DBThreadSafeWeakContainer() let object = NSObject() - container.value = object + container.write(object) - #expect(container.value === object) + #expect(container.read() === object) } - @Test("Set nil clears value") - func setNilClearsValue() { + @Test("Write nil clears value") + func writeNilClearsValue() { let object = NSObject() let container = DBThreadSafeWeakContainer(object) - container.value = nil + container.write(nil) - #expect(container.value == nil) + #expect(container.read() == nil) } @Test("Value becomes nil when referenced object deallocates") @@ -77,22 +77,59 @@ struct DBThreadSafeWeakContainerTests { autoreleasepool { let object = NSObject() - container.value = object - #expect(container.value != nil) + container.write(object) + #expect(container.read() != nil) } - #expect(container.value == nil) + #expect(container.read() == nil) } - @Test("Replace value with another object") - func replaceValueWithAnotherObject() { + @Test("Write closure replaces value with another object") + func writeClosureReplacesValueWithAnotherObject() { let first = NSObject() let second = NSObject() let container = DBThreadSafeWeakContainer(first) - container.value = second + container.write { value in + value = second + } + + #expect(container.read() === second) + } + + @Test("Read closure with return value") + func readClosureReturnValue() { + let object = NSObject() + let container = DBThreadSafeWeakContainer(object) + + let storedObject = container.read { $0 } + + #expect(storedObject === object) + } + + @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.read() === second) + } + + @Test("withLock returns transformed value") + func withLockReturnsTransformedValue() { + let object = NSObject() + let container = DBThreadSafeWeakContainer(object) + + let identity = container.withLock { value in + ObjectIdentifier(value!) + } - #expect(container.value === second) + #expect(identity == ObjectIdentifier(object)) } @Test("Class-bound protocol existentials are supported") @@ -102,9 +139,9 @@ struct DBThreadSafeWeakContainerTests { let delegate = Delegate() let container = DBThreadSafeWeakContainer() - container.value = delegate + container.write(delegate) - #expect((container.value as AnyObject?) === delegate) + #expect((container.read() as AnyObject?) === delegate) } } From bb3ad9031326ca235137396b4f26933d75921af9 Mon Sep 17 00:00:00 2001 From: Aleksey Berezka Date: Mon, 4 May 2026 20:32:32 +0500 Subject: [PATCH 3/3] refactor: keep weak container withLock-only Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 6 +- .../DBThreadSafeWeakContainer.swift | 83 ------------------- .../DBThreadSafeWeakContainerTests.swift | 58 +++++++------ 3 files changed, 32 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 20d3335..bb8f591 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,12 @@ Use `DBThreadSafeWeakContainer` when you need a weak reference that still keeps protocol LoggerDelegate: AnyObject {} let delegate = DBThreadSafeWeakContainer() -delegate.value = loggerDelegate +delegate.withLock { value in + value = loggerDelegate +} ``` -`DBThreadSafeWeakContainer` mirrors the `DBThreadSafeContainer` access patterns with `read`, `write`, and `withLock`, but its payload is optional because the weak reference can disappear at any time. +`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`. diff --git a/Sources/DBThreadSafe/DBThreadSafeWeakContainer.swift b/Sources/DBThreadSafe/DBThreadSafeWeakContainer.swift index b545f18..4dec809 100644 --- a/Sources/DBThreadSafe/DBThreadSafeWeakContainer.swift +++ b/Sources/DBThreadSafe/DBThreadSafeWeakContainer.swift @@ -52,55 +52,16 @@ public final class DBThreadSafeWeakContainer: Sendable { } } - /// Reads the value stored. - /// - Returns: The weakly stored value, or `nil` if it was deallocated. - public func read() -> T? { - storage.read() - } - - public func read(_ closure: (_ value: T?) throws -> Void) rethrows { - try storage.read(closure) - } - - public func read(_ closure: (_ value: T?) throws -> U) rethrows -> U { - try storage.read(closure) - } - /// 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) } - - /// Replaces current weakly stored value with a new one. - /// - Parameter newValue: The new value to be stored in the container. - public func write(_ newValue: T?) { - storage.write(newValue) - } - - /// Returns current weakly stored value in a closure with possibility to replace it inside a single lock. - public func write(_ closure: (_ value: inout T?) throws -> Void) rethrows { - try storage.write(closure) - } - - public var value: T? { - get { - read() - } - set { - write(newValue) - } - } } protocol WeakLockStorage: AnyObject, Sendable { associatedtype Value var lockType: DBThreadSafeLock { get } - - func read() -> Value? - func read(_ closure: (_ value: Value?) throws -> U) rethrows -> U - func write(_ newValue: Value?) - func write(_ closure: (_ value: inout Value?) throws -> Void) rethrows func withLock(_ closure: (_ value: inout Value?) throws -> U) rethrows -> U } @@ -116,28 +77,6 @@ final class PThreadRWLockWeakStorage: WeakLockStorage, @unchecked Sendable { self.object = value as AnyObject? } - func read() -> T? { - lock.readLock() - defer { lock.unlock() } - return object as? T - } - - func read(_ closure: (_ value: T?) throws -> U) rethrows -> U { - lock.readLock() - defer { lock.unlock() } - return try closure(object as? T) - } - - func write(_ newValue: T?) { - lock.writeLock() - defer { lock.unlock() } - object = newValue as AnyObject? - } - - func write(_ closure: (_ value: inout T?) throws -> Void) rethrows { - try withLock(closure) - } - func withLock(_ closure: (_ value: inout T?) throws -> U) rethrows -> U { lock.writeLock() var value = object as? T @@ -164,28 +103,6 @@ final class MutexWeakStorage: WeakLockStorage, @unchecked Sendable { self.mutex = Mutex(()) } - func read() -> T? { - mutex.withLock { _ in - object as? T - } - } - - func read(_ closure: (_ value: T?) throws -> U) rethrows -> U { - try mutex.withLock { _ in - try closure(object as? T) - } - } - - func write(_ newValue: T?) { - mutex.withLock { _ in - object = newValue as AnyObject? - } - } - - func write(_ closure: (_ value: inout T?) throws -> Void) rethrows { - try withLock(closure) - } - func withLock(_ closure: (_ value: inout T?) throws -> U) rethrows -> U { try mutex.withLock { _ in var value = object as? T diff --git a/Tests/DBThreadSafeTests/DBThreadSafeWeakContainerTests.swift b/Tests/DBThreadSafeTests/DBThreadSafeWeakContainerTests.swift index e1bd72a..03b4bfc 100644 --- a/Tests/DBThreadSafeTests/DBThreadSafeWeakContainerTests.swift +++ b/Tests/DBThreadSafeTests/DBThreadSafeWeakContainerTests.swift @@ -40,7 +40,7 @@ struct DBThreadSafeWeakContainerTests { func initWithoutValueStoresNil() { let container = DBThreadSafeWeakContainer() - #expect(container.read() == nil) + #expect(container.withLock { $0 } == nil) } @Test("Init with value stores reference") @@ -48,27 +48,31 @@ struct DBThreadSafeWeakContainerTests { let object = NSObject() let container = DBThreadSafeWeakContainer(object) - #expect(container.read() === object) + #expect(container.withLock { $0 } === object) } - @Test("Write and read value") - func writeAndReadValue() { + @Test("withLock can write and read value") + func withLockCanWriteAndReadValue() { let container = DBThreadSafeWeakContainer() let object = NSObject() - container.write(object) + container.withLock { value in + value = object + } - #expect(container.read() === object) + #expect(container.withLock { $0 } === object) } - @Test("Write nil clears value") - func writeNilClearsValue() { + @Test("withLock can clear value") + func withLockCanClearValue() { let object = NSObject() let container = DBThreadSafeWeakContainer(object) - container.write(nil) + container.withLock { value in + value = nil + } - #expect(container.read() == nil) + #expect(container.withLock { $0 } == nil) } @Test("Value becomes nil when referenced object deallocates") @@ -77,34 +81,26 @@ struct DBThreadSafeWeakContainerTests { autoreleasepool { let object = NSObject() - container.write(object) - #expect(container.read() != nil) + container.withLock { value in + value = object + } + #expect(container.withLock { $0 } != nil) } - #expect(container.read() == nil) + #expect(container.withLock { $0 } == nil) } - @Test("Write closure replaces value with another object") - func writeClosureReplacesValueWithAnotherObject() { + @Test("withLock replaces value with another object") + func withLockReplacesValueWithAnotherObject() { let first = NSObject() let second = NSObject() let container = DBThreadSafeWeakContainer(first) - container.write { value in + container.withLock { value in value = second } - #expect(container.read() === second) - } - - @Test("Read closure with return value") - func readClosureReturnValue() { - let object = NSObject() - let container = DBThreadSafeWeakContainer(object) - - let storedObject = container.read { $0 } - - #expect(storedObject === object) + #expect(container.withLock { $0 } === second) } @Test("withLock updates stored value") @@ -117,7 +113,7 @@ struct DBThreadSafeWeakContainerTests { value = second } - #expect(container.read() === second) + #expect(container.withLock { $0 } === second) } @Test("withLock returns transformed value") @@ -139,9 +135,11 @@ struct DBThreadSafeWeakContainerTests { let delegate = Delegate() let container = DBThreadSafeWeakContainer() - container.write(delegate) + container.withLock { value in + value = delegate + } - #expect((container.read() as AnyObject?) === delegate) + #expect((container.withLock { $0 } as AnyObject?) === delegate) } }