From 6d3b8e80f056d158f487567091715a20d5da615d Mon Sep 17 00:00:00 2001 From: ulkhan-amiraslanov Date: Tue, 19 May 2026 14:49:53 +0400 Subject: [PATCH 1/8] Migrate to Swift 6.2 strict concurrency Brings the library up to Swift 6 strict concurrency. The on-disk format, public API surface, and observation semantics are unchanged. Consumers that previously imported with `@preconcurrency` can drop that workaround. Changes per file: - Package.swift: bump swift-tools-version to 6.2; enable Swift 6 language mode and the concurrency upcoming features (GlobalActorIsolatedTypesUsability, InferIsolatedConformances, InferSendableFromCaptures, NonisolatedNonsendingByDefault, ExistentialAny); bump min platforms to macOS 10.15 / iOS 13 / tvOS 13 / watchOS 6 (matching what Swift 6 supports); exclude the legacy Info.plist from the SPM target. - UncheckedSendable.swift (new): the one explicit @unchecked Sendable boundary, used to hold the non-Sendable UserDefaults reference under documented thread-safety. - DefaultsKeys.swift: protocol DefaultsKeyStore: Sendable, struct DefaultsKeys: Sendable. - DefaultsKey.swift: struct now @unchecked Sendable. The stored fields are immutable; the unchecked annotation is needed because ValueType.T is an unconstrained associated type that may not be Sendable. - DefaultsAdapter.swift: struct now Sendable. The `defaults` field is held via UncheckedSendable internally; the public `var defaults: UserDefaults` accessor stays for backward compatibility. - DefaultsBridges.swift: protocol DefaultsBridge: Sendable. Concrete primitive bridges (String, Int, Double, Bool, Data, URL) are plain Sendable. Bridges that erase an unconstrained generic (Object, Array, Codable, KeyedArchiver, RawRepresentable, Optional variants) declare @unchecked Sendable since the generic could resolve to a non-Sendable type; in practice these structs hold no stored state, so the annotation is the boundary, not a real escape hatch. - DefaultsObserver.swift: protocol DefaultsDisposable: Sendable. The observer class is now @unchecked Sendable with an NSLock guarding the didRemoveObserver lifecycle flag. The handler is typed @Sendable. Renamed an inner-generic shadow (T -> U inside Update.deserialize) that Swift 6 strict mode rejects. - Defaults+Observing.swift: observer handlers typed `@escaping @Sendable` to match the now-Sendable observer interface. - Defaults.swift: the global `var Defaults` is now `nonisolated(unsafe) var Defaults`. Preserves the override-at-startup API while making the mutation explicit under strict concurrency. - PropertyWrappers.swift: SwiftyUserDefaultOptions: Sendable. The SwiftyUserDefault class is @unchecked Sendable with an NSLock guarding the optional `_value` cache and the observation reference. Foundation import added (NSLock). DefaultsDisposable typed as `(any DefaultsDisposable)?` per the ExistentialAny upcoming feature. Build: swift build clean. Tests not yet run against this branch but test target's swiftSettings now match the source target so a follow-up `TEST=1 swift test` should surface any test-only issues. --- Package.swift | 33 ++++++-- Sources/Defaults+Observing.swift | 40 +++++----- Sources/Defaults.swift | 13 +++- Sources/DefaultsAdapter.swift | 13 +++- Sources/DefaultsBridges.swift | 65 +++++++--------- Sources/DefaultsKey.swift | 27 ++++--- Sources/DefaultsKeys.swift | 4 +- Sources/DefaultsObserver.swift | 129 +++++++++++++++++-------------- Sources/PropertyWrappers.swift | 103 +++++++++++++----------- Sources/UncheckedSendable.swift | 38 +++++++++ 10 files changed, 274 insertions(+), 191 deletions(-) create mode 100644 Sources/UncheckedSendable.swift diff --git a/Package.swift b/Package.swift index ec1ca4fe..2fd4e538 100644 --- a/Package.swift +++ b/Package.swift @@ -1,8 +1,9 @@ -// swift-tools-version:5.0 +// swift-tools-version:6.2 +// swiftlint:disable prefixed_toplevel_constant // The swift-tools-version declares the minimum version of Swift required to build this package. -import PackageDescription import class Foundation.ProcessInfo +import PackageDescription let shouldTest = ProcessInfo.processInfo.environment["TEST"] == "1" @@ -15,22 +16,40 @@ func resolveDependencies() -> [Package.Dependency] { ] } +let swiftSettings: [SwiftSetting] = [ + .enableUpcomingFeature("GlobalActorIsolatedTypesUsability"), + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("InferSendableFromCaptures"), + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("ExistentialAny"), +] + func resolveTargets() -> [Target] { - let baseTarget = Target.target(name: "SwiftyUserDefaults", dependencies: [], path: "Sources") - let testTarget = Target.testTarget(name: "SwiftyUserDefaultsTests", dependencies: ["SwiftyUserDefaults", "Quick", "Nimble"]) + let baseTarget = Target.target( + name: "SwiftyUserDefaults", + dependencies: [], + path: "Sources", + exclude: ["Info.plist"], + swiftSettings: swiftSettings + ) + let testTarget = Target.testTarget( + name: "SwiftyUserDefaultsTests", + dependencies: ["SwiftyUserDefaults", "Quick", "Nimble"], + swiftSettings: swiftSettings + ) return shouldTest ? [baseTarget, testTarget] : [baseTarget] } - let package = Package( name: "SwiftyUserDefaults", platforms: [ - .macOS(.v10_11), .iOS(.v9), .tvOS(.v9), .watchOS(.v2) + .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), ], products: [ .library(name: "SwiftyUserDefaults", targets: ["SwiftyUserDefaults"]), ], dependencies: resolveDependencies(), - targets: resolveTargets() + targets: resolveTargets(), + swiftLanguageModes: [.v6] ) diff --git a/Sources/Defaults+Observing.swift b/Sources/Defaults+Observing.swift index ab7349bb..155a62e7 100644 --- a/Sources/Defaults+Observing.swift +++ b/Sources/Defaults+Observing.swift @@ -26,28 +26,28 @@ import Foundation #if !os(Linux) -public extension DefaultsAdapter { - - func observe(_ key: DefaultsKey, - options: NSKeyValueObservingOptions = [.new, .old], - handler: @escaping (DefaultsObserver.Update) -> Void) -> DefaultsDisposable { - return defaults.observe(key, options: options, handler: handler) - } - - func observe(_ keyPath: KeyPath>, - options: NSKeyValueObservingOptions = [.old, .new], - handler: @escaping (DefaultsObserver.Update) -> Void) -> DefaultsDisposable { - return defaults.observe(keyStore[keyPath: keyPath], - options: options, - handler: handler) + public extension DefaultsAdapter { + func observe(_ key: DefaultsKey, + options: NSKeyValueObservingOptions = [.new, .old], + handler: @escaping @Sendable (DefaultsObserver.Update) -> Void) -> DefaultsDisposable + { + return defaults.observe(key, options: options, handler: handler) + } + + func observe(_ keyPath: KeyPath>, + options: NSKeyValueObservingOptions = [.old, .new], + handler: @escaping @Sendable (DefaultsObserver.Update) -> Void) -> DefaultsDisposable + { + return defaults.observe(keyStore[keyPath: keyPath], + options: options, + handler: handler) + } } -} - -public extension UserDefaults { - func observe(_ key: DefaultsKey, options: NSKeyValueObservingOptions = [.old, .new], handler: @escaping (DefaultsObserver.Update) -> Void) -> DefaultsDisposable { - return DefaultsObserver(key: key, userDefaults: self, options: options, handler: handler) + public extension UserDefaults { + func observe(_ key: DefaultsKey, options: NSKeyValueObservingOptions = [.old, .new], handler: @escaping @Sendable (DefaultsObserver.Update) -> Void) -> DefaultsDisposable { + return DefaultsObserver(key: key, userDefaults: self, options: options, handler: handler) + } } -} #endif diff --git a/Sources/Defaults.swift b/Sources/Defaults.swift index 25ac25ce..01536136 100644 --- a/Sources/Defaults.swift +++ b/Sources/Defaults.swift @@ -32,10 +32,16 @@ import Foundation /// var Defaults = DefaultsAdapter(defaults: UserDefaults(suiteName: "com.my.app")!, keyStore: DefaultsKeys()) /// ~~~ -public var Defaults = DefaultsAdapter(defaults: .standard, keyStore: .init()) +// swiftlint:disable identifier_name prefixed_toplevel_constant +/// Mutable global so callers can swap in their own `UserDefaults(suiteName:)` +/// at app startup. `nonisolated(unsafe)` makes the mutation explicit under +/// Swift 6 strict concurrency; the value type (`DefaultsAdapter`) is itself +/// `Sendable`, so the only risk is the initial assignment race, which has +/// always been the caller's responsibility to do at startup before any reads. +public nonisolated(unsafe) var Defaults = DefaultsAdapter(defaults: .standard, keyStore: .init()) +// swiftlint:enable identifier_name prefixed_toplevel_constant public extension UserDefaults { - /// Returns `true` if `key` exists func hasKey(_ key: DefaultsKey) -> Bool { return object(forKey: key._key) != nil @@ -57,8 +63,7 @@ public extension UserDefaults { } } -internal extension UserDefaults { - +extension UserDefaults { func number(forKey key: String) -> NSNumber? { return object(forKey: key) as? NSNumber } diff --git a/Sources/DefaultsAdapter.swift b/Sources/DefaultsAdapter.swift index 98994bf5..1cc93fa4 100644 --- a/Sources/DefaultsAdapter.swift +++ b/Sources/DefaultsAdapter.swift @@ -41,18 +41,23 @@ import Foundation /// Defaults.launchCount += 1 /// ``` @dynamicMemberLookup -public struct DefaultsAdapter { +public struct DefaultsAdapter: Sendable { + private let defaultsBox: UncheckedSendable + + /// The wrapped `UserDefaults` instance. Held via `UncheckedSendable` because + /// Foundation does not expose `UserDefaults` as `Sendable`, even though + /// Apple documents the type as thread-safe. + public var defaults: UserDefaults { defaultsBox.wrappedValue } - public let defaults: UserDefaults public let keyStore: KeyStore public init(defaults: UserDefaults, keyStore: KeyStore) { - self.defaults = defaults + self.defaultsBox = UncheckedSendable(defaults) self.keyStore = keyStore } @available(*, unavailable) - public subscript(dynamicMember member: String) -> Never { + public subscript(dynamicMember _: String) -> Never { fatalError() } diff --git a/Sources/DefaultsBridges.swift b/Sources/DefaultsBridges.swift index 32615a60..18fc643c 100644 --- a/Sources/DefaultsBridges.swift +++ b/Sources/DefaultsBridges.swift @@ -24,8 +24,7 @@ import Foundation -public protocol DefaultsBridge { - +public protocol DefaultsBridge: Sendable { associatedtype T /// This method provides a way of saving your data in UserDefaults. Usually needed @@ -48,8 +47,7 @@ public protocol DefaultsBridge { func deserialize(_ object: Any) -> T? } -public struct DefaultsObjectBridge: DefaultsBridge { - +public struct DefaultsObjectBridge: DefaultsBridge, @unchecked Sendable { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -60,13 +58,12 @@ public struct DefaultsObjectBridge: DefaultsBridge { return userDefaults.object(forKey: key) as? T } - public func deserialize(_ object: Any) -> T? { + public func deserialize(_: Any) -> T? { return nil } } -public struct DefaultsArrayBridge: DefaultsBridge { - +public struct DefaultsArrayBridge: DefaultsBridge, @unchecked Sendable { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -77,13 +74,12 @@ public struct DefaultsArrayBridge: DefaultsBridge { return userDefaults.array(forKey: key) as? T } - public func deserialize(_ object: Any) -> T? { + public func deserialize(_: Any) -> T? { return nil } } -public struct DefaultsStringBridge: DefaultsBridge { - +public struct DefaultsStringBridge: DefaultsBridge, Sendable { public init() {} public func save(key: String, value: String?, userDefaults: UserDefaults) { @@ -94,13 +90,12 @@ public struct DefaultsStringBridge: DefaultsBridge { return userDefaults.string(forKey: key) } - public func deserialize(_ object: Any) -> String? { + public func deserialize(_: Any) -> String? { return nil } } -public struct DefaultsIntBridge: DefaultsBridge { - +public struct DefaultsIntBridge: DefaultsBridge, Sendable { public init() {} public func save(key: String, value: Int?, userDefaults: UserDefaults) { @@ -114,20 +109,20 @@ public struct DefaultsIntBridge: DefaultsBridge { // Fallback for launch arguments if let string = userDefaults.object(forKey: key) as? String, - let int = Int(string) { + let int = Int(string) + { return int } return nil } - public func deserialize(_ object: Any) -> Int? { + public func deserialize(_: Any) -> Int? { return nil } } -public struct DefaultsDoubleBridge: DefaultsBridge { - +public struct DefaultsDoubleBridge: DefaultsBridge, Sendable { public init() {} public func save(key: String, value: Double?, userDefaults: UserDefaults) { @@ -141,20 +136,20 @@ public struct DefaultsDoubleBridge: DefaultsBridge { // Fallback for launch arguments if let string = userDefaults.object(forKey: key) as? String, - let double = Double(string) { + let double = Double(string) + { return double } return nil } - public func deserialize(_ object: Any) -> Double? { + public func deserialize(_: Any) -> Double? { return nil } } -public struct DefaultsBoolBridge: DefaultsBridge { - +public struct DefaultsBoolBridge: DefaultsBridge, Sendable { public init() {} public func save(key: String, value: Bool?, userDefaults: UserDefaults) { @@ -175,13 +170,12 @@ public struct DefaultsBoolBridge: DefaultsBridge { return (userDefaults.object(forKey: key) as? String)?.bool } - public func deserialize(_ object: Any) -> Bool? { + public func deserialize(_: Any) -> Bool? { return nil } } -public struct DefaultsDataBridge: DefaultsBridge { - +public struct DefaultsDataBridge: DefaultsBridge, Sendable { public init() {} public func save(key: String, value: Data?, userDefaults: UserDefaults) { @@ -192,13 +186,12 @@ public struct DefaultsDataBridge: DefaultsBridge { return userDefaults.data(forKey: key) } - public func deserialize(_ object: Any) -> Data? { + public func deserialize(_: Any) -> Data? { return nil } } -public struct DefaultsUrlBridge: DefaultsBridge { - +public struct DefaultsUrlBridge: DefaultsBridge, Sendable { public init() {} public func save(key: String, value: URL?, userDefaults: UserDefaults) { @@ -227,8 +220,7 @@ public struct DefaultsUrlBridge: DefaultsBridge { } } -public struct DefaultsCodableBridge: DefaultsBridge { - +public struct DefaultsCodableBridge: DefaultsBridge, @unchecked Sendable { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -253,8 +245,7 @@ public struct DefaultsCodableBridge: DefaultsBridge { } } -public struct DefaultsKeyedArchiverBridge: DefaultsBridge { - +public struct DefaultsKeyedArchiverBridge: DefaultsBridge, @unchecked Sendable { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -283,8 +274,7 @@ public struct DefaultsKeyedArchiverBridge: DefaultsBridge { } } -public struct DefaultsRawRepresentableBridge: DefaultsBridge { - +public struct DefaultsRawRepresentableBridge: DefaultsBridge, @unchecked Sendable { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -302,8 +292,7 @@ public struct DefaultsRawRepresentableBridge: DefaultsBridg } } -public struct DefaultsRawRepresentableArrayBridge: DefaultsBridge where T.Element: RawRepresentable { - +public struct DefaultsRawRepresentableArrayBridge: DefaultsBridge, @unchecked Sendable where T.Element: RawRepresentable { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -322,8 +311,7 @@ public struct DefaultsRawRepresentableArrayBridge: DefaultsBridge } } -public struct DefaultsOptionalBridge: DefaultsBridge { - +public struct DefaultsOptionalBridge: DefaultsBridge, @unchecked Sendable { public typealias T = Bridge.T? private let bridge: Bridge @@ -345,8 +333,7 @@ public struct DefaultsOptionalBridge: DefaultsBridge { } } -public struct DefaultsOptionalArrayBridge: DefaultsBridge where Bridge.T: Collection { - +public struct DefaultsOptionalArrayBridge: DefaultsBridge, @unchecked Sendable where Bridge.T: Collection { public typealias T = Bridge.T? private let bridge: Bridge diff --git a/Sources/DefaultsKey.swift b/Sources/DefaultsKey.swift index 80b89be2..bf1cb799 100644 --- a/Sources/DefaultsKey.swift +++ b/Sources/DefaultsKey.swift @@ -28,41 +28,44 @@ import Foundation /// Specialize with value type /// and pass key name to the initializer to create a key. -public struct DefaultsKey { - +/// +/// Marked `@unchecked Sendable` because `ValueType.T` is an unconstrained +/// associated type that could resolve to a non-`Sendable` value. The stored +/// fields (`_key`, `defaultValue`, `isOptional`) are themselves immutable or +/// trivially `Sendable`, so the cost is the boundary annotation only. +public struct DefaultsKey: @unchecked Sendable { public let _key: String public let defaultValue: ValueType.T? - internal var isOptional: Bool + var isOptional: Bool public init(_ key: String, defaultValue: ValueType.T) { - self._key = key + _key = key self.defaultValue = defaultValue - self.isOptional = false + isOptional = false } // Couldn't figure out a way of how to pass a nil/none value from extension, thus this initializer. // Used for creating an optional key (without defaultValue) private init(key: String) { - self._key = key - self.defaultValue = nil - self.isOptional = true + _key = key + defaultValue = nil + isOptional = true } @available(*, unavailable, message: "This key needs a `defaultValue` parameter. If this type does not have a default value, consider using an optional key.") - public init(_ key: String) { + public init(_: String) { fatalError() } } public extension DefaultsKey where ValueType: DefaultsSerializable, ValueType: OptionalType, ValueType.Wrapped: DefaultsSerializable { - init(_ key: String) { self.init(key: key) } init(_ key: String, defaultValue: ValueType.T) { - self._key = key + _key = key self.defaultValue = defaultValue - self.isOptional = true + isOptional = true } } diff --git a/Sources/DefaultsKeys.swift b/Sources/DefaultsKeys.swift index b8c228eb..2ae2ade8 100644 --- a/Sources/DefaultsKeys.swift +++ b/Sources/DefaultsKeys.swift @@ -24,8 +24,8 @@ import Foundation -public protocol DefaultsKeyStore {} +public protocol DefaultsKeyStore: Sendable {} -public struct DefaultsKeys: DefaultsKeyStore { +public struct DefaultsKeys: DefaultsKeyStore, Sendable { public init() {} } diff --git a/Sources/DefaultsObserver.swift b/Sources/DefaultsObserver.swift index 80a2d1e5..f3b116cb 100644 --- a/Sources/DefaultsObserver.swift +++ b/Sources/DefaultsObserver.swift @@ -24,84 +24,93 @@ import Foundation -public protocol DefaultsDisposable { +public protocol DefaultsDisposable: Sendable { func dispose() } #if !os(Linux) -public final class DefaultsObserver: NSObject, DefaultsDisposable where T == T.T { - - public struct Update { - public let kind: NSKeyValueChange - public let indexes: IndexSet? - public let isPrior: Bool - public let newValue: T.T? - public let oldValue: T.T? - - init(dict: [NSKeyValueChangeKey: Any], key: DefaultsKey) { - // swiftlint:disable:next force_cast - kind = NSKeyValueChange(rawValue: dict[.kindKey] as! UInt)! - indexes = dict[.indexesKey] as? IndexSet - isPrior = dict[.notificationIsPriorKey] as? Bool ?? false - oldValue = Update.deserialize(dict[.oldKey], for: key) ?? key.defaultValue - newValue = Update.deserialize(dict[.newKey], for: key) ?? key.defaultValue - } + /// Observes KVO changes on a `UserDefaults` key. Marked `@unchecked Sendable` + /// because it stores `didRemoveObserver` mutable state guarded by `lock` and + /// dispatches the user-provided `handler` on whichever thread KVO fires it on. + /// The handler closure is `@Sendable` so it can cross isolation boundaries. + public final class DefaultsObserver: NSObject, DefaultsDisposable, @unchecked Sendable where T == T.T { + public struct Update { + public let kind: NSKeyValueChange + public let indexes: IndexSet? + public let isPrior: Bool + public let newValue: T.T? + public let oldValue: T.T? + + init(dict: [NSKeyValueChangeKey: Any], key: DefaultsKey) { + // swiftlint:disable:next force_cast + kind = NSKeyValueChange(rawValue: dict[.kindKey] as! UInt)! + indexes = dict[.indexesKey] as? IndexSet + isPrior = dict[.notificationIsPriorKey] as? Bool ?? false + oldValue = Update.deserialize(dict[.oldKey], for: key) ?? key.defaultValue + newValue = Update.deserialize(dict[.newKey], for: key) ?? key.defaultValue + } - private static func deserialize(_ value: Any?, for key: DefaultsKey) -> T.T? where T.T == T { - guard let value = value else { return nil } + private static func deserialize(_ value: Any?, for key: DefaultsKey) -> U.T? where U.T == U { + guard let value = value else { return nil } - let deserialized = T._defaults.deserialize(value) + let deserialized = U._defaults.deserialize(value) - let ret: T.T? - if key.isOptional, let _deserialized = deserialized, let __deserialized = _deserialized as? OptionalTypeCheck, !__deserialized.isNil { - ret = __deserialized as? T.T - } else if !key.isOptional { - ret = deserialized ?? value as? T.T - } else { - ret = value as? T.T - } + let ret: U.T? + if key.isOptional, let _deserialized = deserialized, let __deserialized = _deserialized as? OptionalTypeCheck, !__deserialized.isNil { + ret = __deserialized as? U.T + } else if !key.isOptional { + ret = deserialized ?? value as? U.T + } else { + ret = value as? U.T + } - return ret + return ret + } } - } - - private let key: DefaultsKey - private let userDefaults: UserDefaults - private let handler: ((Update) -> Void) - private var didRemoveObserver = false - init(key: DefaultsKey, userDefaults: UserDefaults, options: NSKeyValueObservingOptions, handler: @escaping ((Update) -> Void)) { - self.key = key - self.userDefaults = userDefaults - self.handler = handler - super.init() + private let key: DefaultsKey + private let userDefaults: UserDefaults + private let handler: @Sendable (Update) -> Void + private let lock = NSLock() + private var didRemoveObserver = false - userDefaults.addObserver(self, forKeyPath: key._key, options: options, context: nil) - } + init(key: DefaultsKey, userDefaults: UserDefaults, options: NSKeyValueObservingOptions, handler: @escaping @Sendable (Update) -> Void) { + self.key = key + self.userDefaults = userDefaults + self.handler = handler + super.init() - deinit { - dispose() - } + userDefaults.addObserver(self, forKeyPath: key._key, options: options, context: nil) + } - // swiftlint:disable:next block_based_kvo - public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { - guard let change = change, object != nil, keyPath == key._key else { - return + deinit { + dispose() } - let update = Update(dict: change, key: key) - handler(update) - } + // swiftlint:disable:next block_based_kvo + override public func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context _: UnsafeMutableRawPointer?) { + guard let change = change, object != nil, keyPath == key._key else { + return + } + + let update = Update(dict: change, key: key) + handler(update) + } - public func dispose() { - // We use this local property because when you use `removeObserver` when you are - // not actually observing anymore, you'll receive a runtime error. - if didRemoveObserver { return } + public func dispose() { + // We use this local property because when you use `removeObserver` when you are + // not actually observing anymore, you'll receive a runtime error. + lock.lock() + if didRemoveObserver { + lock.unlock() + return + } + didRemoveObserver = true + lock.unlock() - didRemoveObserver = true - userDefaults.removeObserver(self, forKeyPath: key._key, context: nil) + userDefaults.removeObserver(self, forKeyPath: key._key, context: nil) + } } -} #endif diff --git a/Sources/PropertyWrappers.swift b/Sources/PropertyWrappers.swift index 585038f1..5a609f8d 100644 --- a/Sources/PropertyWrappers.swift +++ b/Sources/PropertyWrappers.swift @@ -22,66 +22,83 @@ // SOFTWARE. // -#if swift(>=5.1) -public struct SwiftyUserDefaultOptions: OptionSet { +import Foundation - public static let cached = SwiftyUserDefaultOptions(rawValue: 1 << 0) - public static let observed = SwiftyUserDefaultOptions(rawValue: 1 << 2) +#if swift(>=5.1) + public struct SwiftyUserDefaultOptions: OptionSet, Sendable { + public static let cached = SwiftyUserDefaultOptions(rawValue: 1 << 0) + public static let observed = SwiftyUserDefaultOptions(rawValue: 1 << 2) - public let rawValue: Int + public let rawValue: Int - public init(rawValue: Int) { - self.rawValue = rawValue + public init(rawValue: Int) { + self.rawValue = rawValue + } } -} -@propertyWrapper -public final class SwiftyUserDefault where T.T == T { + /// Property wrapper for `Defaults`-backed values. Marked `@unchecked Sendable` + /// because it carries a mutable `_value` cache and `observation` reference + /// guarded by `lock`. The wrapped getter/setter reads and writes through the + /// global `Defaults` adapter or one passed at init, both of which delegate to + /// `UserDefaults` (documented thread-safe). + @propertyWrapper + public final class SwiftyUserDefault: @unchecked Sendable where T.T == T { + public let key: DefaultsKey + public let options: SwiftyUserDefaultOptions - public let key: DefaultsKey - public let options: SwiftyUserDefaultOptions - - public var wrappedValue: T { - get { - if options.contains(.cached) { - return _value ?? Defaults[key: key] - } else { - return Defaults[key: key] + public var wrappedValue: T { + get { + if options.contains(.cached) { + lock.lock() + let cached = _value + lock.unlock() + return cached ?? Defaults[key: key] + } else { + return Defaults[key: key] + } + } + set { + lock.lock() + _value = newValue + lock.unlock() + Defaults[key: key] = newValue } } - set { - _value = newValue - Defaults[key: key] = newValue - } - } - private var _value: T.T? - private var observation: DefaultsDisposable? + private let lock = NSLock() + private var _value: T.T? + private var observation: (any DefaultsDisposable)? - public init(keyPath: KeyPath>, adapter: DefaultsAdapter, options: SwiftyUserDefaultOptions = []) { - self.key = adapter.keyStore[keyPath: keyPath] - self.options = options + public init(keyPath: KeyPath>, adapter: DefaultsAdapter, options: SwiftyUserDefaultOptions = []) { + key = adapter.keyStore[keyPath: keyPath] + self.options = options - if options.contains(.observed) { - observation = adapter.observe(key) { [weak self] update in - self?._value = update.newValue + if options.contains(.observed) { + observation = adapter.observe(key) { [weak self] update in + guard let self else { return } + self.lock.lock() + self._value = update.newValue + self.lock.unlock() + } } } - } - public init(keyPath: KeyPath>, options: SwiftyUserDefaultOptions = []) { - self.key = Defaults.keyStore[keyPath: keyPath] - self.options = options + public init(keyPath: KeyPath>, options: SwiftyUserDefaultOptions = []) { + key = Defaults.keyStore[keyPath: keyPath] + self.options = options - if options.contains(.observed) { - observation = Defaults.observe(key) { [weak self] update in - self?._value = update.newValue + if options.contains(.observed) { + observation = Defaults.observe(key) { [weak self] update in + guard let self else { return } + self.lock.lock() + self._value = update.newValue + self.lock.unlock() + } } } - } - deinit { - observation?.dispose() + deinit { + observation?.dispose() + } } -} #endif diff --git a/Sources/UncheckedSendable.swift b/Sources/UncheckedSendable.swift new file mode 100644 index 00000000..981f4604 --- /dev/null +++ b/Sources/UncheckedSendable.swift @@ -0,0 +1,38 @@ +// +// SwiftyUserDefaults +// +// Copyright (c) 2015-present Radosław Pietruszewski, Łukasz Mróz +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +/// Tiny helper that lets a non-`Sendable` value cross isolation boundaries +/// under explicit programmer responsibility. Used at the single boundary where +/// the library has to hold a `UserDefaults` instance, which Apple documents as +/// thread-safe but does not mark `Sendable`. +@frozen +public struct UncheckedSendable: @unchecked Sendable { + public var wrappedValue: Value + + public init(_ wrappedValue: Value) { + self.wrappedValue = wrappedValue + } +} From a7903b0dc1f06e81ca8a026c894a71ecd7b44c06 Mon Sep 17 00:00:00 2001 From: ulkhan-amiraslanov Date: Tue, 19 May 2026 17:46:11 +0400 Subject: [PATCH 2/8] Make isOptional and UncheckedSendable.wrappedValue immutable Both fields are only assigned during initialization. Changing them to `let` reinforces immutability under `@unchecked Sendable` and prevents accidental mutation across isolation boundaries. --- Sources/DefaultsKey.swift | 2 +- Sources/UncheckedSendable.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/DefaultsKey.swift b/Sources/DefaultsKey.swift index bf1cb799..70ac375d 100644 --- a/Sources/DefaultsKey.swift +++ b/Sources/DefaultsKey.swift @@ -36,7 +36,7 @@ import Foundation public struct DefaultsKey: @unchecked Sendable { public let _key: String public let defaultValue: ValueType.T? - var isOptional: Bool + let isOptional: Bool public init(_ key: String, defaultValue: ValueType.T) { _key = key diff --git a/Sources/UncheckedSendable.swift b/Sources/UncheckedSendable.swift index 981f4604..fc1173d6 100644 --- a/Sources/UncheckedSendable.swift +++ b/Sources/UncheckedSendable.swift @@ -30,7 +30,7 @@ import Foundation /// thread-safe but does not mark `Sendable`. @frozen public struct UncheckedSendable: @unchecked Sendable { - public var wrappedValue: Value + public let wrappedValue: Value public init(_ wrappedValue: Value) { self.wrappedValue = wrappedValue From e358b66768fade48563e9bb0764c37fffda72eb7 Mon Sep 17 00:00:00 2001 From: ulkhan-amiraslanov Date: Wed, 20 May 2026 14:33:51 +0400 Subject: [PATCH 3/8] Address review feedback on the strict-concurrency migration - Replace blanket @unchecked Sendable with conditional Sendable on DefaultsKey and the seven generic bridges. The structs are stateless (or all-let), so they conform to Sendable precisely when their generic parameter does. Consumers using non-Sendable T now get a compile-time check instead of a silent unchecked promise. - Extend the DefaultsObserver.dispose() lock to cover removeObserver so a deinit-driven dispose racing with an explicit dispose cannot both reach removeObserver. - Make Update conditionally Sendable based on T.T. - Make UncheckedSendable internal. It is only used inside DefaultsAdapter and is not part of the library's public surface. - Document on observe(_:options:handler:) that the handler may be invoked on any thread (KVO posts on the writer's thread). - Document on .cached that it should be paired with .observed to avoid a stale cache after out-of-band writes. - Consolidate the Defaults global doc comment so it stays attached to the declaration. --- Sources/Defaults+Observing.swift | 12 ++++++++++++ Sources/Defaults.swift | 23 +++++++++++------------ Sources/DefaultsBridges.swift | 32 ++++++++++++++++++++++++-------- Sources/DefaultsKey.swift | 11 ++++++----- Sources/DefaultsObserver.swift | 18 ++++++++++++------ Sources/PropertyWrappers.swift | 8 ++++++++ Sources/UncheckedSendable.swift | 15 +++++++-------- 7 files changed, 80 insertions(+), 39 deletions(-) diff --git a/Sources/Defaults+Observing.swift b/Sources/Defaults+Observing.swift index 155a62e7..ed4fcbaf 100644 --- a/Sources/Defaults+Observing.swift +++ b/Sources/Defaults+Observing.swift @@ -27,6 +27,12 @@ import Foundation #if !os(Linux) public extension DefaultsAdapter { + /// Observe changes on a `UserDefaults` key. + /// + /// The `handler` is invoked on whichever thread posts the KVO notification + /// (typically the writer's thread). It is not guaranteed to run on the + /// main thread. Dispatch onto your own actor or queue inside the handler + /// if you need a specific isolation. func observe(_ key: DefaultsKey, options: NSKeyValueObservingOptions = [.new, .old], handler: @escaping @Sendable (DefaultsObserver.Update) -> Void) -> DefaultsDisposable @@ -34,6 +40,12 @@ import Foundation return defaults.observe(key, options: options, handler: handler) } + /// Observe changes on a `UserDefaults` key path. + /// + /// The `handler` is invoked on whichever thread posts the KVO notification + /// (typically the writer's thread). It is not guaranteed to run on the + /// main thread. Dispatch onto your own actor or queue inside the handler + /// if you need a specific isolation. func observe(_ keyPath: KeyPath>, options: NSKeyValueObservingOptions = [.old, .new], handler: @escaping @Sendable (DefaultsObserver.Update) -> Void) -> DefaultsDisposable diff --git a/Sources/Defaults.swift b/Sources/Defaults.swift index 01536136..37ab0969 100644 --- a/Sources/Defaults.swift +++ b/Sources/Defaults.swift @@ -24,20 +24,19 @@ import Foundation -/// Global shortcut for `UserDefaults.standard` +// swiftlint:disable identifier_name prefixed_toplevel_constant +/// Global shortcut for `UserDefaults.standard`. /// /// **Pro-Tip:** If you want to use shared user defaults, just -/// redefine this global shortcut in your app target, like so: -/// ~~~ -/// var Defaults = DefaultsAdapter(defaults: UserDefaults(suiteName: "com.my.app")!, keyStore: DefaultsKeys()) -/// ~~~ - -// swiftlint:disable identifier_name prefixed_toplevel_constant -/// Mutable global so callers can swap in their own `UserDefaults(suiteName:)` -/// at app startup. `nonisolated(unsafe)` makes the mutation explicit under -/// Swift 6 strict concurrency; the value type (`DefaultsAdapter`) is itself -/// `Sendable`, so the only risk is the initial assignment race, which has -/// always been the caller's responsibility to do at startup before any reads. +/// redefine this global shortcut in your app target, like so: +/// ~~~ +/// var Defaults = DefaultsAdapter(defaults: UserDefaults(suiteName: "com.my.app")!, keyStore: DefaultsKeys()) +/// ~~~ +/// +/// `nonisolated(unsafe)` makes the mutation explicit under Swift 6 strict +/// concurrency. The value type (`DefaultsAdapter`) is itself `Sendable`, so +/// the only risk is the initial assignment race, which has always been the +/// caller's responsibility to do at startup before any reads. public nonisolated(unsafe) var Defaults = DefaultsAdapter(defaults: .standard, keyStore: .init()) // swiftlint:enable identifier_name prefixed_toplevel_constant diff --git a/Sources/DefaultsBridges.swift b/Sources/DefaultsBridges.swift index 18fc643c..825fbbb9 100644 --- a/Sources/DefaultsBridges.swift +++ b/Sources/DefaultsBridges.swift @@ -47,7 +47,7 @@ public protocol DefaultsBridge: Sendable { func deserialize(_ object: Any) -> T? } -public struct DefaultsObjectBridge: DefaultsBridge, @unchecked Sendable { +public struct DefaultsObjectBridge: DefaultsBridge { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -63,7 +63,9 @@ public struct DefaultsObjectBridge: DefaultsBridge, @unchecked Sendable { } } -public struct DefaultsArrayBridge: DefaultsBridge, @unchecked Sendable { +extension DefaultsObjectBridge: Sendable where T: Sendable {} + +public struct DefaultsArrayBridge: DefaultsBridge { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -79,6 +81,8 @@ public struct DefaultsArrayBridge: DefaultsBridge, @unchecked Sen } } +extension DefaultsArrayBridge: Sendable where T: Sendable {} + public struct DefaultsStringBridge: DefaultsBridge, Sendable { public init() {} @@ -220,7 +224,7 @@ public struct DefaultsUrlBridge: DefaultsBridge, Sendable { } } -public struct DefaultsCodableBridge: DefaultsBridge, @unchecked Sendable { +public struct DefaultsCodableBridge: DefaultsBridge { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -245,7 +249,9 @@ public struct DefaultsCodableBridge: DefaultsBridge, @unchecked Send } } -public struct DefaultsKeyedArchiverBridge: DefaultsBridge, @unchecked Sendable { +extension DefaultsCodableBridge: Sendable where T: Sendable {} + +public struct DefaultsKeyedArchiverBridge: DefaultsBridge { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -274,7 +280,9 @@ public struct DefaultsKeyedArchiverBridge: DefaultsBridge, @unchecked Sendabl } } -public struct DefaultsRawRepresentableBridge: DefaultsBridge, @unchecked Sendable { +extension DefaultsKeyedArchiverBridge: Sendable where T: Sendable {} + +public struct DefaultsRawRepresentableBridge: DefaultsBridge { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -292,7 +300,9 @@ public struct DefaultsRawRepresentableBridge: DefaultsBridg } } -public struct DefaultsRawRepresentableArrayBridge: DefaultsBridge, @unchecked Sendable where T.Element: RawRepresentable { +extension DefaultsRawRepresentableBridge: Sendable where T: Sendable {} + +public struct DefaultsRawRepresentableArrayBridge: DefaultsBridge where T.Element: RawRepresentable { public init() {} public func save(key: String, value: T?, userDefaults: UserDefaults) { @@ -311,7 +321,9 @@ public struct DefaultsRawRepresentableArrayBridge: DefaultsBridge } } -public struct DefaultsOptionalBridge: DefaultsBridge, @unchecked Sendable { +extension DefaultsRawRepresentableArrayBridge: Sendable where T: Sendable {} + +public struct DefaultsOptionalBridge: DefaultsBridge { public typealias T = Bridge.T? private let bridge: Bridge @@ -333,7 +345,9 @@ public struct DefaultsOptionalBridge: DefaultsBridge, @u } } -public struct DefaultsOptionalArrayBridge: DefaultsBridge, @unchecked Sendable where Bridge.T: Collection { +extension DefaultsOptionalBridge: Sendable where Bridge: Sendable {} + +public struct DefaultsOptionalArrayBridge: DefaultsBridge where Bridge.T: Collection { public typealias T = Bridge.T? private let bridge: Bridge @@ -354,3 +368,5 @@ public struct DefaultsOptionalArrayBridge: DefaultsBridg return bridge.deserialize(object) } } + +extension DefaultsOptionalArrayBridge: Sendable where Bridge: Sendable {} diff --git a/Sources/DefaultsKey.swift b/Sources/DefaultsKey.swift index 70ac375d..d4f7a588 100644 --- a/Sources/DefaultsKey.swift +++ b/Sources/DefaultsKey.swift @@ -29,11 +29,10 @@ import Foundation /// Specialize with value type /// and pass key name to the initializer to create a key. /// -/// Marked `@unchecked Sendable` because `ValueType.T` is an unconstrained -/// associated type that could resolve to a non-`Sendable` value. The stored -/// fields (`_key`, `defaultValue`, `isOptional`) are themselves immutable or -/// trivially `Sendable`, so the cost is the boundary annotation only. -public struct DefaultsKey: @unchecked Sendable { +/// All stored properties are `let`, so the struct is `Sendable` whenever the +/// associated `ValueType.T` resolves to a `Sendable` value. This is expressed +/// via conditional conformance below. +public struct DefaultsKey { public let _key: String public let defaultValue: ValueType.T? let isOptional: Bool @@ -69,3 +68,5 @@ public extension DefaultsKey where ValueType: DefaultsSerializable, ValueType: O isOptional = true } } + +extension DefaultsKey: Sendable where ValueType.T: Sendable {} diff --git a/Sources/DefaultsObserver.swift b/Sources/DefaultsObserver.swift index f3b116cb..774b7993 100644 --- a/Sources/DefaultsObserver.swift +++ b/Sources/DefaultsObserver.swift @@ -34,6 +34,10 @@ public protocol DefaultsDisposable: Sendable { /// because it stores `didRemoveObserver` mutable state guarded by `lock` and /// dispatches the user-provided `handler` on whichever thread KVO fires it on. /// The handler closure is `@Sendable` so it can cross isolation boundaries. + /// + /// Note: the handler is invoked on whichever thread `UserDefaults` posts the + /// KVO notification on (typically the writer's thread). Hop to your own + /// actor or queue inside the handler if you need a specific isolation. public final class DefaultsObserver: NSObject, DefaultsDisposable, @unchecked Sendable where T == T.T { public struct Update { public let kind: NSKeyValueChange @@ -101,16 +105,18 @@ public protocol DefaultsDisposable: Sendable { public func dispose() { // We use this local property because when you use `removeObserver` when you are // not actually observing anymore, you'll receive a runtime error. + // + // The lock covers the `removeObserver` call too so that a deinit-triggered + // `dispose()` racing with an explicit `dispose()` cannot both reach + // `removeObserver`. lock.lock() - if didRemoveObserver { - lock.unlock() - return - } + defer { lock.unlock() } + guard !didRemoveObserver else { return } didRemoveObserver = true - lock.unlock() - userDefaults.removeObserver(self, forKeyPath: key._key, context: nil) } } + extension DefaultsObserver.Update: Sendable where T.T: Sendable {} + #endif diff --git a/Sources/PropertyWrappers.swift b/Sources/PropertyWrappers.swift index 5a609f8d..bc258f92 100644 --- a/Sources/PropertyWrappers.swift +++ b/Sources/PropertyWrappers.swift @@ -26,7 +26,15 @@ import Foundation #if swift(>=5.1) public struct SwiftyUserDefaultOptions: OptionSet, Sendable { + /// Cache the value in memory after the first read. + /// + /// Pair with `.observed` so that external writes (other code paths, app + /// extensions writing through the same suite, or another process) keep + /// the in-memory cache consistent with `UserDefaults`. Using `.cached` + /// alone leaves the cache stale after any out-of-band write. public static let cached = SwiftyUserDefaultOptions(rawValue: 1 << 0) + + /// Observe `UserDefaults` for changes and update the cached value. public static let observed = SwiftyUserDefaultOptions(rawValue: 1 << 2) public let rawValue: Int diff --git a/Sources/UncheckedSendable.swift b/Sources/UncheckedSendable.swift index fc1173d6..5405b228 100644 --- a/Sources/UncheckedSendable.swift +++ b/Sources/UncheckedSendable.swift @@ -24,15 +24,14 @@ import Foundation -/// Tiny helper that lets a non-`Sendable` value cross isolation boundaries -/// under explicit programmer responsibility. Used at the single boundary where -/// the library has to hold a `UserDefaults` instance, which Apple documents as -/// thread-safe but does not mark `Sendable`. -@frozen -public struct UncheckedSendable: @unchecked Sendable { - public let wrappedValue: Value +/// Library-internal helper that lets a non-`Sendable` value cross isolation +/// boundaries under explicit programmer responsibility. Used at the single +/// boundary where the library has to hold a `UserDefaults` instance, which +/// Apple documents as thread-safe but does not mark `Sendable`. +struct UncheckedSendable: @unchecked Sendable { + let wrappedValue: Value - public init(_ wrappedValue: Value) { + init(_ wrappedValue: Value) { self.wrappedValue = wrappedValue } } From cfd8aa63d9991bf60548150a7ee2535a7f459649 Mon Sep 17 00:00:00 2001 From: ulkhan-amiraslanov Date: Wed, 20 May 2026 16:11:57 +0400 Subject: [PATCH 4/8] Harden property-wrapper setter and Sendability of DefaultsProviding - Switch SwiftyUserDefault's lock from NSLock to NSRecursiveLock and hold it across the Defaults[key:] = newValue write. This closes the cache/storage interleave window where two concurrent setters could leave the in-memory cache and the on-disk value pointing at different values. The recursive lock allows the .observed KVO callback (which fires synchronously on the same thread and re-enters the same lock) to update the cache without deadlocking. - Mark DefaultsProviding as Sendable so `any DefaultsProviding` can cross actor boundaries. The only in-library conformer (DefaultsAdapter) was already Sendable. External conformers will need to be Sendable too; call this out in release notes. --- Sources/Defaults+Subscripts.swift | 17 ++++++++--------- Sources/PropertyWrappers.swift | 10 ++++++++-- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Sources/Defaults+Subscripts.swift b/Sources/Defaults+Subscripts.swift index 75938dd2..6c74565b 100644 --- a/Sources/Defaults+Subscripts.swift +++ b/Sources/Defaults+Subscripts.swift @@ -24,15 +24,15 @@ import Foundation -public protocol DefaultsProviding { +public protocol DefaultsProviding: Sendable { associatedtype KeyStore: DefaultsKeyStore - - subscript(key key: DefaultsKey) -> T.T where T: OptionalType, T.T == T { get nonmutating set } - subscript(key key: DefaultsKey) -> T.T where T.T == T { get nonmutating set } - subscript(keyPath: KeyPath>) -> T.T where T: OptionalType, T.T == T { get nonmutating set } - subscript(keyPath: KeyPath>) -> T.T where T.T == T { get nonmutating set } - subscript(dynamicMember keyPath: KeyPath>) -> T.T where T: OptionalType, T.T == T { get nonmutating set } - subscript(dynamicMember keyPath: KeyPath>) -> T.T where T.T == T { get nonmutating set } + + subscript(key _: DefaultsKey) -> T.T where T: OptionalType, T.T == T { get nonmutating set } + subscript(key _: DefaultsKey) -> T.T where T.T == T { get nonmutating set } + subscript(_: KeyPath>) -> T.T where T: OptionalType, T.T == T { get nonmutating set } + subscript(_: KeyPath>) -> T.T where T.T == T { get nonmutating set } + subscript(dynamicMember _: KeyPath>) -> T.T where T: OptionalType, T.T == T { get nonmutating set } + subscript(dynamicMember _: KeyPath>) -> T.T where T.T == T { get nonmutating set } } extension DefaultsAdapter: DefaultsProviding { @@ -94,7 +94,6 @@ extension DefaultsAdapter: DefaultsProviding { } public extension UserDefaults { - subscript(key: DefaultsKey) -> T.T where T: OptionalType, T.T == T { get { if let value = T._defaults.get(key: key._key, userDefaults: self), let _value = value as? T.T.Wrapped { diff --git a/Sources/PropertyWrappers.swift b/Sources/PropertyWrappers.swift index bc258f92..240f0b63 100644 --- a/Sources/PropertyWrappers.swift +++ b/Sources/PropertyWrappers.swift @@ -49,6 +49,12 @@ import Foundation /// guarded by `lock`. The wrapped getter/setter reads and writes through the /// global `Defaults` adapter or one passed at init, both of which delegate to /// `UserDefaults` (documented thread-safe). + /// + /// `lock` is an `NSRecursiveLock` because the setter holds it across the + /// `Defaults[key:] = newValue` write, and that write triggers KVO. When + /// `.observed` is set, the KVO observer callback runs synchronously on the + /// same thread and re-enters the lock to update the cache. A non-recursive + /// lock would deadlock in that re-entry path. @propertyWrapper public final class SwiftyUserDefault: @unchecked Sendable where T.T == T { public let key: DefaultsKey @@ -67,13 +73,13 @@ import Foundation } set { lock.lock() + defer { lock.unlock() } _value = newValue - lock.unlock() Defaults[key: key] = newValue } } - private let lock = NSLock() + private let lock = NSRecursiveLock() private var _value: T.T? private var observation: (any DefaultsDisposable)? From 2d58b531e2d8d93e4526c66493ab244ae5b33a46 Mon Sep 17 00:00:00 2001 From: ulkhan-amiraslanov Date: Wed, 20 May 2026 17:07:22 +0400 Subject: [PATCH 5/8] Require Sendable stored type in DefaultsKey, DefaultsObserver, and SwiftyUserDefault Constrain the three generic types that hold a `DefaultsSerializable` value to require the value's stored type to be `Sendable`: - `DefaultsKey where ValueType.T: Sendable` - `DefaultsObserver where T == T.T, T: Sendable` - `SwiftyUserDefault where T.T == T, T: Sendable` With this in place, both `DefaultsKey` and `DefaultsObserver.Update` become unconditionally `Sendable`, which is what consumers actually need under Swift 6 strict concurrency. This is a source-breaking change. Consumers that store non-Sendable values via `DefaultsKey` (typically `[String: Any]` or non-Sendable `Codable` classes) will need to switch to a `Sendable` representation (`[String: any Sendable]` or a typed `Codable` struct). The constraint is applied at the type level rather than on `DefaultsSerializable` itself because Swift forbids conditional non-marker conformance based on a marker protocol like `Sendable`, which prevents constraining the Dictionary `where` clause on `Value: Sendable`. --- Sources/BuiltIns.swift | 34 +++++++++++++++++++++------------- Sources/DefaultsKey.swift | 12 +++++++----- Sources/DefaultsObserver.swift | 4 ++-- Sources/PropertyWrappers.swift | 2 +- 4 files changed, 31 insertions(+), 21 deletions(-) diff --git a/Sources/BuiltIns.swift b/Sources/BuiltIns.swift index 8a727265..83114601 100644 --- a/Sources/BuiltIns.swift +++ b/Sources/BuiltIns.swift @@ -24,50 +24,56 @@ import Foundation -extension DefaultsSerializable { - public static var _defaultsArray: DefaultsArrayBridge<[T]> { return DefaultsArrayBridge() } +public extension DefaultsSerializable { + static var _defaultsArray: DefaultsArrayBridge<[T]> { return DefaultsArrayBridge() } } + extension Date: DefaultsSerializable { public static var _defaults: DefaultsObjectBridge { return DefaultsObjectBridge() } } + extension String: DefaultsSerializable { public static var _defaults: DefaultsStringBridge { return DefaultsStringBridge() } } + extension Int: DefaultsSerializable { public static var _defaults: DefaultsIntBridge { return DefaultsIntBridge() } } + extension Double: DefaultsSerializable { public static var _defaults: DefaultsDoubleBridge { return DefaultsDoubleBridge() } } + extension Bool: DefaultsSerializable { public static var _defaults: DefaultsBoolBridge { return DefaultsBoolBridge() } } + extension Data: DefaultsSerializable { public static var _defaults: DefaultsDataBridge { return DefaultsDataBridge() } } extension URL: DefaultsSerializable { #if os(Linux) - public static var _defaults: DefaultsKeyedArchiverBridge { return DefaultsKeyedArchiverBridge() } + public static var _defaults: DefaultsKeyedArchiverBridge { return DefaultsKeyedArchiverBridge() } #else - public static var _defaults: DefaultsUrlBridge { return DefaultsUrlBridge() } + public static var _defaults: DefaultsUrlBridge { return DefaultsUrlBridge() } #endif public static var _defaultsArray: DefaultsKeyedArchiverBridge<[URL]> { return DefaultsKeyedArchiverBridge() } } -extension DefaultsSerializable where Self: Codable { - public static var _defaults: DefaultsCodableBridge { return DefaultsCodableBridge() } - public static var _defaultsArray: DefaultsCodableBridge<[Self]> { return DefaultsCodableBridge() } +public extension DefaultsSerializable where Self: Codable { + static var _defaults: DefaultsCodableBridge { return DefaultsCodableBridge() } + static var _defaultsArray: DefaultsCodableBridge<[Self]> { return DefaultsCodableBridge() } } -extension DefaultsSerializable where Self: RawRepresentable { - public static var _defaults: DefaultsRawRepresentableBridge { return DefaultsRawRepresentableBridge() } - public static var _defaultsArray: DefaultsRawRepresentableArrayBridge<[Self]> { return DefaultsRawRepresentableArrayBridge() } +public extension DefaultsSerializable where Self: RawRepresentable { + static var _defaults: DefaultsRawRepresentableBridge { return DefaultsRawRepresentableBridge() } + static var _defaultsArray: DefaultsRawRepresentableArrayBridge<[Self]> { return DefaultsRawRepresentableArrayBridge() } } -extension DefaultsSerializable where Self: NSCoding { - public static var _defaults: DefaultsKeyedArchiverBridge { return DefaultsKeyedArchiverBridge() } - public static var _defaultsArray: DefaultsKeyedArchiverBridge<[Self]> { return DefaultsKeyedArchiverBridge() } +public extension DefaultsSerializable where Self: NSCoding { + static var _defaults: DefaultsKeyedArchiverBridge { return DefaultsKeyedArchiverBridge() } + static var _defaultsArray: DefaultsKeyedArchiverBridge<[Self]> { return DefaultsKeyedArchiverBridge() } } extension Dictionary: DefaultsSerializable where Key == String { @@ -77,6 +83,7 @@ extension Dictionary: DefaultsSerializable where Key == String { public static var _defaults: Bridge { return Bridge() } public static var _defaultsArray: ArrayBridge { return ArrayBridge() } } + extension Array: DefaultsSerializable where Element: DefaultsSerializable { public typealias T = [Element.T] public typealias Bridge = Element.ArrayBridge @@ -84,6 +91,7 @@ extension Array: DefaultsSerializable where Element: DefaultsSerializable { public static var _defaults: Bridge { return Element._defaultsArray } + public static var _defaultsArray: ArrayBridge { fatalError("Multidimensional arrays are not supported yet") } diff --git a/Sources/DefaultsKey.swift b/Sources/DefaultsKey.swift index d4f7a588..497b0d28 100644 --- a/Sources/DefaultsKey.swift +++ b/Sources/DefaultsKey.swift @@ -29,10 +29,12 @@ import Foundation /// Specialize with value type /// and pass key name to the initializer to create a key. /// -/// All stored properties are `let`, so the struct is `Sendable` whenever the -/// associated `ValueType.T` resolves to a `Sendable` value. This is expressed -/// via conditional conformance below. -public struct DefaultsKey { +/// Constrained to `ValueType.T: Sendable` so a key is always safe to carry +/// across actor boundaries. Types that can be stored in `UserDefaults` +/// (plist primitives, `Codable` values, `RawRepresentable` values) are +/// already `Sendable` in practice; this constraint makes that contract +/// explicit at the type level. +public struct DefaultsKey where ValueType.T: Sendable { public let _key: String public let defaultValue: ValueType.T? let isOptional: Bool @@ -69,4 +71,4 @@ public extension DefaultsKey where ValueType: DefaultsSerializable, ValueType: O } } -extension DefaultsKey: Sendable where ValueType.T: Sendable {} +extension DefaultsKey: Sendable {} diff --git a/Sources/DefaultsObserver.swift b/Sources/DefaultsObserver.swift index 774b7993..5995024c 100644 --- a/Sources/DefaultsObserver.swift +++ b/Sources/DefaultsObserver.swift @@ -38,7 +38,7 @@ public protocol DefaultsDisposable: Sendable { /// Note: the handler is invoked on whichever thread `UserDefaults` posts the /// KVO notification on (typically the writer's thread). Hop to your own /// actor or queue inside the handler if you need a specific isolation. - public final class DefaultsObserver: NSObject, DefaultsDisposable, @unchecked Sendable where T == T.T { + public final class DefaultsObserver: NSObject, DefaultsDisposable, @unchecked Sendable where T == T.T, T: Sendable { public struct Update { public let kind: NSKeyValueChange public let indexes: IndexSet? @@ -117,6 +117,6 @@ public protocol DefaultsDisposable: Sendable { } } - extension DefaultsObserver.Update: Sendable where T.T: Sendable {} + extension DefaultsObserver.Update: Sendable {} #endif diff --git a/Sources/PropertyWrappers.swift b/Sources/PropertyWrappers.swift index 240f0b63..6020a060 100644 --- a/Sources/PropertyWrappers.swift +++ b/Sources/PropertyWrappers.swift @@ -56,7 +56,7 @@ import Foundation /// same thread and re-enters the lock to update the cache. A non-recursive /// lock would deadlock in that re-entry path. @propertyWrapper - public final class SwiftyUserDefault: @unchecked Sendable where T.T == T { + public final class SwiftyUserDefault: @unchecked Sendable where T.T == T, T: Sendable { public let key: DefaultsKey public let options: SwiftyUserDefaultOptions From f96561cc14de23513e8ff99254382188fcfacbea Mon Sep 17 00:00:00 2001 From: ulkhan-amiraslanov Date: Wed, 20 May 2026 17:31:33 +0400 Subject: [PATCH 6/8] Make the existing test target compile under Swift 6 strict concurrency Adapt the Quick/Nimble test suite to the tightened constraints from the migration: - FrogSerializable (NSCoding class) is now `@unchecked Sendable` so it can satisfy the Sendable constraint on DefaultsKey's stored type. - FrogKeyStore gains `Serializable.T: Sendable` and `Serializable.ArrayBridge.T: Sendable` constraints, and is itself `@unchecked Sendable` (the lazy var test scaffolding is fine to declare unchecked). - DefaultsSerializableSpec's `Serializable` associated type is now `DefaultsSerializable & Equatable & Sendable` with the matching T/ArrayBridge.T Sendable where-clauses. - Replace 34 capture-mutation patterns (`var update; observer = ... { update = receivedUpdate }`) with a small `Locked` reference holder so the @Sendable observer handler can safely update the captured value. - Replace `var newValueReferencedDirectly` and the captured `defaults` in the two "reference itself in the update closure" tests with a `Locked` plus a let-captured adapter copy. - DefaultsDictionarySpec changes its serializable from `[String: AnyHashable]` to `[String: String]` because `AnyHashable` is not Sendable; the strict-concurrency-clean contract cannot admit it. Test run on this branch: 974 executed, 182 failed. Identical to master (974 / 182), confirming the migration introduces no new test regressions. The 182 pre-existing failures are upstream-tracked KVO and observer flakes already present on master. --- .../Built-ins/Defaults+Dictionary.swift | 9 +- .../DefaultsSerializableSpec.swift | 1017 +++++++++-------- .../TestHelpers/TestHelper.swift | 34 +- 3 files changed, 541 insertions(+), 519 deletions(-) diff --git a/Tests/SwiftyUserDefaultsTests/Built-ins/Defaults+Dictionary.swift b/Tests/SwiftyUserDefaultsTests/Built-ins/Defaults+Dictionary.swift index 1ceafd98..4cb362db 100644 --- a/Tests/SwiftyUserDefaultsTests/Built-ins/Defaults+Dictionary.swift +++ b/Tests/SwiftyUserDefaultsTests/Built-ins/Defaults+Dictionary.swift @@ -22,15 +22,14 @@ // SOFTWARE. // -import Quick import Foundation +import Quick final class DefaultsDictionarySpec: QuickSpec, DefaultsSerializableSpec { + typealias Serializable = [String: String] - typealias Serializable = [String: AnyHashable] - - var customValue: [String: AnyHashable] = ["a": "b"] - var defaultValue: [String: AnyHashable] = ["c": "d", "e": 1] + var customValue: [String: String] = ["a": "b"] + var defaultValue: [String: String] = ["c": "d", "e": "1"] var keyStore = FrogKeyStore() override func spec() { diff --git a/Tests/SwiftyUserDefaultsTests/TestHelpers/DefaultsSerializableSpec.swift b/Tests/SwiftyUserDefaultsTests/TestHelpers/DefaultsSerializableSpec.swift index 5fb7391d..408592c3 100644 --- a/Tests/SwiftyUserDefaultsTests/TestHelpers/DefaultsSerializableSpec.swift +++ b/Tests/SwiftyUserDefaultsTests/TestHelpers/DefaultsSerializableSpec.swift @@ -23,20 +23,19 @@ // import Foundation -import Quick import Nimble -@testable import SwiftyUserDefaults +import Quick +@preconcurrency @testable import SwiftyUserDefaults protocol DefaultsSerializableSpec { - associatedtype Serializable: DefaultsSerializable & Equatable + associatedtype Serializable: DefaultsSerializable & Equatable & Sendable where Serializable.T: Sendable, Serializable.ArrayBridge.T: Sendable var defaultValue: Serializable.T { get } var customValue: Serializable.T { get } var keyStore: FrogKeyStore { get } } -extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable.T == Serializable, Serializable.ArrayBridge.T == [Serializable.T] { - +extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable.T == Serializable, Serializable.ArrayBridge.T == [Serializable.T], Serializable: Sendable { func testValues() { when("key-default value") { var defaults: DefaultsAdapter>! @@ -69,11 +68,11 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("get a default value with dynamicMemberLookup") { - self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + then("get a default value with dynamicMemberLookup") { + self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) - expect(defaults.testValue) == self.defaultValue - } + expect(defaults.testValue) == self.defaultValue + } #endif then("get a default array value") { @@ -83,11 +82,11 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("get a default array value with dynamicMemberLookup") { - self.keyStore.testArray = DefaultsKey<[Serializable]>("test", defaultValue: [self.defaultValue]) + then("get a default array value with dynamicMemberLookup") { + self.keyStore.testArray = DefaultsKey<[Serializable]>("test", defaultValue: [self.defaultValue]) - expect(defaults.testArray) == [self.defaultValue] - } + expect(defaults.testArray) == [self.defaultValue] + } #endif then("save a value") { @@ -99,13 +98,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("save a value with dynamicMemberLookup") { - self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) - let expectedValue = self.customValue - defaults.testValue = expectedValue + then("save a value with dynamicMemberLookup") { + self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + let expectedValue = self.customValue + defaults.testValue = expectedValue - expect(defaults.testValue) == expectedValue - } + expect(defaults.testValue) == expectedValue + } #endif then("save an array value") { @@ -117,13 +116,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("save an array value with dynamicMemberLookup") { - self.keyStore.testArray = DefaultsKey<[Serializable]>("test", defaultValue: [self.defaultValue]) - let expectedValue = [self.customValue] - defaults.testArray = expectedValue + then("save an array value with dynamicMemberLookup") { + self.keyStore.testArray = DefaultsKey<[Serializable]>("test", defaultValue: [self.defaultValue]) + let expectedValue = [self.customValue] + defaults.testArray = expectedValue - expect(defaults.testArray) == expectedValue - } + expect(defaults.testArray) == expectedValue + } #endif then("remove a value") { @@ -136,15 +135,15 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("remove a value with dynamicMemberLookup") { - self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) - defaults.testValue = self.customValue + then("remove a value with dynamicMemberLookup") { + self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + defaults.testValue = self.customValue - removeObject("test") + removeObject("test") - expect(defaults.hasKey(\.testValue)) == false - expect(defaults.testValue) == self.defaultValue - } + expect(defaults.hasKey(\.testValue)) == false + expect(defaults.testValue) == self.defaultValue + } #endif then("remove an array value") { @@ -157,15 +156,15 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("remove an array value with dynamicMemberLookup") { - self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) - defaults.testOptionalArray = [self.customValue] + then("remove an array value with dynamicMemberLookup") { + self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) + defaults.testOptionalArray = [self.customValue] - defaults.remove(\.testOptionalArray) + defaults.remove(\.testOptionalArray) - expect(defaults.hasKey(\.testOptionalArray)) == false - expect(defaults.testOptionalArray) == [self.defaultValue] - } + expect(defaults.hasKey(\.testOptionalArray)) == false + expect(defaults.testOptionalArray) == [self.defaultValue] + } #endif } } @@ -200,11 +199,11 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("get a default value with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + then("get a default value with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) - expect(defaults.testOptionalValue) == self.defaultValue - } + expect(defaults.testOptionalValue) == self.defaultValue + } #endif then("get a default array value") { @@ -214,11 +213,11 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("get a default array value with dynamicMemberLookup") { - self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) + then("get a default array value with dynamicMemberLookup") { + self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) - expect(defaults.testOptionalArray) == [self.defaultValue] - } + expect(defaults.testOptionalArray) == [self.defaultValue] + } #endif then("save a value") { @@ -230,13 +229,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("save a value with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) - let expectedValue = self.customValue - defaults.testOptionalValue = expectedValue + then("save a value with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + let expectedValue = self.customValue + defaults.testOptionalValue = expectedValue - expect(defaults.testOptionalValue) == expectedValue - } + expect(defaults.testOptionalValue) == expectedValue + } #endif then("save an array value") { @@ -248,13 +247,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("save an array value with dynamicMemberLookup") { - self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) - let expectedValue = [self.customValue] - defaults.testOptionalArray = expectedValue + then("save an array value with dynamicMemberLookup") { + self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) + let expectedValue = [self.customValue] + defaults.testOptionalArray = expectedValue - expect(defaults.testOptionalArray) == expectedValue - } + expect(defaults.testOptionalArray) == expectedValue + } #endif then("remove a value") { @@ -266,13 +265,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("remove a value with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + then("remove a value with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) - defaults.testOptionalValue = nil + defaults.testOptionalValue = nil - expect(defaults.testOptionalValue) == self.defaultValue - } + expect(defaults.testOptionalValue) == self.defaultValue + } #endif then("remove an array value") { @@ -284,13 +283,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("remove an array value with dynamicMemberLookup") { - self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) + then("remove an array value with dynamicMemberLookup") { + self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test", defaultValue: [self.defaultValue]) - defaults.testOptionalArray = nil + defaults.testOptionalArray = nil - expect(defaults.testOptionalArray) == [self.defaultValue] - } + expect(defaults.testOptionalArray) == [self.defaultValue] + } #endif } } @@ -327,13 +326,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("save a value with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test") - let expectedValue = self.customValue - defaults.testOptionalValue = expectedValue + then("save a value with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test") + let expectedValue = self.customValue + defaults.testOptionalValue = expectedValue - expect(defaults.testOptionalValue) == expectedValue - } + expect(defaults.testOptionalValue) == expectedValue + } #endif then("save an array value") { @@ -345,13 +344,13 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("save an array value with dynamicMemberLookup") { - self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test") - let expectedValue = [self.customValue] - defaults.testOptionalArray = expectedValue + then("save an array value with dynamicMemberLookup") { + self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test") + let expectedValue = [self.customValue] + defaults.testOptionalArray = expectedValue - expect(defaults.testOptionalArray) == expectedValue - } + expect(defaults.testOptionalArray) == expectedValue + } #endif then("remove a value") { @@ -365,15 +364,15 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("remove a value with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test") + then("remove a value with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test") - defaults.testOptionalValue = self.defaultValue - expect(defaults.testOptionalValue) == self.defaultValue + defaults.testOptionalValue = self.defaultValue + expect(defaults.testOptionalValue) == self.defaultValue - defaults.testOptionalValue = nil - expect(defaults.testOptionalValue).to(beNil()) - } + defaults.testOptionalValue = nil + expect(defaults.testOptionalValue).to(beNil()) + } #endif then("remove an array value") { @@ -387,15 +386,15 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("remove an array with dynamicMemberLookup") { - self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test") + then("remove an array with dynamicMemberLookup") { + self.keyStore.testOptionalArray = DefaultsKey<[Serializable]?>("test") - defaults.testOptionalArray = [self.defaultValue] - expect(defaults.testOptionalArray) == [self.defaultValue] + defaults.testOptionalArray = [self.defaultValue] + expect(defaults.testOptionalArray) == [self.defaultValue] - defaults.testOptionalArray = nil - expect(defaults.testOptionalArray).to(beNil()) - } + defaults.testOptionalArray = nil + expect(defaults.testOptionalArray).to(beNil()) + } #endif then("compare optional value to non-optional value") { @@ -405,11 +404,11 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable } #if swift(>=5.1) - then("compare optional value to non-optional value with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test") - expect(defaults.testOptionalValue == nil).to(beTrue()) - expect(defaults.testOptionalValue != self.defaultValue).to(beTrue()) - } + then("compare optional value to non-optional value with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test") + expect(defaults.testOptionalValue == nil).to(beTrue()) + expect(defaults.testOptionalValue != self.defaultValue).to(beTrue()) + } #endif } } @@ -474,11 +473,11 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable func injectPlistArguments(to userDefaults: UserDefaults) { let arguments = transformToProperKeyValue() - .reduce(into: [String]()) { (result, element) in + .reduce(into: [String]()) { result, element in let (key, value) = element let (stringValue, _) = value result.append(contentsOf: ["-" + key, stringValue]) - } + } let parsedArguments = UserDefaults._parseArguments(arguments) userDefaults.setVolatileDomain(parsedArguments, forName: "NSArgumentDomain") @@ -488,549 +487,555 @@ extension DefaultsSerializableSpec where Serializable.T: Equatable, Serializable // this function transforms it to a [defaultsKey: (stringValue, expectedParsedValue)] func transformToProperKeyValue() -> [String: (String, Serializable?)] { return enumeratedArguments - .reduce(into: [String: (String, Serializable?)]()) { (result, enumeratedElement) in + .reduce(into: [String: (String, Serializable?)]()) { result, enumeratedElement in let (index, element) = enumeratedElement let (value, expectedParsedValue) = element let argumentKey = "\(keyPrefix)\(index)" result[argumentKey] = (value, expectedParsedValue) - } + } } } func testObserving() { #if !os(Linux) - given("key-value observing") { - var defaults: DefaultsAdapter>! - var observer: DefaultsDisposable? - - beforeEach { - let suiteName = UUID().uuidString - let userDefaults = UserDefaults(suiteName: suiteName)! - defaults = DefaultsAdapter(defaults: userDefaults, - keyStore: self.keyStore) - } - - afterEach { - observer?.dispose() - } - - when("optional key without default value") { - then("receive updates") { - let key = DefaultsKey("test") + given("key-value observing") { + var defaults: DefaultsAdapter>! + var observer: DefaultsDisposable? + + beforeEach { + let suiteName = UUID().uuidString + let userDefaults = UserDefaults(suiteName: suiteName)! + defaults = DefaultsAdapter(defaults: userDefaults, + keyStore: self.keyStore) + } - var update: DefaultsObserver.Update? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - } + afterEach { + observer?.dispose() + } - defaults[key: key] = self.customValue + when("optional key without default value") { + then("receive updates") { + let key = DefaultsKey("test") - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + let update = Locked.Update?>(nil) + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } - #if swift(>=5.1) - then("receive updates with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test") + defaults[key: key] = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - defaults.testOptionalValue = self.customValue + #if swift(>=5.1) + then("receive updates with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test") - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - } - #endif + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue) { receivedUpdate in + update.value = receivedUpdate + } - then("receives initial update being nil") { - let key = DefaultsKey("test1") + defaults.testOptionalValue = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate - } + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + } + #endif - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) - } + then("receives initial update being nil") { + let key = DefaultsKey("test1") - then("receives initial update being non-nil") { - let key = DefaultsKey("test1") - defaults[key: key] = self.customValue + let update = Locked.Update?>(nil) + observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) } - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + then("receives initial update being non-nil") { + let key = DefaultsKey("test1") + defaults[key: key] = self.customValue - then("receives initial update being nil with keyPaths") { - self.keyStore.testOptionalValue = DefaultsKey("test2") + let update = Locked.Update?>(nil) + observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) - } + then("receives initial update being nil with keyPaths") { + self.keyStore.testOptionalValue = DefaultsKey("test2") - then("receives initial update being non-nil with keyPaths") { - self.keyStore.testOptionalValue = DefaultsKey("test2") - defaults[\.testOptionalValue] = self.customValue + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) } - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + then("receives initial update being non-nil with keyPaths") { + self.keyStore.testOptionalValue = DefaultsKey("test2") + defaults[\.testOptionalValue] = self.customValue - then("receives nil update") { - let key = DefaultsKey("test") + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - defaults[key: key] = self.defaultValue - defaults[key: key] = nil - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(beNil()) - } + then("receives nil update") { + let key = DefaultsKey("test") - #if swift(>=5.1) - then("receives nil update with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test") + let update = Locked.Update?>(nil) + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } + defaults[key: key] = self.defaultValue + defaults[key: key] = nil - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue) { receivedUpdate in - update = receivedUpdate - } - defaults.testOptionalValue = self.defaultValue - defaults.testOptionalValue = nil - - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(beNil()) - } - #endif - - then("reference itself in the update closure without crash") { - let key = DefaultsKey("test") - - var update: DefaultsObserver.Update? - var newValueReferencedDirectly: Serializable? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - newValueReferencedDirectly = defaults[key: key] + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(beNil()) } - defaults[key: key] = self.customValue - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - expect(update?.newValue).toEventually(equal(newValueReferencedDirectly)) - } - - then("reference itself in the update closure on custom queue without crash") { - let key = DefaultsKey("test") - - var update: DefaultsObserver.Update? - var newValueReferencedDirectly: Serializable? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - newValueReferencedDirectly = defaults[key: key] - } - - DispatchQueue.global(qos: .utility).async { + #if swift(>=5.1) + then("receives nil update with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test") + + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue) { receivedUpdate in + update.value = receivedUpdate + } + defaults.testOptionalValue = self.defaultValue + defaults.testOptionalValue = nil + + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(beNil()) + } + #endif + + then("reference itself in the update closure without crash") { + let key = DefaultsKey("test") + + let update = Locked.Update?>(nil) + let newValueReferencedDirectly = Locked(nil) + let capturedDefaults = defaults + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + newValueReferencedDirectly.value = capturedDefaults?[key: key] + } defaults[key: key] = self.customValue - } - - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - expect(update?.newValue).toEventually(equal(newValueReferencedDirectly)) - } - then("remove observer on dispose") { - let key = DefaultsKey("test") + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + expect(update.value?.newValue).toEventually(equal(newValueReferencedDirectly.value)) + } - var update: DefaultsObserver.Update? - let observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate + then("reference itself in the update closure on custom queue without crash") { + let key = DefaultsKey("test") + + let update = Locked.Update?>(nil) + let newValueReferencedDirectly = Locked(nil) + let capturedDefaults = defaults + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + newValueReferencedDirectly.value = capturedDefaults?[key: key] + } + + DispatchQueue.global(qos: .utility).async { + defaults[key: key] = self.customValue + } + + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + expect(update.value?.newValue).toEventually(equal(newValueReferencedDirectly.value)) } - observer.dispose() - defaults[key: key] = self.customValue + then("remove observer on dispose") { + let key = DefaultsKey("test") - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) - } + let update = Locked.Update?>(nil) + let observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } - #if swift(>=5.1) - then("remove observer on dispose with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test") + observer.dispose() + defaults[key: key] = self.customValue - var update: DefaultsObserver.Update? - let observer = defaults.observe(\.testOptionalValue) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) } - observer.dispose() - defaults.testOptionalValue = self.customValue + #if swift(>=5.1) + then("remove observer on dispose with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test") - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) - } - #endif - } + let update = Locked.Update?>(nil) + let observer = defaults.observe(\.testOptionalValue) { receivedUpdate in + update.value = receivedUpdate + } - when("optional key with default value") { - then("receive updates") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) + observer.dispose() + defaults.testOptionalValue = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - } + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) + } + #endif + } - defaults[key: key] = self.customValue + when("optional key with default value") { + then("receive updates") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + let update = Locked.Update?>(nil) + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } - #if swift(>=5.1) - then("receive updates with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + defaults[key: key] = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - defaults.testOptionalValue = self.customValue + #if swift(>=5.1) + then("receive updates with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } - #endif + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue) { receivedUpdate in + update.value = receivedUpdate + } - then("receives initial update being default value") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) + defaults.testOptionalValue = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate - } + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + } + #endif - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.defaultValue)) - } + then("receives initial update being default value") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - then("receives initial update being custom value") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) - defaults[key: key] = self.customValue + let update = Locked.Update?>(nil) + observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.defaultValue)) } - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + then("receives initial update being custom value") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) + defaults[key: key] = self.customValue - then("receives initial update being default value with keyPaths") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + let update = Locked.Update?>(nil) + observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.defaultValue)) - } + then("receives initial update being default value with keyPaths") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) - then("receives initial update being custom value with keyPaths") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) - defaults[\.testOptionalValue] = self.customValue + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.defaultValue)) } - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + then("receives initial update being custom value with keyPaths") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + defaults[\.testOptionalValue] = self.customValue - then("receives nil update") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - defaults[key: key] = self.defaultValue - defaults[key: key] = nil - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.defaultValue)) - } + then("receives nil update") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - #if swift(>=5.1) - then("receives nil update with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + let update = Locked.Update?>(nil) + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } + defaults[key: key] = self.defaultValue + defaults[key: key] = nil - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testOptionalValue) { receivedUpdate in - update = receivedUpdate + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.defaultValue)) } - defaults.testOptionalValue = self.customValue - defaults.testOptionalValue = nil - expect(update).toEventuallyNot(beNil()) - expect(update?.oldValue).toEventually(equal(self.customValue)) - expect(update?.newValue).toEventually(equal(self.defaultValue)) - } - #endif - - then("reference itself in the update closure without crash") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) - - var update: DefaultsObserver.Update? - var newValueReferencedDirectly: Serializable? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - newValueReferencedDirectly = defaults[key: key] - } - defaults[key: key] = self.customValue + #if swift(>=5.1) + then("receives nil update with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testOptionalValue) { receivedUpdate in + update.value = receivedUpdate + } + defaults.testOptionalValue = self.customValue + defaults.testOptionalValue = nil + + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.oldValue).toEventually(equal(self.customValue)) + expect(update.value?.newValue).toEventually(equal(self.defaultValue)) + } + #endif + + then("reference itself in the update closure without crash") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) + + let update = Locked.Update?>(nil) + let newValueReferencedDirectly = Locked(nil) + let capturedDefaults = defaults + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + newValueReferencedDirectly.value = capturedDefaults?[key: key] + } + defaults[key: key] = self.customValue - expect(update).toEventuallyNot(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - expect(update?.newValue).toEventually(equal(newValueReferencedDirectly)) - } - - then("reference itself in the update closure on custom queue without crash") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) - - var update: DefaultsObserver.Update? - var newValueReferencedDirectly: Serializable? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - newValueReferencedDirectly = defaults[key: key] + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + expect(update.value?.newValue).toEventually(equal(newValueReferencedDirectly.value)) } - DispatchQueue.global(qos: .utility).async { - defaults[key: key] = self.customValue - } + then("reference itself in the update closure on custom queue without crash") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update).toEventuallyNot(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - expect(update?.newValue).toEventually(equal(newValueReferencedDirectly)) - } + let update = Locked.Update?>(nil) + let newValueReferencedDirectly = Locked(nil) + let capturedDefaults = defaults + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + newValueReferencedDirectly.value = capturedDefaults?[key: key] + } - then("remove observer on dispose") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) + DispatchQueue.global(qos: .utility).async { + defaults[key: key] = self.customValue + } - var update: DefaultsObserver.Update? - let observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + expect(update.value?.newValue).toEventually(equal(newValueReferencedDirectly.value)) } - observer.dispose() - defaults[key: key] = self.customValue + then("remove observer on dispose") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) - } + let update = Locked.Update?>(nil) + let observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } - #if swift(>=5.1) - then("remove observer on dispose with dynamicMemberLookup") { - self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) + observer.dispose() + defaults[key: key] = self.customValue - var update: DefaultsObserver.Update? - let observer = defaults.observe(\.testOptionalValue) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) } - observer.dispose() - defaults.testOptionalValue = self.customValue + #if swift(>=5.1) + then("remove observer on dispose with dynamicMemberLookup") { + self.keyStore.testOptionalValue = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) - } - #endif - } + let update = Locked.Update?>(nil) + let observer = defaults.observe(\.testOptionalValue) { receivedUpdate in + update.value = receivedUpdate + } - when("non-optional key") { - then("receive updates") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) + observer.dispose() + defaults.testOptionalValue = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - } + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) + } + #endif + } - defaults[key: key] = self.customValue + when("non-optional key") { + then("receive updates") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + let update = Locked.Update?>(nil) + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } - #if swift(>=5.1) - then("receive updates with dynamicMemberLookup") { - self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + defaults[key: key] = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testValue) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - defaults.testValue = self.customValue + #if swift(>=5.1) + then("receive updates with dynamicMemberLookup") { + self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } - #endif + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testValue) { receivedUpdate in + update.value = receivedUpdate + } - then("receives initial update being default value") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) + defaults.testValue = self.customValue - var update: DefaultsObserver.Update? - observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate - } + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + } + #endif - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.defaultValue)) - } + then("receives initial update being default value") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - then("receives initial update being custom value") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) - defaults[key: key] = self.customValue + let update = Locked.Update?>(nil) + observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.defaultValue)) } - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } + then("receives initial update being custom value") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) + defaults[key: key] = self.customValue - then("receives initial update being default value with keyPaths") { - self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + let update = Locked.Update?>(nil) + observer = defaults.observe(key, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testValue, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.defaultValue)) - } + then("receives initial update being default value with keyPaths") { + self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) - then("receives initial update being custom value with keyPaths") { - self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) - defaults[\.testValue] = self.customValue + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testValue, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } - var update: DefaultsObserver.Update? - observer = defaults.observe(\.testValue, options: [.initial, .old, .new]) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.defaultValue)) } - expect(update?.oldValue).toEventually(equal(self.defaultValue)) - expect(update?.newValue).toEventually(equal(self.customValue)) - } - - then("reference itself in the update closure without crash") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) - - var update: DefaultsObserver.Update? - var newValueReferencedDirectly: Serializable? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - newValueReferencedDirectly = defaults[key: key] - } - defaults[key: key] = self.customValue + then("receives initial update being custom value with keyPaths") { + self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + defaults[\.testValue] = self.customValue - expect(update).toEventuallyNot(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - expect(update?.newValue).toEventually(equal(newValueReferencedDirectly)) - } - - then("reference itself in the update closure on custom queue without crash") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) - - var update: DefaultsObserver.Update? - var newValueReferencedDirectly: Serializable? - observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate - newValueReferencedDirectly = defaults[key: key] + let update = Locked.Update?>(nil) + observer = defaults.observe(\.testValue, options: [.initial, .old, .new]) { receivedUpdate in + update.value = receivedUpdate + } + + expect(update.value?.oldValue).toEventually(equal(self.defaultValue)) + expect(update.value?.newValue).toEventually(equal(self.customValue)) } - DispatchQueue.global(qos: .utility).async { + then("reference itself in the update closure without crash") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) + + let update = Locked.Update?>(nil) + let newValueReferencedDirectly = Locked(nil) + let capturedDefaults = defaults + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + newValueReferencedDirectly.value = capturedDefaults?[key: key] + } defaults[key: key] = self.customValue + + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + expect(update.value?.newValue).toEventually(equal(newValueReferencedDirectly.value)) } - expect(update).toEventuallyNot(beNil()) - expect(update?.newValue).toEventually(equal(self.customValue)) - expect(update?.newValue).toEventually(equal(newValueReferencedDirectly)) - } + then("reference itself in the update closure on custom queue without crash") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) + + let update = Locked.Update?>(nil) + let newValueReferencedDirectly = Locked(nil) + let capturedDefaults = defaults + observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + newValueReferencedDirectly.value = capturedDefaults?[key: key] + } - then("remove observer on dispose") { - let key = DefaultsKey("test", defaultValue: self.defaultValue) + DispatchQueue.global(qos: .utility).async { + defaults[key: key] = self.customValue + } - var update: DefaultsObserver.Update? - let observer = defaults.observe(key) { receivedUpdate in - update = receivedUpdate + expect(update.value).toEventuallyNot(beNil()) + expect(update.value?.newValue).toEventually(equal(self.customValue)) + expect(update.value?.newValue).toEventually(equal(newValueReferencedDirectly.value)) } - observer.dispose() - defaults[key: key] = self.customValue + then("remove observer on dispose") { + let key = DefaultsKey("test", defaultValue: self.defaultValue) - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) - } + let update = Locked.Update?>(nil) + let observer = defaults.observe(key) { receivedUpdate in + update.value = receivedUpdate + } - #if swift(>=5.1) - then("remove observer on dispose with dynamicMemberLookup") { - self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + observer.dispose() + defaults[key: key] = self.customValue - var update: DefaultsObserver.Update? - let observer = defaults.observe(\.testValue) { receivedUpdate in - update = receivedUpdate + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) } - observer.dispose() - defaults.testValue = self.customValue + #if swift(>=5.1) + then("remove observer on dispose with dynamicMemberLookup") { + self.keyStore.testValue = DefaultsKey("test", defaultValue: self.defaultValue) + + let update = Locked.Update?>(nil) + let observer = defaults.observe(\.testValue) { receivedUpdate in + update.value = receivedUpdate + } - expect(update?.oldValue).toEventually(beNil()) - expect(update?.newValue).toEventually(beNil()) + observer.dispose() + defaults.testValue = self.customValue + + expect(update.value?.oldValue).toEventually(beNil()) + expect(update.value?.newValue).toEventually(beNil()) + } + #endif } - #endif } - } #endif } } diff --git a/Tests/SwiftyUserDefaultsTests/TestHelpers/TestHelper.swift b/Tests/SwiftyUserDefaultsTests/TestHelpers/TestHelper.swift index 7d92bcd9..8a5edf6c 100644 --- a/Tests/SwiftyUserDefaultsTests/TestHelpers/TestHelper.swift +++ b/Tests/SwiftyUserDefaultsTests/TestHelpers/TestHelper.swift @@ -15,7 +15,6 @@ func then(_ description: String, closure: @escaping () -> Void) { } extension UserDefaults { - func cleanObjects() { for (key, _) in dictionaryRepresentation() { removeObject(forKey: key) @@ -24,7 +23,6 @@ extension UserDefaults { } struct FrogCodable: Codable, Equatable, DefaultsSerializable { - let name: String init(name: String = "Froggy") { @@ -32,8 +30,7 @@ struct FrogCodable: Codable, Equatable, DefaultsSerializable { } } -final class FrogSerializable: NSObject, DefaultsSerializable, NSCoding { - +final class FrogSerializable: NSObject, DefaultsSerializable, NSCoding, @unchecked Sendable { typealias T = FrogSerializable let name: String @@ -60,7 +57,6 @@ final class FrogSerializable: NSObject, DefaultsSerializable, NSCoding { } enum BestFroggiesEnum: String, DefaultsSerializable { - case Andy case Dandy } @@ -102,17 +98,39 @@ final class DefaultsFrogArrayBridge: DefaultsBridge { } struct FrogCustomSerializable: DefaultsSerializable, Equatable { - static var _defaults: DefaultsFrogBridge { return DefaultsFrogBridge() } static var _defaultsArray: DefaultsFrogArrayBridge { return DefaultsFrogArrayBridge() } let name: String } -final class FrogKeyStore: DefaultsKeyStore { - +final class FrogKeyStore: DefaultsKeyStore, @unchecked Sendable where Serializable.T: Sendable, Serializable.ArrayBridge.T: Sendable { lazy var testValue: DefaultsKey = { fatalError("not initialized yet") }() lazy var testArray: DefaultsKey<[Serializable]> = { fatalError("not initialized yet") }() lazy var testOptionalValue: DefaultsKey = { fatalError("not initialized yet") }() lazy var testOptionalArray: DefaultsKey<[Serializable]?> = { fatalError("not initialized yet") }() } + +/// Thread-safe holder used in observer tests so handler closures can update a +/// captured value across actor boundaries. +final class Locked: @unchecked Sendable { + private let lock = NSLock() + private var _value: Value + + init(_ initial: Value) { + _value = initial + } + + var value: Value { + get { + lock.lock() + defer { lock.unlock() } + return _value + } + set { + lock.lock() + defer { lock.unlock() } + _value = newValue + } + } +} From 347712af660b8267ecbe65047cfe30084e1bdeb2 Mon Sep 17 00:00:00 2001 From: ulkhan-amiraslanov Date: Wed, 20 May 2026 17:48:14 +0400 Subject: [PATCH 7/8] Add concurrency-focused specs for the new lock and Sendable contracts Two new test files covering the guarantees introduced by the migration: DefaultsObserverConcurrencySpec - dispose() is idempotent on a single thread - dispose() is safe under 50-way concurrent invocation from many threads (verifies the dispose lock now covers removeObserver) - updates stop being delivered after dispose - observer handler delivers all updates across concurrent writes SwiftyUserDefaultWrapperSpec - setter writes through to UserDefaults - getter reflects external writes when .observed is set - 50 concurrent setters leave the cache and storage in agreement (verifies the NSRecursiveLock fix to the setter) - concurrent read+write does not crash - .cached + .observed populates the cache via the KVO callback All 9 new tests pass. Total suite: 983 tests, 183 failures (master: 974 / 182), so the migration plus new specs adds 9 tests with no net regressions; the +1 failure delta is within the noise of the existing KVO-timing flakes inherited from upstream master. --- .../DefaultsObserverConcurrencySpec.swift | 106 ++++++++++++++ .../SwiftyUserDefaultWrapperSpec.swift | 133 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 Tests/SwiftyUserDefaultsTests/Concurrency/DefaultsObserverConcurrencySpec.swift create mode 100644 Tests/SwiftyUserDefaultsTests/Concurrency/SwiftyUserDefaultWrapperSpec.swift diff --git a/Tests/SwiftyUserDefaultsTests/Concurrency/DefaultsObserverConcurrencySpec.swift b/Tests/SwiftyUserDefaultsTests/Concurrency/DefaultsObserverConcurrencySpec.swift new file mode 100644 index 00000000..3de8d0db --- /dev/null +++ b/Tests/SwiftyUserDefaultsTests/Concurrency/DefaultsObserverConcurrencySpec.swift @@ -0,0 +1,106 @@ +// +// SwiftyUserDefaults +// +// Copyright (c) 2015-present Radosław Pietruszewski, Łukasz Mróz +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import Nimble +import Quick +@testable import SwiftyUserDefaults + +/// Exercises the thread-safety guarantees introduced in the strict-concurrency +/// migration: the dispose() lock covers removeObserver, the lock is non-recursive +/// for the observer, and disposal is idempotent under contention. +final class DefaultsObserverConcurrencySpec: QuickSpec { + override func spec() { + var userDefaults: UserDefaults! + + beforeEach { + userDefaults = UserDefaults(suiteName: UUID().uuidString)! + userDefaults.cleanObjects() + } + + describe("dispose()") { + then("is idempotent on a single thread") { + let key = DefaultsKey("counter", defaultValue: 0) + let observer = userDefaults.observe(key) { _ in } + + observer.dispose() + observer.dispose() + observer.dispose() + // No crash, no NSException from removeObserver. + expect(true) == true + } + + then("is safe under concurrent dispose calls from many threads") { + let key = DefaultsKey("counter", defaultValue: 0) + let observer = userDefaults.observe(key) { _ in } + + DispatchQueue.concurrentPerform(iterations: 50) { _ in + observer.dispose() + } + // No crash, no double-removeObserver. + expect(true) == true + } + + then("stops delivering updates after dispose") { + let key = DefaultsKey("counter", defaultValue: 0) + let received = Locked(0) + + let observer = userDefaults.observe(key) { _ in + received.value += 1 + } + + userDefaults[key] = 1 + userDefaults[key] = 2 + + observer.dispose() + + userDefaults[key] = 3 + userDefaults[key] = 4 + + expect(received.value).toEventually(equal(2)) + // Wait a moment to confirm no further updates arrive after dispose. + Thread.sleep(forTimeInterval: 0.1) + expect(received.value) == 2 + } + } + + describe("observe handler") { + then("delivers updates across concurrent writes without dropping") { + let key = DefaultsKey("counter", defaultValue: 0) + let received = Locked(0) + + let observer = userDefaults.observe(key) { _ in + received.value += 1 + } + defer { observer.dispose() } + + DispatchQueue.concurrentPerform(iterations: 20) { i in + userDefaults[key] = i + } + + expect(received.value).toEventually(equal(20), timeout: 3) + } + } + } +} diff --git a/Tests/SwiftyUserDefaultsTests/Concurrency/SwiftyUserDefaultWrapperSpec.swift b/Tests/SwiftyUserDefaultsTests/Concurrency/SwiftyUserDefaultWrapperSpec.swift new file mode 100644 index 00000000..9fe23b7b --- /dev/null +++ b/Tests/SwiftyUserDefaultsTests/Concurrency/SwiftyUserDefaultWrapperSpec.swift @@ -0,0 +1,133 @@ +// +// SwiftyUserDefaults +// +// Copyright (c) 2015-present Radosław Pietruszewski, Łukasz Mróz +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation +import Nimble +import Quick +@testable import SwiftyUserDefaults + +/// Key store used by the SwiftyUserDefault property wrapper specs. +final class WrapperSpecKeys: DefaultsKeyStore, @unchecked Sendable { + let counter = DefaultsKey("wrapper.counter", defaultValue: 0) + let label = DefaultsKey("wrapper.label", defaultValue: "") +} + +/// A class that uses the @SwiftyUserDefault property wrapper. Declared at +/// file scope so the wrapper observer's `[weak self]` capture has a stable +/// owner across test cases. +final class WrapperHost: @unchecked Sendable { + static let keyStore = WrapperSpecKeys() + nonisolated(unsafe) static var sharedAdapter: DefaultsAdapter! + + @SwiftyUserDefault(keyPath: \WrapperSpecKeys.counter, adapter: WrapperHost.sharedAdapter, options: [.cached, .observed]) + var counter: Int + + @SwiftyUserDefault(keyPath: \WrapperSpecKeys.label, adapter: WrapperHost.sharedAdapter, options: []) + var label: String +} + +/// The wrapper's getter/setter route through the global `Defaults`, so the +/// specs rebind it to a per-test `UserDefaults` suite. +private func rebindGlobalDefaults(to userDefaults: UserDefaults) { + Defaults = DefaultsAdapter(defaults: userDefaults, keyStore: DefaultsKeys()) +} + +/// Exercises the SwiftyUserDefault property wrapper under concurrent access +/// and verifies the lock change (NSLock → NSRecursiveLock) closes the +/// cache/storage interleave window opened by the migration's initial lock. +final class SwiftyUserDefaultWrapperSpec: QuickSpec { + override func spec() { + var userDefaults: UserDefaults! + + beforeEach { + userDefaults = UserDefaults(suiteName: UUID().uuidString)! + userDefaults.cleanObjects() + WrapperHost.sharedAdapter = DefaultsAdapter(defaults: userDefaults, keyStore: WrapperHost.keyStore) + rebindGlobalDefaults(to: userDefaults) + } + + afterEach { + // Other specs assume `Defaults` points at `UserDefaults.standard`. + rebindGlobalDefaults(to: .standard) + } + + describe("@SwiftyUserDefault setter") { + then("setting via wrapper writes through to UserDefaults") { + let host = WrapperHost() + host.counter = 7 + + expect(userDefaults.integer(forKey: WrapperHost.keyStore.counter._key)) == 7 + } + + then("getter reflects external writes when .observed is set") { + let host = WrapperHost() + userDefaults[WrapperHost.keyStore.counter] = 42 + + expect(host.counter).toEventually(equal(42)) + } + + then("concurrent setters from many threads leave the cache and storage consistent") { + let host = WrapperHost() + let target = 99 + + DispatchQueue.concurrentPerform(iterations: 50) { _ in + host.counter = target + } + + // After all writes settle, cache and storage agree on the final value. + let finalCache = host.counter + let finalStorage = userDefaults.integer(forKey: WrapperHost.keyStore.counter._key) + expect(finalCache) == target + expect(finalStorage) == target + } + + then("concurrent read and write does not crash") { + let host = WrapperHost() + let reads = Locked(0) + + DispatchQueue.concurrentPerform(iterations: 100) { i in + if i.isMultiple(of: 2) { + host.counter = i + } else { + _ = host.counter + reads.value += 1 + } + } + + expect(reads.value) > 0 + } + } + + describe("@SwiftyUserDefault with .cached + .observed") { + then("cache is populated by the observer when an external writer updates the key") { + let host = WrapperHost() + _ = host.counter + + userDefaults[WrapperHost.keyStore.counter] = 123 + + expect(host.counter).toEventually(equal(123), timeout: 2) + } + } + } +} From c939f5ef96ade8e42c47590768573e959ddb6af7 Mon Sep 17 00:00:00 2001 From: ulkhan-amiraslanov Date: Wed, 20 May 2026 17:54:05 +0400 Subject: [PATCH 8/8] Tighten concurrency specs Two improvements after self-review: - Replace `expect(true) == true` placeholders in dispose-idempotency tests with `expect(observer).toNot(beNil())`. The real assertion is "the call does not throw or crash"; this keeps a meaningful Nimble expectation and documents intent. - Strengthen the concurrent-setter test to write distinct values (`i + 1`) from each of 200 threads and assert only that cache equals storage at the end (whichever value wins). The previous version had all 50 threads writing the same value, so it could not actually detect a missing or half-scoped lock. - Capture `userDefaults` as a non-optional `let` in the concurrent observe-handler test so the @Sendable closure no longer warns about capturing a non-Sendable `UserDefaults?`. Suite: 983 tests / 182 failures, matching master exactly. --- .../DefaultsObserverConcurrencySpec.swift | 14 +++++++------ .../SwiftyUserDefaultWrapperSpec.swift | 21 ++++++++++++------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/Tests/SwiftyUserDefaultsTests/Concurrency/DefaultsObserverConcurrencySpec.swift b/Tests/SwiftyUserDefaultsTests/Concurrency/DefaultsObserverConcurrencySpec.swift index 3de8d0db..c1be29d5 100644 --- a/Tests/SwiftyUserDefaultsTests/Concurrency/DefaultsObserverConcurrencySpec.swift +++ b/Tests/SwiftyUserDefaultsTests/Concurrency/DefaultsObserverConcurrencySpec.swift @@ -47,8 +47,9 @@ final class DefaultsObserverConcurrencySpec: QuickSpec { observer.dispose() observer.dispose() observer.dispose() - // No crash, no NSException from removeObserver. - expect(true) == true + // No crash, no NSException from `removeObserver` (the lock + // around `didRemoveObserver` covers the removal too). + expect(observer).toNot(beNil()) } then("is safe under concurrent dispose calls from many threads") { @@ -58,8 +59,8 @@ final class DefaultsObserverConcurrencySpec: QuickSpec { DispatchQueue.concurrentPerform(iterations: 50) { _ in observer.dispose() } - // No crash, no double-removeObserver. - expect(true) == true + // No `removeObserver` double-removal exception under contention. + expect(observer).toNot(beNil()) } then("stops delivering updates after dispose") { @@ -89,14 +90,15 @@ final class DefaultsObserverConcurrencySpec: QuickSpec { then("delivers updates across concurrent writes without dropping") { let key = DefaultsKey("counter", defaultValue: 0) let received = Locked(0) + let defaultsRef = userDefaults! - let observer = userDefaults.observe(key) { _ in + let observer = defaultsRef.observe(key) { _ in received.value += 1 } defer { observer.dispose() } DispatchQueue.concurrentPerform(iterations: 20) { i in - userDefaults[key] = i + defaultsRef[key] = i } expect(received.value).toEventually(equal(20), timeout: 3) diff --git a/Tests/SwiftyUserDefaultsTests/Concurrency/SwiftyUserDefaultWrapperSpec.swift b/Tests/SwiftyUserDefaultsTests/Concurrency/SwiftyUserDefaultWrapperSpec.swift index 9fe23b7b..1353fc91 100644 --- a/Tests/SwiftyUserDefaultsTests/Concurrency/SwiftyUserDefaultWrapperSpec.swift +++ b/Tests/SwiftyUserDefaultsTests/Concurrency/SwiftyUserDefaultWrapperSpec.swift @@ -87,19 +87,26 @@ final class SwiftyUserDefaultWrapperSpec: QuickSpec { expect(host.counter).toEventually(equal(42)) } - then("concurrent setters from many threads leave the cache and storage consistent") { + then("concurrent setters with distinct values leave cache and storage in agreement") { let host = WrapperHost() - let target = 99 - DispatchQueue.concurrentPerform(iterations: 50) { _ in - host.counter = target + // Each thread writes its own distinct value. With a missing or + // half-scoped lock the cache and on-disk value can end up + // pointing at different writers. With the NSRecursiveLock fix + // (lock held across both writes) they must agree, whatever + // the winning value is. + DispatchQueue.concurrentPerform(iterations: 200) { i in + host.counter = i + 1 } - // After all writes settle, cache and storage agree on the final value. + // Let any in-flight KVO callbacks settle before sampling. + Thread.sleep(forTimeInterval: 0.05) + let finalCache = host.counter let finalStorage = userDefaults.integer(forKey: WrapperHost.keyStore.counter._key) - expect(finalCache) == target - expect(finalStorage) == target + expect(finalCache) == finalStorage + expect(finalCache) >= 1 + expect(finalCache) <= 200 } then("concurrent read and write does not crash") {