From 2a62cccc9ce60c5b1c88ee909c1433b1670b8d89 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Thu, 11 Dec 2025 20:34:38 -0700 Subject: [PATCH 1/3] Update to use newer Cache version --- Package.swift | 22 +- README.md | 19 ++ .../Application/Application+internal.swift | 6 +- .../AppState/Application/Application.swift | 4 - Sources/AppState/Dependencies/Keychain.swift | 45 +-- .../ConcurrencyStressTests.swift | 289 ++++++++++++++++++ 6 files changed, 340 insertions(+), 45 deletions(-) create mode 100644 Tests/AppStateTests/ConcurrencyStressTests.swift diff --git a/Package.swift b/Package.swift index 2845cb1..ec51494 100644 --- a/Package.swift +++ b/Package.swift @@ -5,11 +5,11 @@ import PackageDescription let package = Package( name: "AppState", platforms: [ - .iOS(.v15), - .watchOS(.v8), - .macOS(.v11), - .tvOS(.v15), - .visionOS(.v1) + .iOS(.v18), + .watchOS(.v11), + .macOS(.v15), + .tvOS(.v18), + .visionOS(.v2) ], products: [ .library( @@ -18,7 +18,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/0xLeif/Cache", from: "2.0.0"), + .package(url: "https://github.com/0xLeif/Cache", branch: "leif/mutex"), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.4.0") ], targets: [ @@ -26,11 +26,19 @@ let package = Package( name: "AppState", dependencies: [ "Cache" + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableExperimentalFeature("StrictConcurrency") ] ), .testTarget( name: "AppStateTests", - dependencies: ["AppState"] + dependencies: ["AppState"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableExperimentalFeature("StrictConcurrency") + ] ) ] ) diff --git a/README.md b/README.md index bd87c2b..f96d922 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,25 @@ struct ContentView: View { This snippet demonstrates defining a state value in an `Application` extension and using the `@AppState` property wrapper to bind it inside a view. +## Examples + +Explore our comprehensive [Examples](Examples/) folder with 31 example projects: + +| Category | Examples | Description | +|----------|----------|-------------| +| **Focused** | 2 | Production-quality apps (SyncNotes, MultiPlatformTracker) | +| **Moderate** | 4 | Feature-focused apps (TodoCloud, SettingsKit, DataDashboard, SecureVault) | +| **Lightweight** | 25 | Single-concept examples covering all AppState features | + +### Featured Examples + +- **[SyncNotes](Examples/Focused/SyncNotes/)** - Note-taking with iCloud sync across devices +- **[MultiPlatformTracker](Examples/Focused/MultiPlatformTracker/)** - Habit tracker for iOS/macOS/watchOS +- **[TodoCloud](Examples/Moderate/TodoCloud/)** - Progressive persistence (memory → local → cloud) +- **[SecureVault](Examples/Moderate/SecureVault/)** - Password manager with Keychain storage + +See [Examples/README.md](Examples/README.md) for the full list with descriptions. + ## Documentation Here’s a detailed breakdown of **AppState**'s documentation: diff --git a/Sources/AppState/Application/Application+internal.swift b/Sources/AppState/Application/Application+internal.swift index 1762cff..332c521 100644 --- a/Sources/AppState/Application/Application+internal.swift +++ b/Sources/AppState/Application/Application+internal.swift @@ -101,13 +101,11 @@ extension Application { ) } - /// Returns value for the provided keyPath. This method is thread safe + /// Returns value for the provided keyPath. Thread safety is provided by Cache's internal Mutex. /// /// - Parameter keyPath: KeyPath of the value to be fetched func value(keyPath: KeyPath) -> Value { - lock.lock(); defer { lock.unlock() } - - return self[keyPath: keyPath] + self[keyPath: keyPath] } /** diff --git a/Sources/AppState/Application/Application.swift b/Sources/AppState/Application/Application.swift index 8c956d7..589274f 100644 --- a/Sources/AppState/Application/Application.swift +++ b/Sources/AppState/Application/Application.swift @@ -27,9 +27,6 @@ open class Application: NSObject { @MainActor static var isLoggingEnabled: Bool = false - /// A recursive lock to ensure thread-safe access to shared resources within the Application instance. - let lock: NSRecursiveLock - /// The underlying cache used to store all state and dependency values. let cache: Cache @@ -51,7 +48,6 @@ open class Application: NSObject { public required init( setup: (Application) -> Void = { _ in } ) { - lock = NSRecursiveLock() cache = Cache() super.init() diff --git a/Sources/AppState/Dependencies/Keychain.swift b/Sources/AppState/Dependencies/Keychain.swift index 1e794df..e2e8290 100644 --- a/Sources/AppState/Dependencies/Keychain.swift +++ b/Sources/AppState/Dependencies/Keychain.swift @@ -1,6 +1,7 @@ #if !os(Linux) && !os(Windows) import Cache import Foundation +import Synchronization /** A `Keychain` class that adopts the `Cacheable` protocol. @@ -25,23 +26,19 @@ public final class Keychain: Sendable { public typealias Key = String public typealias Value = String - private let lock: NSLock - @MainActor - private var keys: Set - + private let keys: Mutex> + /// Default initializer public init() { - self.lock = NSLock() - self.keys = [] + self.keys = Mutex([]) } - + /** Initialize with a predefined set of keys. - Parameter keys: The predefined set of keys. */ public init(keys: Set) { - self.lock = NSLock() - self.keys = keys + self.keys = Mutex(keys) } /** @@ -57,19 +54,16 @@ public final class Keychain: Sendable { kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne ] - + var dataTypeRef: AnyObject? - - lock.lock() let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) - lock.unlock() - + guard status == noErr, let data = dataTypeRef as? Data, let output = String(data: data, encoding: .utf8) as? Output else { return nil } - + return output } @@ -113,9 +107,7 @@ public final class Keychain: Sendable { SecItemAdd(addQuery as CFDictionary, nil) } - Task { @MainActor in - keys.insert(key) - } + _ = keys.withLock { $0.insert(key) } } /** @@ -127,10 +119,9 @@ public final class Keychain: Sendable { kSecClass: kSecClassGenericPassword, kSecAttrAccount: key ] - - lock.lock() + SecItemDelete(query as CFDictionary) - lock.unlock() + _ = keys.withLock { $0.remove(key) } } /** @@ -174,21 +165,16 @@ public final class Keychain: Sendable { - Parameter ofType: The type of the values expected. - Returns: A dictionary with keys and their associated values. */ - @MainActor public func values(ofType: Output.Type) -> [Key: Output] { - let storedKeys: [Key] + let storedKeys = keys.withLock { Array($0) } var values: [Key: Output] = [:] - - lock.lock() - storedKeys = Array(keys) - lock.unlock() - + for key in storedKeys { if let value = get(key, as: Output.self) { values[key] = value } } - + return values } } @@ -217,7 +203,6 @@ public extension Keychain { Returns all keys and their string values currently in the keychain. - Returns: A dictionary with keys and their corresponding string values. */ - @MainActor func values() -> [Key: String] { values(ofType: String.self) } diff --git a/Tests/AppStateTests/ConcurrencyStressTests.swift b/Tests/AppStateTests/ConcurrencyStressTests.swift new file mode 100644 index 0000000..9620448 --- /dev/null +++ b/Tests/AppStateTests/ConcurrencyStressTests.swift @@ -0,0 +1,289 @@ +import Foundation +import XCTest +@testable import AppState + +fileprivate extension Application { + var stressCounter: State { + state(initial: 0) + } + + var stressString: State { + state(initial: "initial") + } + + var stressArray: State<[Int]> { + state(initial: []) + } +} + +final class ConcurrencyStressTests: XCTestCase { + override func setUp() async throws { + try await super.setUp() + + await MainActor.run { + Application.reset(\.stressCounter) + Application.reset(\.stressString) + Application.reset(\.stressArray) + Application.logging(isEnabled: false) // Disable logging for stress tests + } + } + + override func tearDown() async throws { + await MainActor.run { + Application.reset(\.stressCounter) + Application.reset(\.stressString) + Application.reset(\.stressArray) + } + + try await super.tearDown() + } + + // MARK: - Concurrent State Read/Write + + /// Tests concurrent reads and writes to state don't cause crashes or data races. + @MainActor + func testConcurrentStateReadWrite() async { + let iterations = 500 + + await withTaskGroup(of: Void.self) { group in + // Writers + for i in 0..= 0 && element < iterations) + } + } + + // MARK: - Concurrent Dependency Access + + /// Tests that dependencies remain stable under concurrent access. + @MainActor + func testConcurrentDependencyAccess() async { + let iterations = 500 + var accessCount = 0 + + await withTaskGroup(of: Bool.self) { group in + for _ in 0..= 0) + } +} From 1adff45b6be4334e665ef216d23212ca81a9a3b5 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Thu, 11 Dec 2025 20:39:18 -0700 Subject: [PATCH 2/3] Update CI --- .github/workflows/macOS.yml | 6 +++--- .github/workflows/ubuntu.yml | 4 ++-- .github/workflows/windows.yml | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/macOS.yml b/.github/workflows/macOS.yml index f075aa5..ca9ac4b 100644 --- a/.github/workflows/macOS.yml +++ b/.github/workflows/macOS.yml @@ -10,11 +10,11 @@ jobs: steps: - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 16.0 + xcode-version: 16.2 - name: Set up Swift uses: swift-actions/setup-swift@v2 - with: - swift-version: '6.1.0' + with: + swift-version: '6.2' - uses: actions/checkout@v3 - name: Build run: swift build -v diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml index ca4352b..8e8bf80 100644 --- a/.github/workflows/ubuntu.yml +++ b/.github/workflows/ubuntu.yml @@ -14,8 +14,8 @@ jobs: steps: - name: Set up Swift uses: swift-actions/setup-swift@v2 - with: - swift-version: '6.1.0' + with: + swift-version: '6.2' - uses: actions/checkout@v3 - name: Build for release run: swift build -v -c release diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 01046e3..15cd343 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -11,11 +11,11 @@ jobs: - uses: actions/checkout@v4 # ① Install Swift for Windows - - name: Set up Swift 6.1 + - name: Set up Swift 6.2 uses: compnerd/gha-setup-swift@main with: - branch: swift-6.1-release # release branch - tag: 6.1-RELEASE # exact toolchain tag + branch: swift-6.2-release # release branch + tag: 6.2-RELEASE # exact toolchain tag # ② Build & test - run: swift --version # sanity-check From fe1c66e7c66650713a96383d78bc96efdd9feaeb Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Thu, 11 Dec 2025 20:47:31 -0700 Subject: [PATCH 3/3] Update test and README --- README.md | 33 ++--- .../ConcurrencyStressTests.swift | 118 ++++++++---------- 2 files changed, 57 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index f96d922..13379a0 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ Read this in other languages: [French](documentation/README.fr.md) | [German](do ## Requirements -- **iOS**: 15.0+ -- **watchOS**: 8.0+ -- **macOS**: 11.0+ -- **tvOS**: 15.0+ -- **visionOS**: 1.0+ -- **Swift**: 6.0+ -- **Xcode**: 16.0+ +- **iOS**: 18.0+ +- **watchOS**: 11.0+ +- **macOS**: 15.0+ +- **tvOS**: 18.0+ +- **visionOS**: 2.0+ +- **Swift**: 6.2+ +- **Xcode**: 16.2+ **Non-Apple Platform Support**: Linux & Windows @@ -72,25 +72,6 @@ struct ContentView: View { This snippet demonstrates defining a state value in an `Application` extension and using the `@AppState` property wrapper to bind it inside a view. -## Examples - -Explore our comprehensive [Examples](Examples/) folder with 31 example projects: - -| Category | Examples | Description | -|----------|----------|-------------| -| **Focused** | 2 | Production-quality apps (SyncNotes, MultiPlatformTracker) | -| **Moderate** | 4 | Feature-focused apps (TodoCloud, SettingsKit, DataDashboard, SecureVault) | -| **Lightweight** | 25 | Single-concept examples covering all AppState features | - -### Featured Examples - -- **[SyncNotes](Examples/Focused/SyncNotes/)** - Note-taking with iCloud sync across devices -- **[MultiPlatformTracker](Examples/Focused/MultiPlatformTracker/)** - Habit tracker for iOS/macOS/watchOS -- **[TodoCloud](Examples/Moderate/TodoCloud/)** - Progressive persistence (memory → local → cloud) -- **[SecureVault](Examples/Moderate/SecureVault/)** - Password manager with Keychain storage - -See [Examples/README.md](Examples/README.md) for the full list with descriptions. - ## Documentation Here’s a detailed breakdown of **AppState**'s documentation: diff --git a/Tests/AppStateTests/ConcurrencyStressTests.swift b/Tests/AppStateTests/ConcurrencyStressTests.swift index 9620448..8335ecf 100644 --- a/Tests/AppStateTests/ConcurrencyStressTests.swift +++ b/Tests/AppStateTests/ConcurrencyStressTests.swift @@ -16,6 +16,11 @@ fileprivate extension Application { } } +/// Tests for high-volume operations and true concurrent access. +/// +/// Note: State/Dependency tests use `@MainActor` because the Application API requires it. +/// These tests verify stability under high-volume sequential operations, not parallel execution. +/// Keychain tests are truly concurrent as Keychain methods are nonisolated and Mutex-protected. final class ConcurrencyStressTests: XCTestCase { override func setUp() async throws { try await super.setUp() @@ -24,7 +29,7 @@ final class ConcurrencyStressTests: XCTestCase { Application.reset(\.stressCounter) Application.reset(\.stressString) Application.reset(\.stressArray) - Application.logging(isEnabled: false) // Disable logging for stress tests + Application.logging(isEnabled: false) } } @@ -38,15 +43,16 @@ final class ConcurrencyStressTests: XCTestCase { try await super.tearDown() } - // MARK: - Concurrent State Read/Write + // MARK: - High-Volume MainActor Operations + // These tests verify stability under many sequential operations on MainActor. + // They are NOT true concurrency tests since @MainActor serializes execution. - /// Tests concurrent reads and writes to state don't cause crashes or data races. + /// Tests high-volume reads and writes to state are stable. @MainActor - func testConcurrentStateReadWrite() async { + func testHighVolumeStateReadWrite() async { let iterations = 500 await withTaskGroup(of: Void.self) { group in - // Writers for i in 0..= 0 && element < iterations) } } - // MARK: - Concurrent Dependency Access - - /// Tests that dependencies remain stable under concurrent access. + /// Tests high-volume dependency access is stable. @MainActor - func testConcurrentDependencyAccess() async { + func testHighVolumeDependencyAccess() async { let iterations = 500 var accessCount = 0 await withTaskGroup(of: Bool.self) { group in for _ in 0..= 0) + } + #if !os(Linux) && !os(Windows) - // MARK: - Keychain Concurrent Access + // MARK: - True Concurrent Access (Keychain) + // These tests ARE truly concurrent - Keychain methods are nonisolated and Mutex-protected. - /// Tests Keychain Mutex handles concurrent operations correctly. + /// Tests Keychain Mutex handles true concurrent operations correctly. @MainActor func testKeychainConcurrentAccess() async { let keychain = Keychain() let iterations = 100 await withTaskGroup(of: Void.self) { group in - // Concurrent writes + // Concurrent writes - truly parallel, no @MainActor for i in 0..= 0) - } }