Skip to content

Migrate to Swift 6.2 strict concurrency#1

Open
ulkhan-amiraslanov-paysera wants to merge 8 commits into
masterfrom
swift-6.2-concurrency-migration
Open

Migrate to Swift 6.2 strict concurrency#1
ulkhan-amiraslanov-paysera wants to merge 8 commits into
masterfrom
swift-6.2-concurrency-migration

Conversation

@ulkhan-amiraslanov-paysera
Copy link
Copy Markdown
Collaborator

@ulkhan-amiraslanov-paysera ulkhan-amiraslanov-paysera commented May 19, 2026

Summary

Migrate the library to Swift 6.2 strict concurrency. Consumers can drop @preconcurrency import SwiftyUserDefaults and 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 @unchecked annotations.

Package

  • Bump swift-tools-version to 6.2
  • Set swiftLanguageModes: [.v6]
  • Enable upcoming features: GlobalActorIsolatedTypesUsability, InferIsolatedConformances, InferSendableFromCaptures, NonisolatedNonsendingByDefault, ExistentialAny
  • Bump platform floors to macOS 10.15, iOS 13, tvOS 13, watchOS 6 (required by Swift 6)

Sendable surface

  • DefaultsKeys, DefaultsKeyStore, DefaultsAdapter, DefaultsBridge, DefaultsDisposable, DefaultsProviding, SwiftyUserDefaultOptions made Sendable.
  • Primitive bridges (String, Int, Double, Bool, Data, URL) are plain Sendable.
  • Generic bridges (Object, Array, Codable, KeyedArchiver, RawRepresentable, Optional variants) use conditional Sendable based on their generic parameter — replacing the original blanket @unchecked Sendable. Consumers using non-Sendable T now get a compile-time check instead of a silent unchecked promise.
  • DefaultsObserver is @unchecked Sendable; mutable didRemoveObserver state guarded by NSLock.
  • SwiftyUserDefault property wrapper is @unchecked Sendable; mutable cache and observation reference guarded by NSRecursiveLock.

Stored-type Sendability (source-breaking)

DefaultsKey, DefaultsObserver, and SwiftyUserDefault now require their stored type to be Sendable:

public struct DefaultsKey<ValueType: DefaultsSerializable> where ValueType.T: Sendable { ... }
public final class DefaultsObserver<T: DefaultsSerializable> ... where T == T.T, T: Sendable { ... }
public final class SwiftyUserDefault<T: DefaultsSerializable> ... where T.T == T, T: Sendable { ... }

With this in place, both DefaultsKey and DefaultsObserver.Update become unconditionally Sendable.

Consumers storing 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 generic 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 Key == String extension on Value: 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(): the NSLock now covers the removeObserver call so that an explicit dispose() racing with a deinit-triggered dispose() from another thread cannot both reach removeObserver.
  • SwiftyUserDefault setter: switched from NSLock to NSRecursiveLock and the setter now holds the lock across both the _value cache write and the Defaults[key:] = newValue storage write. The KVO observer callback (.observed mode) 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

  • Defaults global wrapped in nonisolated(unsafe). Kept as var to 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

  • New UncheckedSendable<Value> wrapper, internal-scoped, used to hand UserDefaults across isolation boundaries in DefaultsAdapter. Apple documents UserDefaults as thread-safe but does not mark it Sendable.

Observer handlers

  • All observer handler closures typed @escaping @Sendable (Update) -> Void.
  • The public 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

  • .cached carries a doc-comment explaining that it should be paired with .observed to 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)? for ExistentialAny.
  • Renamed inner shadowed generic TU inside Update.deserialize to 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:

  • The nonisolated(unsafe) var Defaults global — closing it would remove the upstream-documented reassignment pattern.
  • try? JSONDecoder().decode(...) in DefaultsCodableBridge and UserDefaults.decodable — pre-existing silent failure on malformed payloads; reliability concern, not concurrency.
  • ?? key.defaultValue in DefaultsObserver.Update.init — pre-existing fallback that hides deserialize failures.

Source-breaking changes consumers should know about

  1. DefaultsDisposable: Sendable — external conformers must also be Sendable.
  2. DefaultsProviding: Sendable — external conformers must also be Sendable.
  3. DefaultsKey<T> / DefaultsObserver<T> / SwiftyUserDefault<T> now require T.T: Sendable. The typical migration is [String: Any][String: any Sendable] or a typed Codable struct, and Codable class → mark as Sendable.
  4. Platform-floor bumps (macOS 10.15, iOS 13, tvOS 13, watchOS 6).

Test plan

  • swift build succeeds under Swift 6
  • TEST=1 swift test passes the existing Quick/Nimble suite
  • New concurrency specs for SwiftyUserDefault and DefaultsObserver pass
  • Consumer project compiles without @preconcurrency on the import
  • No runtime regressions on observer callbacks (KVO firing thread → handler isolation hop is documented as Sendable)

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.
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Sources/DefaultsKey.swift Outdated
Comment thread Sources/UncheckedSendable.swift Outdated
Both fields are only assigned during initialization. Changing them to
`let` reinforces immutability under `@unchecked Sendable` and prevents
accidental mutation across isolation boundaries.
Comment on lines +60 to +64
set {
lock.lock()
_value = newValue
lock.unlock()
Defaults[key: key] = newValue
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +51 to +56
if options.contains(.cached) {
lock.lock()
let cached = _value
lock.unlock()
return cached ?? Defaults[key: key]
} else {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Sources/Defaults.swift Outdated
Comment on lines +34 to +42

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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Sources/DefaultsBridges.swift Outdated

public struct DefaultsObjectBridge<T>: DefaultsBridge {

public struct DefaultsObjectBridge<T>: DefaultsBridge, @unchecked Sendable {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e358b66. All seven generic bridges (DefaultsObjectBridge, DefaultsArrayBridge, DefaultsCodableBridge, DefaultsKeyedArchiverBridge, DefaultsRawRepresentableBridge, DefaultsRawRepresentableArrayBridge, DefaultsOptionalBridge, DefaultsOptionalArrayBridge) now use conditional Sendable conformance instead of blanket @unchecked.

Comment thread Sources/DefaultsObserver.swift Outdated
/// 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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things:

  1. The handler closure is @Sendable (Update) -> Void, which requires Update to be Sendable. Update contains T.T?, so this only holds if T.T: Sendable. If DefaultsSerializable.T isn't constrained Sendable, this is an implicit promise the type system can't keep — please either constrain DefaultsSerializable.T: Sendable, or add an explicit extension DefaultsObserver.Update: Sendable where T.T: Sendable {}.
  2. KVO delivers observeValue on whichever thread fired the change, so the user-provided handler runs on an unspecified thread. Worth adding a doc-comment on the public observe(_:options:handler:) saying "the handler may be invoked on any thread; hop to your own actor if you need isolation."

Copy link
Copy Markdown
Collaborator Author

@ulkhan-amiraslanov-paysera ulkhan-amiraslanov-paysera May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Sources/DefaultsKey.swift Outdated
/// 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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

@ulkhan-amiraslanov-paysera ulkhan-amiraslanov-paysera May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Sources/UncheckedSendable.swift Outdated
/// 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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e358b66. UncheckedSendable is now internal and no longer @Frozen — it is only used inside DefaultsAdapter and was not intended as a public escape hatch.

a-ulkhan added 6 commits May 20, 2026 14:33
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants