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 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..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 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..8335ecf --- /dev/null +++ b/Tests/AppStateTests/ConcurrencyStressTests.swift @@ -0,0 +1,271 @@ +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: []) + } +} + +/// 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() + + await MainActor.run { + Application.reset(\.stressCounter) + Application.reset(\.stressString) + Application.reset(\.stressArray) + Application.logging(isEnabled: false) + } + } + + override func tearDown() async throws { + await MainActor.run { + Application.reset(\.stressCounter) + Application.reset(\.stressString) + Application.reset(\.stressArray) + } + + try await super.tearDown() + } + + // 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 high-volume reads and writes to state are stable. + @MainActor + func testHighVolumeStateReadWrite() async { + let iterations = 500 + + await withTaskGroup(of: Void.self) { group in + for i in 0..= 0 && element < iterations) + } + } + + /// Tests high-volume dependency access is stable. + @MainActor + 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: - True Concurrent Access (Keychain) + // These tests ARE truly concurrent - Keychain methods are nonisolated and Mutex-protected. + + /// 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 - truly parallel, no @MainActor + for i in 0..