diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 3bee43a..1fdc3d9 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -23,6 +23,17 @@ jobs: runsonlabels: '["macOS", "self-hosted"]' scheme: SpeziFoundation artifactname: SpeziFoundation.xcresult + resultBundle: SpeziFoundation.xcresult + packageios_latest: + name: Build and Test Swift Package iOS Latest + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + with: + runsonlabels: '["macOS", "self-hosted"]' + scheme: SpeziFoundation + xcodeversion: latest + swiftVersion: 6 + artifactname: SpeziFoundation-Latest.xcresult + resultBundle: SpeziFoundation-Latest.xcresult packagewatchos: name: Build and Test Swift Package watchOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 diff --git a/Package.swift b/Package.swift index 7c361a5..fcf7729 100644 --- a/Package.swift +++ b/Package.swift @@ -13,9 +13,9 @@ import PackageDescription #if swift(<6) -let swiftConcurrency: SwiftSetting = .enableExperimentalFeature("SwiftConcurrency") +let swiftConcurrency: SwiftSetting = .enableExperimentalFeature("StrictConcurrency") #else -let swiftConcurrency: SwiftSetting = .enableUpcomingFeature("SwiftConcurrency") +let swiftConcurrency: SwiftSetting = .enableUpcomingFeature("StrictConcurrency") #endif diff --git a/Sources/SpeziFoundation/Misc/TimeoutError.swift b/Sources/SpeziFoundation/Misc/TimeoutError.swift index 0dedba6..8368f00 100644 --- a/Sources/SpeziFoundation/Misc/TimeoutError.swift +++ b/Sources/SpeziFoundation/Misc/TimeoutError.swift @@ -97,7 +97,8 @@ extension TimeoutError: LocalizedError { /// - Parameters: /// - timeout: The duration of the timeout. /// - action: The action to run once the timeout passed. -public func withTimeout(of timeout: Duration, perform action: () async -> Void) async { +@inlinable +public func withTimeout(of timeout: Duration, perform action: @Sendable () async -> Void) async { try? await Task.sleep(for: timeout) guard !Task.isCancelled else { return diff --git a/Sources/SpeziFoundation/Semaphore/AsyncSemaphore.swift b/Sources/SpeziFoundation/Semaphore/AsyncSemaphore.swift index 484f17e..6deb803 100644 --- a/Sources/SpeziFoundation/Semaphore/AsyncSemaphore.swift +++ b/Sources/SpeziFoundation/Semaphore/AsyncSemaphore.swift @@ -50,7 +50,7 @@ import Foundation /// ``` /// /// - Warning: `cancelAll` will trigger a runtime error if it attempts to cancel tasks that are not cancellable. -public final class AsyncSemaphore: @unchecked Sendable { +public final class AsyncSemaphore: Sendable { private enum Suspension { case cancelable(UnsafeContinuation) case regular(UnsafeContinuation) @@ -72,9 +72,9 @@ public final class AsyncSemaphore: @unchecked Sendable { } - private var value: Int - private var suspendedTasks: [SuspendedTask] = [] - private let nsLock = NSLock() + private nonisolated(unsafe) var value: Int + private nonisolated(unsafe) var suspendedTasks: [SuspendedTask] = [] + private let nsLock = NSLock() // protects both of the non-isolated unsafe properties above /// Initializes a new semaphore with a given concurrency limit. @@ -90,17 +90,17 @@ public final class AsyncSemaphore: @unchecked Sendable { /// /// Use this method when access to a resource should be awaited without the possibility of cancellation. public func wait() async { - lock() + unsafeLock() // this is okay, as the continuation body actually runs sync, so we do no have async code within critical region value -= 1 if value >= 0 { - unlock() + unsafeUnlock() return } await withUnsafeContinuation { continuation in suspendedTasks.append(SuspendedTask(id: UUID(), suspension: .regular(continuation))) - unlock() + nsLock.unlock() } } @@ -112,19 +112,19 @@ public final class AsyncSemaphore: @unchecked Sendable { public func waitCheckingCancellation() async throws { try Task.checkCancellation() // check if we are already cancelled - lock() + unsafeLock() // this is okay, as the continuation body actually runs sync, so we do no have async code within critical region do { // check if we got cancelled while acquiring the lock try Task.checkCancellation() } catch { - unlock() + unsafeUnlock() throw error } value -= 1 // decrease the value if value >= 0 { - unlock() + unsafeUnlock() return } @@ -134,28 +134,27 @@ public final class AsyncSemaphore: @unchecked Sendable { try await withUnsafeThrowingContinuation { (continuation: UnsafeContinuation) in if Task.isCancelled { value += 1 // restore the value - unlock() + unsafeUnlock() continuation.resume(throwing: CancellationError()) } else { suspendedTasks.append(SuspendedTask(id: id, suspension: .cancelable(continuation))) - unlock() + unsafeUnlock() } } } onCancel: { - self.lock() - - value += 1 - - guard let index = suspendedTasks.firstIndex(where: { $0.id == id }) else { - preconditionFailure("Inconsistent internal state reached") + let task = nsLock.withLock { + value += 1 + + guard let index = suspendedTasks.firstIndex(where: { $0.id == id }) else { + preconditionFailure("Inconsistent internal state reached") + } + + let task = suspendedTasks[index] + suspendedTasks.remove(at: index) + return task } - let task = suspendedTasks[index] - suspendedTasks.remove(at: index) - - unlock() - switch task.suspension { case .regular: preconditionFailure("Tried to cancel a task that was not cancellable!") @@ -173,18 +172,20 @@ public final class AsyncSemaphore: @unchecked Sendable { /// - Returns: `true` if a task was resumed, `false` otherwise. @discardableResult public func signal() -> Bool { - lock() + let first: SuspendedTask? = nsLock.withLock { + value += 1 + + guard let first = suspendedTasks.first else { + return nil + } - value += 1 + suspendedTasks.removeFirst() + return first + } - guard let first = suspendedTasks.first else { - unlock() + guard let first else { return false } - - suspendedTasks.removeFirst() - unlock() - first.suspension.resume() return true } @@ -193,14 +194,13 @@ public final class AsyncSemaphore: @unchecked Sendable { /// /// This method resumes all `Task`s that are currently waiting for access. public func signalAll() { - lock() - - value += suspendedTasks.count + let tasks = nsLock.withLock { + value += suspendedTasks.count - let tasks = suspendedTasks - self.suspendedTasks.removeAll() - - unlock() + let tasks = suspendedTasks + self.suspendedTasks.removeAll() + return tasks + } for task in tasks { task.suspension.resume() @@ -213,14 +213,13 @@ public final class AsyncSemaphore: @unchecked Sendable { /// /// - Warning: Will trigger a runtime error if it attempts to cancel `Task`s that are not cancellable. public func cancelAll() { - lock() - - value += suspendedTasks.count + let tasks = nsLock.withLock { + value += suspendedTasks.count - let tasks = suspendedTasks - self.suspendedTasks.removeAll() - - unlock() + let tasks = suspendedTasks + self.suspendedTasks.removeAll() + return tasks + } for task in tasks { switch task.suspension { @@ -232,11 +231,11 @@ public final class AsyncSemaphore: @unchecked Sendable { } } - private func lock() { + private func unsafeLock() { // silences a warning, just make sure that you don't have an await in between lock/unlock! nsLock.lock() } - private func unlock() { + private func unsafeUnlock() { nsLock.unlock() } } diff --git a/Tests/SpeziFoundationTests/SharedRepositoryTests.swift b/Tests/SpeziFoundationTests/SharedRepositoryTests.swift index e75a6d5..78f185d 100644 --- a/Tests/SpeziFoundationTests/SharedRepositoryTests.swift +++ b/Tests/SpeziFoundationTests/SharedRepositoryTests.swift @@ -33,10 +33,13 @@ private protocol AnyTestInstance { func testKeyLikeKnowledgeSource() + @MainActor func testComputedKnowledgeSourceComputedOnlyPolicy() + @MainActor func testComputedKnowledgeSourceComputedOnlyPolicyReadOnly() + @MainActor func testComputedKnowledgeSourceStorePolicy() } @@ -66,8 +69,8 @@ final class SharedRepositoryTests: XCTestCase { } struct DefaultedTestStruct: DefaultProvidingKnowledgeSource, TestTypes { - var value: Int static let defaultValue = DefaultedTestStruct(value: 0) + var value: Int } struct ComputedTestStruct: ComputedKnowledgeSource { @@ -76,7 +79,9 @@ final class SharedRepositoryTests: XCTestCase { typealias StoragePolicy = Policy static func compute>(from repository: Repository) -> Int { - computedValue + MainActor.assumeIsolated { + computedValue + } } } @@ -86,7 +91,9 @@ final class SharedRepositoryTests: XCTestCase { typealias StoragePolicy = Policy static func compute>(from repository: Repository) -> Int? { - optionalComputedValue + MainActor.assumeIsolated { + optionalComputedValue + } } } @@ -254,11 +261,12 @@ final class SharedRepositoryTests: XCTestCase { } } - static var computedValue: Int = 3 - static var optionalComputedValue: Int? + @MainActor static var computedValue: Int = 3 + @MainActor static var optionalComputedValue: Int? private var repos: [AnyTestInstance] = [] + @MainActor override func setUp() { repos = [TestInstance(HeapRepository()), TestInstance(ValueRepository())] Self.computedValue = 3 @@ -314,14 +322,17 @@ final class SharedRepositoryTests: XCTestCase { repos.forEach { $0.testKeyLikeKnowledgeSource() } } + @MainActor func testComputedKnowledgeSourceComputedOnlyPolicy() { repos.forEach { $0.testComputedKnowledgeSourceComputedOnlyPolicy() } } + @MainActor func testComputedKnowledgeSourceComputedOnlyPolicyReadOnly() { repos.forEach { $0.testComputedKnowledgeSourceComputedOnlyPolicyReadOnly() } } + @MainActor func testComputedKnowledgeSourceStorePolicy() { repos.forEach { $0.testComputedKnowledgeSourceStorePolicy() } } diff --git a/Tests/SpeziFoundationTests/TimeoutTests.swift b/Tests/SpeziFoundationTests/TimeoutTests.swift index c2fdc54..ce516b8 100644 --- a/Tests/SpeziFoundationTests/TimeoutTests.swift +++ b/Tests/SpeziFoundationTests/TimeoutTests.swift @@ -14,6 +14,7 @@ import XCTest final class TimeoutTests: XCTestCase { @MainActor private var continuation: CheckedContinuation? + @MainActor func operation(for duration: Duration) { Task { @MainActor in try? await Task.sleep(for: duration) @@ -40,6 +41,7 @@ final class TimeoutTests: XCTestCase { } } + @MainActor func testTimeout() async throws { let negativeExpectation = XCTestExpectation() negativeExpectation.isInverted = true