diff --git a/Tests/AppStateTests/ConcurrencyTests.swift b/Tests/AppStateTests/ConcurrencyTests.swift new file mode 100644 index 0000000..c406faf --- /dev/null +++ b/Tests/AppStateTests/ConcurrencyTests.swift @@ -0,0 +1,1434 @@ +import Foundation +import XCTest +@testable import AppState + +/// Thread safety and concurrency tests for AppState + +@MainActor +fileprivate extension Application { + var concurrencyCounter: State { + state(initial: 0) + } + + var concurrencyMessage: State { + state(initial: "initial") + } + + var concurrencyData: State<[String: String]> { + state(initial: [:]) + } + + var concurrencyStoredCounter: StoredState { + storedState(initial: 0, id: "concurrency_stored_counter") + } + + // Dependencies that contain AppState internally + var statefulService: Dependency { + dependency(StatefulService()) + } + + var statefulManager: Dependency { + dependency(StatefulManager()) + } + + // Realistic service with FileState + var stopwatchService: Dependency { + dependency(StopwatchService()) + } + + // AppState for the service + var serviceData: State<[String: String]> { + state(initial: [:]) + } + + var serviceCounter: State { + state(initial: 0) + } + + // AppState for the manager + var managerOperations: State<[String]> { + state(initial: []) + } + + var managerCoordinationCount: State { + state(initial: 0) + } + + // AppState for operation counts - stress test caching + var serviceOperationCount: State { + state(initial: 0) + } + + var managerOperationCount: State { + state(initial: 0) + } + + // FileState for persistent service data + var stopwatchState: FileState { + fileState(initial: StopwatchAppletState(), filename: "stopwatch_state") + } + + var serviceMetrics: FileState { + fileState(initial: ServiceMetrics(), filename: "service_metrics") + } +} + +// MARK: - Test Support Data Structures + +/// Realistic stopwatch state that would be persisted to file +public struct StopwatchAppletState: Codable, Sendable, Equatable { + var isRunning: Bool = false + var startTime: Date? + var elapsedTime: TimeInterval = 0 + var lapTimes: [TimeInterval] = [] + var totalLaps: Int = 0 + + mutating func start() { + isRunning = true + startTime = Date() + } + + mutating func stop() { + isRunning = false + if let start = startTime { + elapsedTime += Date().timeIntervalSince(start) + } + startTime = nil + } + + mutating func addLap() { + if isRunning, let start = startTime { + let lapTime = Date().timeIntervalSince(start) + lapTimes.append(lapTime) + totalLaps += 1 + startTime = Date() // Reset for next lap + } + } + + mutating func reset() { + isRunning = false + startTime = nil + elapsedTime = 0 + lapTimes.removeAll() + totalLaps = 0 + } +} + +/// Service metrics for tracking performance +public struct ServiceMetrics: Codable, Sendable, Equatable { + var totalOperations: Int = 0 + var averageResponseTime: TimeInterval = 0 + var lastUpdated: Date = Date() + var errorCount: Int = 0 + var successCount: Int = 0 + + mutating func recordOperation(responseTime: TimeInterval, success: Bool) { + totalOperations += 1 + if success { + successCount += 1 + } else { + errorCount += 1 + } + + // Update average response time + averageResponseTime = (averageResponseTime * Double(totalOperations - 1) + responseTime) / Double(totalOperations) + lastUpdated = Date() + } +} + +// MARK: - Test Support Classes for Dependencies with AppState + +/// A service that contains AppState internally and manages its own state +@MainActor +final class StatefulService: ObservableObject, @unchecked Sendable { + @AppState(\.serviceOperationCount) var operationCount: Int + @AppState(\.serviceData) var serviceData: [String: String] + @AppState(\.serviceCounter) var serviceCounter: Int + + func performOperation() { + operationCount += 1 + } + + // Service methods that interact with AppState + func updateServiceData(_ key: String, value: String) { + serviceData[key] = value + } + + func incrementServiceCounter() { + serviceCounter += 1 + } + + func getServiceData() -> [String: String] { + serviceData + } + + func getServiceCounter() -> Int { + serviceCounter + } +} + +/// A manager (view model) that contains AppState and coordinates with the StatefulService +@MainActor +final class StatefulManager: ObservableObject, @unchecked Sendable { + @AppState(\.managerOperationCount) var operationCount: Int + @AppState(\.managerOperations) var managerOperations: [String] + @AppState(\.managerCoordinationCount) var managerCoordinationCount: Int + + // Manager methods that interact with AppState and the service + func recordOperation(_ operation: String) { + managerOperations.append("\(operation) at \(Date().timeIntervalSince1970)") + operationCount += 1 + } + + func coordinate() { + managerCoordinationCount += 1 + } + + func getManagerOperations() -> [String] { + managerOperations + } + + func getManagerCoordinationCount() -> Int { + managerCoordinationCount + } + + // Manager coordinates with the service + func coordinateWithService(_ service: StatefulService, operation: String) { + // Record the operation in manager's AppState + recordOperation(operation) + + // Update service's AppState + service.performOperation() // This increments serviceOperationCount + service.updateServiceData("manager_operation", value: operation) + service.incrementServiceCounter() + + // Coordinate + coordinate() + } +} + +/// Realistic service that uses @FileState for persistence +@MainActor +public final class StopwatchService: ObservableObject { + @FileState(\.stopwatchState) public var state: StopwatchAppletState + @FileState(\.serviceMetrics) public var metrics: ServiceMetrics + + private var _operationCount = 0 + + public init() {} + + var operationCount: Int { + _operationCount + } + + // Stopwatch operations + func startStopwatch() { + state.start() + _operationCount += 1 + } + + func stopStopwatch() { + state.stop() + _operationCount += 1 + } + + func addLap() { + state.addLap() + _operationCount += 1 + } + + func resetStopwatch() { + state.reset() + _operationCount += 1 + } + + func recordMetrics(responseTime: TimeInterval, success: Bool) { + metrics.recordOperation(responseTime: responseTime, success: success) + } + + func getStopwatchState() -> StopwatchAppletState { + state + } + + func getMetrics() -> ServiceMetrics { + metrics + } +} + +final class ConcurrencyTests: XCTestCase { + + override func setUp() async throws { + try await super.setUp() + + await MainActor.run { + Application.reset(\.concurrencyCounter) + Application.reset(\.concurrencyMessage) + + // Reset FileState between tests to prevent interference + var stopwatchState = Application.fileState(\.stopwatchState) + stopwatchState.value = StopwatchAppletState() + + var serviceMetrics = Application.fileState(\.serviceMetrics) + serviceMetrics.value = ServiceMetrics() + Application.reset(\.concurrencyData) + Application.reset(\.concurrencyStoredCounter) + Application.reset(\.serviceData) + Application.reset(\.serviceCounter) + Application.reset(\.managerOperations) + Application.reset(\.managerCoordinationCount) + Application.reset(\.serviceOperationCount) + Application.reset(\.managerOperationCount) + Application.reset(\.stopwatchState) + Application.reset(\.serviceMetrics) + Application.logging(isEnabled: true) + } + } + + // MARK: - Basic State Concurrency Tests + + func testStateConcurrency() async { + let tasks = 10 + let iterations = 100 + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Data should contain values") + } + } + + // MARK: - StoredState Concurrency Tests + + func testStoredStateConcurrency() async { + let tasks = 5 + let iterations = 20 + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Stateful service should have performed operations") + XCTAssertTrue(serviceData.count > 0, "Stateful service should have AppState data") + XCTAssertTrue(serviceCounter > 0, "Stateful service should have incremented counter") + XCTAssertTrue(managerOperations.count > 0, "Stateful manager should have recorded operations") + XCTAssertTrue(managerCoordinationCount > 0, "Stateful manager should have coordinated") + XCTAssertEqual(finalCounter, tasks * iterations, "External counter should equal total operations") + XCTAssertTrue(finalMessage.hasPrefix("dependency_"), "External message should have been updated") + } + + func testDependencyAppStateInteraction() async { + let tasks = 12 + let iterations = 15 + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Service should have AppState data") + XCTAssertTrue(managerOperations.count > 0, "Manager should have recorded operations") + XCTAssertEqual(finalCounter, tasks * iterations, "External counter should equal total operations") + XCTAssertTrue(finalData.count > 0, "External data should contain values") + } + + func testRealisticServiceManagerArchitecture() async { + let tasks = 10 + let iterations = 20 + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Service should have AppState data") + XCTAssertTrue(serviceCounter > 0, "Service should have incremented counter") + XCTAssertTrue(managerOperations.count > 0, "Manager should have recorded operations") + XCTAssertTrue(managerCoordinationCount > 0, "Manager should have coordinated") + XCTAssertEqual(finalCounter, tasks * iterations, "External counter should equal total operations") + + // Verify the service has manager operations stored + XCTAssertTrue(serviceData["manager_operation"] != nil, "Service should have manager operation data") + } + + func testDependencyStatefulServiceStressTest() async { + let tasks = 20 + let iterations = 25 + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Service should have performed operations") + XCTAssertTrue(serviceData.count > 0, "Service should have AppState data") + XCTAssertEqual(finalCounter, tasks * iterations, "External counter should equal total operations") + } + + func testDependencyStatefulManagerCoordination() async { + let tasks = 10 + let iterations = 30 + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Manager should have recorded operations") + XCTAssertTrue(managerCoordinationCount > 0, "Manager should have coordinated") + XCTAssertEqual(finalCounter, tasks * iterations, "Counter should equal total operations") + XCTAssertTrue(finalMessage.hasPrefix("coordinated_"), "Message should have been coordinated") + XCTAssertTrue(finalData.count > 0, "Data should contain coordinated values") + } + + func testDependencyRaceConditionPrevention() async { + let tasks = 25 + let iterations = 20 + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Service should have AppState data") + XCTAssertTrue(managerOperations.count > 0, "Manager should have recorded operations") + XCTAssertEqual(finalCounter, tasks * iterations, "No race conditions should have occurred") + } + + func testDependencyDeadlockPrevention() async { + let tasks = 15 + let iterations = 10 + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Service should have performed operations") + XCTAssertTrue(managerOperations.count > 0, "Manager should have recorded operations") + XCTAssertEqual(finalCounter, tasks * iterations, "All operations should complete") + } + + // MARK: - AppState Caching Stress Tests + + func testAppStateCachingStressTest() async { + let tasks = 20 + let iterations = 50 + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Data should be populated") + XCTAssertTrue(serviceOperationCount > 0, "Service should have operations") + XCTAssertTrue(managerOperationCount > 0, "Manager should have operations") + } + + func testAppStateServiceManagerCoordination() async { + let tasks = 15 + let iterations = 30 + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Service should have data from coordination") + XCTAssertTrue(serviceCounter > 0, "Service should have counter updates") + XCTAssertTrue(managerOperations.count > 0, "Manager should have operations") + XCTAssertTrue(managerCoordinationCount > 0, "Manager should have coordination") + XCTAssertTrue(serviceOperationCount > 0, "Service should have operation count") + XCTAssertTrue(managerOperationCount > 0, "Manager should have operation count") + } + + // MARK: - Cache Deadlock Prevention Tests + + /// Test to prevent the specific cache deadlock issue from the stack trace + /// This tests the scenario where FileState operations cause recursive cache access + func testCacheDeadlockPrevention() async { + let tasks = 10 + let iterations = 20 + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Stopwatch state should have been updated") + XCTAssertTrue(finalMetrics.totalOperations > 0, "Metrics should have been updated") + } + + /// Test concurrent FileState operations that could cause cache deadlocks + func testConcurrentFileStateDeadlockPrevention() async { + let tasks = 15 + let iterations = 10 + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Stopwatch should have laps") + XCTAssertTrue(finalMetrics.totalOperations > 0, "Metrics should have operations") + } + + /// Test the specific deadlock scenario from the stack trace + /// This tests FileState operations that could cause recursive cache access + func testFileStateRecursiveCacheDeadlock() async { + let tasks = 8 + let iterations = 15 + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Stopwatch should have laps") + XCTAssertTrue(finalMetrics.totalOperations > 0, "Metrics should have operations") + } + + // MARK: - Realistic FileState Service Tests + + func testStopwatchServiceWithFileState() async { + let tasks = 12 + let iterations = 15 + + // Get the service instance once to ensure we're using the same instance + let stopwatchService = await MainActor.run { Application.dependency(\.stopwatchService) } + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Stopwatch service should have performed operations") + XCTAssertTrue(finalMetrics.totalOperations > 0, "Metrics should have recorded operations") + XCTAssertTrue(finalMetrics.successCount > 0, "Should have some successful operations") + XCTAssertTrue(finalMetrics.averageResponseTime > 0, "Should have recorded response times") + XCTAssertTrue(finalState.totalLaps > 0, "Should have recorded laps") + } + + func testStopwatchServiceConcurrencyStressTest() async { + let tasks = 20 + let iterations = 25 + + // Get the service instance once to ensure we're using the same instance + let stopwatchService = await MainActor.run { Application.dependency(\.stopwatchService) } + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Service should have performed operations") + XCTAssertTrue(finalMetrics.totalOperations > 0, "Metrics should have recorded operations") + XCTAssertTrue(finalMetrics.successCount > 0, "Should have successful operations") + XCTAssertTrue(finalState.totalLaps > 0, "Should have recorded laps") + } + + func testStopwatchServiceFileStatePersistence() async { + let tasks = 8 + let iterations = 20 + + // Get the service instance once to ensure we're using the same instance + let stopwatchService = await MainActor.run { Application.dependency(\.stopwatchService) } + + await withTaskGroup(of: Void.self) { group in + for _ in 0.. 0, "Service should have performed operations") + XCTAssertTrue(finalMetrics.totalOperations > 0, "Metrics should have recorded operations") + XCTAssertTrue(finalMetrics.successCount > 0, "Should have successful operations") + XCTAssertTrue(finalMetrics.errorCount >= 0, "May have some errors") + XCTAssertTrue(finalMetrics.averageResponseTime > 0, "Should have recorded response times") + XCTAssertTrue(finalState.totalLaps > 0, "Should have recorded laps") + XCTAssertTrue(finalState.lapTimes.count > 0, "Should have lap times recorded") + } + + // MARK: - Edge Case Tests + + func testRapidStateChanges() async { + let iterations = 500 + + await withTaskGroup(of: Void.self) { group in + // Rapid read task + group.addTask { + for _ in 0..= 0 && finalValue < iterations, "Final value should be within expected range") + } + } + + func testConcurrentStateAccess() async { + let tasks = 10 + let iterations = 20 + + await withTaskGroup(of: Void.self) { group in + for _ in 0..= 0, "Counter should be accessible") + } + } +}