Migrate to Swift 6.2 strict concurrency#1
Conversation
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<UserDefaults> 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.
There was a problem hiding this comment.
Code Review
This pull request updates the library for Swift 6 strict concurrency by adding Sendable conformances, utilizing the @sendable attribute for closures, and implementing thread-safety via NSLock in observers and property wrappers. It also introduces an UncheckedSendable utility to handle non-Sendable types like UserDefaults. The review feedback suggests enhancing safety and idiomatic correctness by using constant properties (let) for isOptional in DefaultsKey and wrappedValue in UncheckedSendable to ensure immutability.
Both fields are only assigned during initialization. Changing them to `let` reinforces immutability under `@unchecked Sendable` and prevents accidental mutation across isolation boundaries.
| set { | ||
| lock.lock() | ||
| _value = newValue | ||
| lock.unlock() | ||
| Defaults[key: key] = newValue |
There was a problem hiding this comment.
The lock is released before the write to Defaults, which means two concurrent setters can interleave so the cache (_value) ends up holding one value while UserDefaults ends up with the other:
T1: lock; _value=A; unlock; ──────────────────── Defaults[key]=A
T2: lock; _value=B; unlock; Defaults[key]=B
Result: cache says A, storage says B (or vice versa). The class is declared @unchecked Sendable with a comment claiming the lock makes it safe, but the lock doesn't cover the externally visible mutation. Either hold the lock across the Defaults[key:] write (watch for re-entrancy via KVO observers), or stop caching writes and rely on .observed to repopulate _value.
There was a problem hiding this comment.
Keeping the lock scope as-is. Widening it to cover the Defaults[key:] = newValue write would change the locking model (the observer callback already takes the same lock, so we'd need an NSRecursiveLock to avoid a synchronous-KVO deadlock), and dropping the cache-on-write line would change the observable behavior of the wrapper for callers using .cached without .observed. The interleave you describe is inherent to the upstream cache design and exists pre-migration. Documented the limitation on .cached itself (see e358b66) so callers understand the contract.
| if options.contains(.cached) { | ||
| lock.lock() | ||
| let cached = _value | ||
| lock.unlock() | ||
| return cached ?? Defaults[key: key] | ||
| } else { |
There was a problem hiding this comment.
Worth documenting that .cached alone (without .observed) gives a permanently-stale cache the moment anyone writes the key through a different SwiftyUserDefault instance or directly via Defaults. Consider making .observed an implicit requirement of .cached, or at least adding a doc-comment warning on SwiftyUserDefaultOptions.cached.
There was a problem hiding this comment.
Added a doc-comment on .cached pointing this out (e358b66). Made it advisory rather than implicit so we don't change the existing combinator semantics.
|
|
||
| public var Defaults = DefaultsAdapter<DefaultsKeys>(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<DefaultsKeys>(defaults: .standard, keyStore: .init()) | ||
| // swiftlint:enable identifier_name prefixed_toplevel_constant |
There was a problem hiding this comment.
nonisolated(unsafe) var on a public mutable global disables race-checking entirely; nothing prevents a non-startup reassignment from racing with reads on another thread. The doc-comment example ("redefine this global shortcut in your app target") describes shadowing, not mutation — shadowing works with let.
Suggest making this let (consumers shadow with their own var/let in their app target, which is the documented pattern). If keeping it var is required for back-compat, wrap the mutation in an actor / lock-isolated holder and drop the (unsafe) qualifier. Either way, please file/link a follow-up issue per the strict-concurrency migration policy — nonisolated(unsafe) should never be the final state.
There was a problem hiding this comment.
Keeping as nonisolated(unsafe) var to match the upstream API contract (the original library exposes public var Defaults and the docs explicitly invite consumers to reassign it for shared suites). The nonisolated(unsafe) annotation makes the existing race-checking opt-out explicit under Swift 6 rather than changing the surface. A follow-up tightening (actor-isolated holder or let) would be a behavior change and belongs in a separate change.
|
|
||
| public struct DefaultsObjectBridge<T>: DefaultsBridge { | ||
|
|
||
| public struct DefaultsObjectBridge<T>: DefaultsBridge, @unchecked Sendable { |
There was a problem hiding this comment.
These bridges are stateless (only init(), or a single let bridge in the optional wrappers), so they're trivially Sendable whenever T (or Bridge) is. Replace the blanket @unchecked Sendable with conditional conformance:
public struct DefaultsObjectBridge<T>: DefaultsBridge {}
extension DefaultsObjectBridge: Sendable where T: Sendable {}This keeps Swift 6 happy where it matters and gives consumers a compile-time check if they try to use a non-Sendable T, instead of a silent unchecked promise. Same applies to DefaultsArrayBridge, DefaultsCodableBridge, DefaultsKeyedArchiverBridge, DefaultsRawRepresentableBridge, DefaultsRawRepresentableArrayBridge, DefaultsOptionalBridge, DefaultsOptionalArrayBridge.
There was a problem hiding this comment.
Fixed in e358b66. All seven generic bridges (DefaultsObjectBridge, DefaultsArrayBridge, DefaultsCodableBridge, DefaultsKeyedArchiverBridge, DefaultsRawRepresentableBridge, DefaultsRawRepresentableArrayBridge, DefaultsOptionalBridge, DefaultsOptionalArrayBridge) now use conditional Sendable conformance instead of blanket @unchecked.
| /// 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<T: DefaultsSerializable>: NSObject, DefaultsDisposable, @unchecked Sendable where T == T.T { |
There was a problem hiding this comment.
Two things:
- The handler closure is
@Sendable (Update) -> Void, which requiresUpdateto beSendable.UpdatecontainsT.T?, so this only holds ifT.T: Sendable. IfDefaultsSerializable.Tisn't constrainedSendable, this is an implicit promise the type system can't keep — please either constrainDefaultsSerializable.T: Sendable, or add an explicitextension DefaultsObserver.Update: Sendable where T.T: Sendable {}. - KVO delivers
observeValueon whichever thread fired the change, so the user-provided handler runs on an unspecified thread. Worth adding a doc-comment on the publicobserve(_:options:handler:)saying "the handler may be invoked on any thread; hop to your own actor if you need isolation."
There was a problem hiding this comment.
Fixed in e358b66, and then strengthened in 2d58b53.
Initial fix added extension DefaultsObserver.Update: Sendable where T.T: Sendable {} (conditional). The follow-up commit 2d58b53 added T: Sendable to DefaultsObserver<T>'s where clause, which makes Update unconditionally Sendable (currently extension DefaultsObserver.Update: Sendable {}).
Also added a threading note on the public observe(_:options:handler:) describing that the handler is invoked on whichever thread posts the KVO notification.
| /// 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<ValueType: DefaultsSerializable>: @unchecked Sendable { |
There was a problem hiding this comment.
All stored properties are let, so the only reason @unchecked is needed is that ValueType.T is unconstrained. Prefer structural conformance:
public struct DefaultsKey<ValueType: DefaultsSerializable> { ... }
extension DefaultsKey: Sendable where ValueType.T: Sendable {}Combined with constraining DefaultsSerializable.T: Sendable (see comment on DefaultsObserver), the @unchecked disappears entirely.
There was a problem hiding this comment.
Fixed in e358b66, then strengthened in 2d58b53.
Initial fix made DefaultsKey conditionally Sendable (extension DefaultsKey: Sendable where ValueType.T: Sendable {}). The follow-up commit 2d58b53 added a generic constraint to the struct itself (where ValueType.T: Sendable), so DefaultsKey is now unconditionally Sendable.
Going via a generic constraint rather than constraining DefaultsSerializable directly because Swift forbids non-marker conditional conformance based on the Sendable marker — extension Dictionary: DefaultsSerializable where Key == String, Value: Sendable hits a compiler wall.
| /// the library has to hold a `UserDefaults` instance, which Apple documents as | ||
| /// thread-safe but does not mark `Sendable`. | ||
| @frozen | ||
| public struct UncheckedSendable<Value>: @unchecked Sendable { |
There was a problem hiding this comment.
Consider making this internal (and dropping @frozen/public since neither matters for library-internal use). The wrapper is only used inside DefaultsAdapter; keeping it public invites consumers to reach for an unchecked escape hatch instead of constraining their types properly. The doc-comment already says it's for the one specific UserDefaults boundary — the access level should match.
- 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.
- 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.
…iftyUserDefault Constrain the three generic types that hold a `DefaultsSerializable` value to require the value's stored type to be `Sendable`: - `DefaultsKey<ValueType: DefaultsSerializable> where ValueType.T: Sendable` - `DefaultsObserver<T: DefaultsSerializable> where T == T.T, T: Sendable` - `SwiftyUserDefault<T: DefaultsSerializable> 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`.
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<Serializable> 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<T>` 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<Serializable?>` 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.
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.
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.
Summary
Migrate the library to Swift 6.2 strict concurrency. Consumers can drop
@preconcurrency import SwiftyUserDefaultsand use the library cleanly under Swift 6 language mode. The migration is now concurrency-hardened: the property-wrapper write path is race-free, observer disposal is race-free, and the public Sendable surface is expressed at the type level rather than via blanket@uncheckedannotations.Package
swift-tools-versionto 6.2swiftLanguageModes: [.v6]GlobalActorIsolatedTypesUsability,InferIsolatedConformances,InferSendableFromCaptures,NonisolatedNonsendingByDefault,ExistentialAnySendable surface
DefaultsKeys,DefaultsKeyStore,DefaultsAdapter,DefaultsBridge,DefaultsDisposable,DefaultsProviding,SwiftyUserDefaultOptionsmadeSendable.String,Int,Double,Bool,Data,URL) are plainSendable.Object,Array,Codable,KeyedArchiver,RawRepresentable,Optionalvariants) use conditionalSendablebased on their generic parameter — replacing the original blanket@unchecked Sendable. Consumers using non-SendableTnow get a compile-time check instead of a silent unchecked promise.DefaultsObserveris@unchecked Sendable; mutabledidRemoveObserverstate guarded byNSLock.SwiftyUserDefaultproperty wrapper is@unchecked Sendable; mutable cache and observation reference guarded byNSRecursiveLock.Stored-type Sendability (source-breaking)
DefaultsKey,DefaultsObserver, andSwiftyUserDefaultnow require their stored type to beSendable:With this in place, both
DefaultsKeyandDefaultsObserver.Updatebecome unconditionallySendable.Consumers storing non-Sendable values via
DefaultsKey(typically[String: Any]or non-SendableCodableclasses) will need to switch to a Sendable representation ([String: any Sendable]or a typedCodablestruct).The constraint is applied at the generic type level rather than on
DefaultsSerializableitself because Swift forbids conditional non-marker conformance based on a marker protocol likeSendable, which prevents constraining theDictionary where Key == Stringextension onValue: Sendable.Race fixes in the new lock paths
The migration added locks where the upstream library had none. Two locks were originally added imperfectly and have since been tightened:
DefaultsObserver.dispose(): theNSLocknow covers theremoveObservercall so that an explicitdispose()racing with a deinit-triggereddispose()from another thread cannot both reachremoveObserver.SwiftyUserDefaultsetter: switched fromNSLocktoNSRecursiveLockand the setter now holds the lock across both the_valuecache write and theDefaults[key:] = newValuestorage write. The KVO observer callback (.observedmode) re-enters the same lock on the same thread synchronously, which is why a non-recursive lock would have deadlocked here.The result: in
.cached(with or without.observed) mode, two concurrent setters serialize and the cache and storage cannot diverge.Global state
Defaultsglobal wrapped innonisolated(unsafe). Kept asvarto match the upstream API contract (the original library invites consumers to reassign it for shared-suite use). The(unsafe)annotation makes the existing race-checking opt-out explicit under Swift 6 rather than changing the surface.Boundary helpers
UncheckedSendable<Value>wrapper,internal-scoped, used to handUserDefaultsacross isolation boundaries inDefaultsAdapter. Apple documentsUserDefaultsas thread-safe but does not mark itSendable.Observer handlers
@escaping @Sendable (Update) -> Void.observe(_:options:handler:)is now documented: the handler is invoked on whichever thread posts the KVO notification (typically the writer's thread), not guaranteed to be the main thread. Dispatch onto your own actor/queue inside the handler if you need a specific isolation.Property-wrapper option doc
.cachedcarries a doc-comment explaining that it should be paired with.observedto avoid a permanently-stale cache after out-of-band writes (other code paths, app extensions writing through the same suite, or another process).Other
DefaultsDisposable?→(any DefaultsDisposable)?forExistentialAny.T→UinsideUpdate.deserializeto satisfy stricter generic resolution.What is NOT changed (intentional behavior preservation)
These belong in separate follow-up changes if pursued, because each alters runtime semantics rather than concurrency annotations:
nonisolated(unsafe) var Defaultsglobal — closing it would remove the upstream-documented reassignment pattern.try? JSONDecoder().decode(...)inDefaultsCodableBridgeandUserDefaults.decodable— pre-existing silent failure on malformed payloads; reliability concern, not concurrency.?? key.defaultValueinDefaultsObserver.Update.init— pre-existing fallback that hides deserialize failures.Source-breaking changes consumers should know about
DefaultsDisposable: Sendable— external conformers must also be Sendable.DefaultsProviding: Sendable— external conformers must also be Sendable.DefaultsKey<T>/DefaultsObserver<T>/SwiftyUserDefault<T>now requireT.T: Sendable. The typical migration is[String: Any]→[String: any Sendable]or a typed Codable struct, andCodable class→ mark asSendable.Test plan
swift buildsucceeds under Swift 6TEST=1 swift testpasses the existing Quick/Nimble suiteSwiftyUserDefaultandDefaultsObserverpass@preconcurrencyon the import