diff --git a/.github/workflows/carthage.yml b/.github/workflows/carthage.yml index ba4f8d5e4..4fb93c634 100644 --- a/.github/workflows/carthage.yml +++ b/.github/workflows/carthage.yml @@ -13,7 +13,7 @@ on: jobs: carthage: name: Carthage Build - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 diff --git a/.github/workflows/ci-swiftpm.yml b/.github/workflows/ci-swiftpm.yml index dcb26af60..d57d2a66a 100644 --- a/.github/workflows/ci-swiftpm.yml +++ b/.github/workflows/ci-swiftpm.yml @@ -11,24 +11,24 @@ on: - "*" jobs: - swiftpm_darwin_ventura: + swiftpm_darwin_sonoma: name: SwiftPM, Darwin, Xcode ${{ matrix.xcode }} - runs-on: macos-13 + runs-on: macos-14 strategy: matrix: - xcode: ["14.3.1"] + xcode: ["16.1"] env: DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app" steps: - uses: actions/checkout@v4 - run: ./test swiftpm - swiftpm_darwin_sonoma: + swiftpm_darwin_sequoia: name: SwiftPM, Darwin, Xcode ${{ matrix.xcode }} - runs-on: macos-14 + runs-on: macos-15 strategy: matrix: - xcode: ["15.3", "16.1"] + xcode: ["16.3"] env: DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app" steps: @@ -41,10 +41,9 @@ jobs: strategy: matrix: container: - - swift:5.7 - - swift:5.8 - - swift:5.9 - swift:6.0 + - swift:6.1 + - swift:6.2 # - swiftlang/swift:nightly fail-fast: false container: ${{ matrix.container }} @@ -61,7 +60,7 @@ jobs: - name: Install Swift uses: compnerd/gha-setup-swift@main with: - branch: swift-5.9-release - tag: 5.9-RELEASE + branch: swift-6.2-release + tag: 6.2-RELEASE - name: Test Windows run: swift test -Xswiftc -suppress-warnings diff --git a/.github/workflows/ci-xcode.yml b/.github/workflows/ci-xcode.yml index fc80353cf..bae95891d 100644 --- a/.github/workflows/ci-xcode.yml +++ b/.github/workflows/ci-xcode.yml @@ -11,12 +11,12 @@ on: - "*" jobs: - xcode_ventura: + xcode_sonoma: name: Xcode ${{ matrix.xcode }} (Xcode Project) - runs-on: macos-13 + runs-on: macos-14 strategy: matrix: - xcode: ["14.3.1"] + xcode: ["16.1"] fail-fast: false env: DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app" @@ -27,12 +27,12 @@ jobs: - run: ./test tvos - run: ./test watchos - xcode_sonoma: + xcode_sequoia: name: Xcode ${{ matrix.xcode }} (Xcode Project) - runs-on: macos-14 + runs-on: macos-15 strategy: matrix: - xcode: ["15.4", "16.1"] + xcode: ["16.3"] fail-fast: false env: DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app" @@ -45,10 +45,10 @@ jobs: xcode_spm: name: Xcode ${{ matrix.xcode }} (Swift Package) - runs-on: macos-14 + runs-on: macos-15 strategy: matrix: - xcode: ["16.1"] + xcode: ["16.3"] fail-fast: false env: DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app" diff --git a/.github/workflows/cocoapods.yml b/.github/workflows/cocoapods.yml index 6310578ba..31c6d9876 100644 --- a/.github/workflows/cocoapods.yml +++ b/.github/workflows/cocoapods.yml @@ -13,7 +13,7 @@ on: jobs: cocoapods: name: CocoaPods Lint - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eeee90184..d35b5606a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,11 +6,11 @@ on: jobs: carthage_archive: - name: Darwin, Xcode 14.0 - runs-on: macos-14 + name: Darwin, Xcode 16.x + runs-on: macos-15 strategy: matrix: - xcode: ["16.1"] + xcode: ["16.3"] env: DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app" steps: diff --git a/Nimble.xcodeproj/project.pbxproj b/Nimble.xcodeproj/project.pbxproj index 90edd5607..c2dd74a4b 100644 --- a/Nimble.xcodeproj/project.pbxproj +++ b/Nimble.xcodeproj/project.pbxproj @@ -142,10 +142,10 @@ 898F28B025D9F4C30052B8D0 /* AlwaysFailMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898F28AF25D9F4C30052B8D0 /* AlwaysFailMatcher.swift */; }; 899441EF2902EE4B00C1FAF9 /* AsyncAwaitTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899441EE2902EE4B00C1FAF9 /* AsyncAwaitTest.swift */; }; 899441F82902EF2500C1FAF9 /* DSL+AsyncAwait.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899441F32902EF0900C1FAF9 /* DSL+AsyncAwait.swift */; }; + 89A5126C2E74790600423EDF /* NimbleTimeIntervalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A5126B2E74790200423EDF /* NimbleTimeIntervalTest.swift */; }; 89B8C60F2C6476A6001F12D3 /* Negation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89B8C60E2C6476A6001F12D3 /* Negation.swift */; }; 89B8C6112C6478F2001F12D3 /* NegationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89B8C6102C6478F2001F12D3 /* NegationTest.swift */; }; 89C297CC2A911CDA002A143F /* AsyncTimerSequenceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C297CB2A911CDA002A143F /* AsyncTimerSequenceTest.swift */; }; - 89C297CE2A92AB34002A143F /* AsyncPromiseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C297CD2A92AB34002A143F /* AsyncPromiseTest.swift */; }; 89D8AC852B3211C600410644 /* CwlCatchException in Frameworks */ = {isa = PBXBuildFile; productRef = 89D8AC842B3211C600410644 /* CwlCatchException */; }; 89D8AC872B3211EA00410644 /* CwlPosixPreconditionTesting in Frameworks */ = {isa = PBXBuildFile; platformFilters = (tvos, watchos, ); productRef = 89D8AC862B3211EA00410644 /* CwlPosixPreconditionTesting */; }; 89D8AC892B3211EA00410644 /* CwlPreconditionTesting in Frameworks */ = {isa = PBXBuildFile; platformFilters = (driverkit, ios, maccatalyst, macos, xros, ); productRef = 89D8AC882B3211EA00410644 /* CwlPreconditionTesting */; }; @@ -333,10 +333,10 @@ 898F28AF25D9F4C30052B8D0 /* AlwaysFailMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlwaysFailMatcher.swift; sourceTree = ""; }; 899441EE2902EE4B00C1FAF9 /* AsyncAwaitTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAwaitTest.swift; sourceTree = ""; }; 899441F32902EF0900C1FAF9 /* DSL+AsyncAwait.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DSL+AsyncAwait.swift"; sourceTree = ""; }; + 89A5126B2E74790200423EDF /* NimbleTimeIntervalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NimbleTimeIntervalTest.swift; sourceTree = ""; }; 89B8C60E2C6476A6001F12D3 /* Negation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Negation.swift; sourceTree = ""; }; 89B8C6102C6478F2001F12D3 /* NegationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NegationTest.swift; sourceTree = ""; }; 89C297CB2A911CDA002A143F /* AsyncTimerSequenceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTimerSequenceTest.swift; sourceTree = ""; }; - 89C297CD2A92AB34002A143F /* AsyncPromiseTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncPromiseTest.swift; sourceTree = ""; }; 89EEF5A42A03293100988224 /* AsyncMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncMatcher.swift; sourceTree = ""; }; 89EEF5B22A032C2500988224 /* AsyncPredicateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncPredicateTest.swift; sourceTree = ""; }; 89EEF5BB2A06210D00988224 /* AsyncHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncHelpers.swift; sourceTree = ""; }; @@ -484,6 +484,7 @@ children = ( CDC157902511957100EAA480 /* DSLTest.swift */, 89F5E096290C37B8001F9377 /* OnFailureThrowsTest.swift */, + 89A5126B2E74790200423EDF /* NimbleTimeIntervalTest.swift */, 89F5E06C290765BB001F9377 /* PollingTest.swift */, 892282892B2833B7002DA355 /* PollingTest+Require.swift */, CDBC39B82462EA7D00069677 /* PredicateTest.swift */, @@ -492,7 +493,6 @@ 899441EE2902EE4B00C1FAF9 /* AsyncAwaitTest.swift */, 8922828E2B283956002DA355 /* AsyncAwaitTest+Require.swift */, 89C297CB2A911CDA002A143F /* AsyncTimerSequenceTest.swift */, - 89C297CD2A92AB34002A143F /* AsyncPromiseTest.swift */, 965B0D0B1B62C06D0005AE66 /* UserDescriptionTest.swift */, 895644DE2C1B71DE0006EC12 /* SwiftTestingSupportTest.swift */, 6CAEDD091CAEA86F003F1584 /* LinuxSupport.swift */, @@ -939,7 +939,6 @@ 8969624A2A5FAD5F00A7929D /* AsyncAllPassTest.swift in Sources */, 1F4A56671A3B305F009E1637 /* ObjCAsyncTest.m in Sources */, 1F925EFD195C186800ED456B /* BeginWithTest.swift in Sources */, - 89C297CE2A92AB34002A143F /* AsyncPromiseTest.swift in Sources */, 89F5E06D290765BB001F9377 /* PollingTest.swift in Sources */, DDB4D5F119FE442800E9D9FE /* MatchTest.swift in Sources */, 1F4A56741A3B3210009E1637 /* ObjCBeginWithTest.m in Sources */, @@ -1004,6 +1003,7 @@ DD72EC651A93874A002F7651 /* AllPassTest.swift in Sources */, 1F4A569E1A3B3565009E1637 /* ObjCMatchTest.m in Sources */, 1F925EEA195C124400ED456B /* BeAnInstanceOfTest.swift in Sources */, + 89A5126C2E74790600423EDF /* NimbleTimeIntervalTest.swift in Sources */, 8923E6102B47D08300F3961A /* MapTest.swift in Sources */, 29EA59641B551ED2002D767E /* ThrowErrorTest.swift in Sources */, 6CAEDD0B1CAEA86F003F1584 /* LinuxSupport.swift in Sources */, diff --git a/Package.swift b/Package.swift index 566f9540f..378137c40 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,10 @@ -// swift-tools-version:5.7 +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "Nimble", platforms: [ - .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6) + .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .visionOS(.v1) ], products: [ .library( @@ -19,7 +19,7 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/mattgallagher/CwlPreconditionTesting.git", .upToNextMajor(from: "2.1.0")), + .package(url: "https://github.com/mattgallagher/CwlPreconditionTesting.git", .upToNextMajor(from: "2.2.0")), .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), ], targets: { @@ -32,14 +32,11 @@ let package = Package( name: "Nimble", dependencies: [ .product(name: "CwlPreconditionTesting", package: "CwlPreconditionTesting", - condition: .when(platforms: [.macOS, .iOS, .macCatalyst])), + condition: .when(platforms: [.macOS, .iOS, .macCatalyst, .visionOS])), .product(name: "CwlPosixPreconditionTesting", package: "CwlPreconditionTesting", condition: .when(platforms: [.tvOS, .watchOS])) ], - exclude: ["Info.plist"], - resources: [ - .copy("PrivacyInfo.xcprivacy") - ] + exclude: ["Info.plist"] ), .target( name: "NimbleSharedTestHelpers", diff --git a/Sources/Nimble/Adapters/NimbleEnvironment.swift b/Sources/Nimble/Adapters/NimbleEnvironment.swift index aa515f2f4..f399a8e1f 100644 --- a/Sources/Nimble/Adapters/NimbleEnvironment.swift +++ b/Sources/Nimble/Adapters/NimbleEnvironment.swift @@ -36,20 +36,4 @@ internal class NimbleEnvironment: NSObject { var suppressTVOSAssertionWarning: Bool = false var suppressWatchOSAssertionWarning: Bool = false - #if !os(WASI) - var awaiter: Awaiter - #endif - - override init() { - #if !os(WASI) - let timeoutQueue = DispatchQueue.global(qos: .userInitiated) - awaiter = Awaiter( - waitLock: AssertionWaitLock(), - asyncQueue: .main, - timeoutQueue: timeoutQueue - ) - #endif - - super.init() - } } diff --git a/Sources/Nimble/DSL+AsyncAwait.swift b/Sources/Nimble/DSL+AsyncAwait.swift index 57b4213df..3c67992ff 100644 --- a/Sources/Nimble/DSL+AsyncAwait.swift +++ b/Sources/Nimble/DSL+AsyncAwait.swift @@ -134,31 +134,17 @@ private enum ErrorResult { private func throwableUntil( timeout: NimbleTimeInterval, sourceLocation: SourceLocation, - action: @escaping (@escaping @Sendable () -> Void) async throws -> Void) async { + action: @escaping @Sendable (@escaping @Sendable () -> Void) async throws -> Void) async { let leeway = timeout.divided let result = await performBlock( - timeoutInterval: timeout, + timeout: timeout, leeway: leeway, - sourceLocation: sourceLocation) { @MainActor (done: @escaping (ErrorResult) -> Void) async throws -> Void in - do { - try await action { - done(.none) - } - } catch let e { - done(.error(e)) - } - } + sourceLocation: sourceLocation, + closure: action + ) switch result { case .incomplete: internalError("Reached .incomplete state for waitUntil(...).") - case .blockedRunLoop: - fail( - blockedRunLoopErrorMessageFor("-waitUntil()", leeway: leeway), - fileID: sourceLocation.fileID, - file: sourceLocation.filePath, - line: sourceLocation.line, - column: sourceLocation.column - ) case .timedOut: fail( "Waited more than \(timeout.description)", @@ -175,15 +161,7 @@ private func throwableUntil( line: sourceLocation.line, column: sourceLocation.column ) - case .completed(.error(let error)): - fail( - "Unexpected error thrown: \(error)", - fileID: sourceLocation.fileID, - file: sourceLocation.filePath, - line: sourceLocation.line, - column: sourceLocation.column - ) - case .completed(.none): // success + case .completed: // success break } } diff --git a/Sources/Nimble/DSL+Wait.swift b/Sources/Nimble/DSL+Wait.swift index 4f912dd76..f42222b5f 100644 --- a/Sources/Nimble/DSL+Wait.swift +++ b/Sources/Nimble/DSL+Wait.swift @@ -36,7 +36,7 @@ public class NMBWait: NSObject { line: UInt = #line, column: UInt = #column, action: @escaping (@escaping () -> Void) -> Void) { - return throwableUntil(timeout: timeout, file: file, line: line) { done in + return throwableUntil(timeout: timeout, file: file, line: line) { done in action(done) } } @@ -49,36 +49,17 @@ public class NMBWait: NSObject { line: UInt = #line, column: UInt = #column, action: @escaping (@escaping () -> Void) throws -> Void) { - let awaiter = NimbleEnvironment.activeInstance.awaiter let leeway = timeout.divided - let result = awaiter.performBlock(file: file, line: line) { (done: @escaping (ErrorResult) -> Void) throws -> Void in - DispatchQueue.main.async { - let capture = NMBExceptionCapture( - handler: ({ exception in - done(.exception(exception)) - }), - finally: ({ }) - ) - capture.tryBlock { - do { - try action { - done(.none) - } - } catch let e { - done(.error(e)) - } - } - } - }.timeout(timeout, forcefullyAbortTimeout: leeway, isContinuous: false).wait( - "waitUntil(...)", - sourceLocation: SourceLocation(fileID: fileID, filePath: file, line: line, column: column) + + let result = synchronousWaitUntil( + timeout: timeout + leeway, + fnName: "waitUntil(...)", + sourceLocation: SourceLocation(fileID: fileID, filePath: file, line: line, column: column), + closure: action ) switch result { case .incomplete: internalError("Reached .incomplete state for waitUntil(...).") - case .blockedRunLoop: - fail(blockedRunLoopErrorMessageFor("-waitUntil()", leeway: leeway), - fileID: fileID, file: file, line: line, column: column) case .timedOut: fail("Waited more than \(timeout.description)", fileID: fileID, file: file, line: line, column: column) @@ -89,16 +70,7 @@ public class NMBWait: NSObject { case let .errorThrown(error): fail("Unexpected error thrown: \(error)", fileID: fileID, file: file, line: line, column: column - ) - case .completed(.exception(let exception)): - fail("Unexpected exception raised: \(exception)", - fileID: fileID, file: file, line: line, column: column - ) - case .completed(.error(let error)): - fail("Unexpected error thrown: \(error)", - fileID: fileID, file: file, line: line, column: column - ) - case .completed(.none): // success + ) case .completed: // success break } } @@ -125,11 +97,6 @@ public class NMBWait: NSObject { #endif } -internal func blockedRunLoopErrorMessageFor(_ fnName: String, leeway: NimbleTimeInterval) -> String { - // swiftlint:disable:next line_length - return "\(fnName) timed out but was unable to run the timeout handler because the main thread is unresponsive. (\(leeway.description) is allowed after the wait times out) Conditions that may cause this include processing blocking IO on the main thread, calls to sleep(), deadlocks, and synchronous IPC. Nimble forcefully stopped the run loop which may cause future failures in test runs." -} - /// Wait asynchronously until the done closure is called or the timeout has been reached. /// /// @discussion @@ -138,7 +105,15 @@ internal func blockedRunLoopErrorMessageFor(_ fnName: String, leeway: NimbleTime /// This function manages the main run loop (`NSRunLoop.mainRunLoop()`) while this function /// is executing. Any attempts to touch the run loop may cause non-deterministic behavior. @available(*, noasync, message: "the sync variant of `waitUntil` does not work in async contexts. Use the async variant as a drop-in replacement") -public func waitUntil(timeout: NimbleTimeInterval = PollingDefaults.timeout, fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, action: @escaping (@escaping () -> Void) -> Void) { +@available(*, deprecated, message: "the synchronous variant of `waitUntil` will be unavailable from Swift in a future release of Nimble. Please use the asynchronous variant as a near drop-in replacement.") +public func waitUntil( + timeout: NimbleTimeInterval = PollingDefaults.timeout, + fileID: String = #fileID, + file: FileString = #filePath, + line: UInt = #line, + column: UInt = #column, + action: @escaping (@escaping () -> Void) -> Void +) { NMBWait.until(timeout: timeout, fileID: fileID, file: file, line: line, column: column, action: action) } diff --git a/Sources/Nimble/Matchers/BeLogical.swift b/Sources/Nimble/Matchers/BeLogical.swift index ea04915e1..b979b3103 100644 --- a/Sources/Nimble/Matchers/BeLogical.swift +++ b/Sources/Nimble/Matchers/BeLogical.swift @@ -42,31 +42,31 @@ extension Int64: Swift.ExpressibleByBooleanLiteral { } } -extension UInt64: ExpressibleByBooleanLiteral { +extension UInt64: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).uint64Value } } -extension Float: ExpressibleByBooleanLiteral { +extension Float: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).floatValue } } -extension Double: ExpressibleByBooleanLiteral { +extension Double: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).doubleValue } } -extension Int: ExpressibleByBooleanLiteral { +extension Int: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).intValue } } -extension UInt: ExpressibleByBooleanLiteral { +extension UInt: @retroactive ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = NSNumber(value: value).uintValue } diff --git a/Sources/Nimble/Polling+AsyncAwait.swift b/Sources/Nimble/Polling+AsyncAwait.swift index d4486b8af..5123fd0e3 100644 --- a/Sources/Nimble/Polling+AsyncAwait.swift +++ b/Sources/Nimble/Polling+AsyncAwait.swift @@ -38,8 +38,7 @@ internal actor Poller { let result = await pollBlock( pollInterval: poll, timeoutInterval: timeout, - sourceLocation: expression.location, - fnName: fnName) { + sourceLocation: expression.location) { if self.updateMatcherResult(result: try await matcherRunner()) .toBoolean(expectation: style) { if matchStyle.isContinuous { diff --git a/Sources/Nimble/Polling.swift b/Sources/Nimble/Polling.swift index 4ff995f1b..19b12ea0c 100644 --- a/Sources/Nimble/Polling.swift +++ b/Sources/Nimble/Polling.swift @@ -148,10 +148,6 @@ internal func processPollResult(_ result: PollResult, matchStyle: AsyncMat return MatcherResult(status: .fail, message: .fail("unexpected error thrown: <\(error)>")) case let .raisedException(exception): return MatcherResult(status: .fail, message: .fail("unexpected exception raised: \(exception)")) - case .blockedRunLoop: - let message = lastMatcherResult?.message.appended(message: " (timed out, but main run loop was unresponsive).") ?? - .fail("main run loop was unresponsive") - return MatcherResult(status: .fail, message: message) case .incomplete: internalError("Reached .incomplete state for \(fnName)(...).") } @@ -176,6 +172,7 @@ extension SyncExpectation { /// This form of `toEventually` does not work in any kind of async context. Use the async form of `toEventually` if you are running tests in an async context. @discardableResult @available(*, noasync, message: "the sync variant of `toEventually` does not work in async contexts. Use the async variant as a drop-in replacement") + @available(*, deprecated, message: "the synchronous variant of `toEventually` will be unavailable from Swift in a future release of Nimble. Please use the asynchronous variant as a near drop-in replacement.") public func toEventually(_ matcher: Matcher, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) -> Self { nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) @@ -209,6 +206,7 @@ extension SyncExpectation { /// Use the async form of `toEventuallyNot` if you are running tests in an async context. @discardableResult @available(*, noasync, message: "the sync variant of `toEventuallyNot` does not work in async contexts. Use the async variant as a drop-in replacement") + @available(*, deprecated, message: "the synchronous variant of `toEventuallyNot` will be unavailable from Swift in a future release of Nimble. Please use the asynchronous variant as a near drop-in replacement.") public func toEventuallyNot(_ matcher: Matcher, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) -> Self { nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) @@ -244,6 +242,7 @@ extension SyncExpectation { /// Use the async form of `toNotEventually` if you are running tests in an async context. @discardableResult @available(*, noasync, message: "the sync variant of `toNotEventually` does not work in async contexts. Use the async variant as a drop-in replacement") + @available(*, deprecated, message: "the synchronous variant of `toNotEventually` will be unavailable from Swift in a future release of Nimble. Please use the asynchronous variant as a near drop-in replacement.") public func toNotEventually(_ matcher: Matcher, timeout: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) -> Self { return toEventuallyNot(matcher, timeout: timeout, pollInterval: pollInterval, description: description) } @@ -260,6 +259,7 @@ extension SyncExpectation { /// Use the async form of `toNever` if you are running tests in an async context. @discardableResult @available(*, noasync, message: "the sync variant of `toNever` does not work in async contexts. Use the async variant as a drop-in replacement") + @available(*, deprecated, message: "the synchronous variant of `toNever` will be unavailable from Swift in a future release of Nimble. Please use the asynchronous variant as a near drop-in replacement.") public func toNever(_ matcher: Matcher, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) -> Self { nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) @@ -295,6 +295,7 @@ extension SyncExpectation { /// Use the async form of `neverTo` if you are running tests in an async context. @discardableResult @available(*, noasync, message: "the sync variant of `neverTo` does not work in async contexts. Use the async variant as a drop-in replacement") + @available(*, deprecated, message: "the synchronous variant of `neverTo` will be unavailable from Swift in a future release of Nimble. Please use the asynchronous variant as a near drop-in replacement.") public func neverTo(_ matcher: Matcher, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) -> Self { return toNever(matcher, until: until, pollInterval: pollInterval, description: description) } @@ -311,6 +312,7 @@ extension SyncExpectation { /// Use the async form of `toAlways` if you are running tests in an async context. @discardableResult @available(*, noasync, message: "the sync variant of `toAlways` does not work in async contexts. Use the async variant as a drop-in replacement") + @available(*, deprecated, message: "the synchronous variant of `toAlways` will be unavailable from Swift in a future release of Nimble. Please use the asynchronous variant as a near drop-in replacement.") public func toAlways(_ matcher: Matcher, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) -> Self { nimblePrecondition(expression.isClosure, "NimbleInternalError", toEventuallyRequiresClosureError.stringValue) @@ -346,6 +348,7 @@ extension SyncExpectation { /// Use the async form of `alwaysTo` if you are running tests in an async context. @discardableResult @available(*, noasync, message: "the sync variant of `alwaysTo` does not work in async contexts. Use the async variant as a drop-in replacement") + @available(*, deprecated, message: "the synchronous variant of `alwaysTo` will be unavailable from Swift in a future release of Nimble. Please use the asynchronous variant as a near drop-in replacement.") public func alwaysTo(_ matcher: Matcher, until: NimbleTimeInterval = PollingDefaults.timeout, pollInterval: NimbleTimeInterval = PollingDefaults.pollInterval, description: String? = nil) -> Self { return toAlways(matcher, until: until, pollInterval: pollInterval, description: description) } diff --git a/Sources/Nimble/Utils/AsyncAwait.swift b/Sources/Nimble/Utils/AsyncAwait.swift index e12c5a0d3..c11c79836 100644 --- a/Sources/Nimble/Utils/AsyncAwait.swift +++ b/Sources/Nimble/Utils/AsyncAwait.swift @@ -1,8 +1,9 @@ #if !os(WASI) - -import CoreFoundation import Dispatch import Foundation +#if canImport(Testing) +@_implementationOnly import Testing +#endif private let timeoutLeeway = NimbleTimeInterval.milliseconds(1) private let pollLeeway = NimbleTimeInterval.milliseconds(1) @@ -14,12 +15,6 @@ internal enum AsyncPollResult { case incomplete /// TimedOut indicates the result reached its defined timeout limit before returning case timedOut - /// BlockedRunLoop indicates the main runloop is too busy processing other blocks to trigger - /// the timeout code. - /// - /// This may also mean the async code waiting upon may have never actually ran within the - /// required time because other timers & sources are running on the main run loop. - case blockedRunLoop /// The async block successfully executed and returned a given result case completed(T) /// When a Swift Error is thrown @@ -43,292 +38,201 @@ internal enum AsyncPollResult { switch self { case .incomplete: return .incomplete case .timedOut: return .timedOut - case .blockedRunLoop: return .blockedRunLoop case .completed(let value): return .completed(value) case .errorThrown(let error): return .errorThrown(error) } } } -// A mechanism to send a single value between 2 tasks. -// Inspired by swift-async-algorithm's AsyncChannel, but massively simplified -// especially given Nimble's usecase. -// AsyncChannel: https://github.com/apple/swift-async-algorithms/blob/main/Sources/AsyncAlgorithms/Channels/AsyncChannel.swift -internal actor AsyncPromise { - private let storage = Storage() +final class BlockingTask: Sendable { + private nonisolated(unsafe) var finished = false + private nonisolated(unsafe) var continuation: CheckedContinuation? = nil + let sourceLocation: SourceLocation + private let lock = NSLock() + + init(sourceLocation: SourceLocation) { + self.sourceLocation = sourceLocation + } - private final class Storage: @unchecked Sendable { - private var continuations: [UnsafeContinuation] = [] - private var value: T? - // Yes, this is not the fastest lock, but it's platform independent, - // which means we don't have to have a Lock protocol and separate Lock - // implementations for Linux & Darwin (and Windows if we ever add - // support for that). - private let lock = NSLock() + func run() async { + let continuation: CheckedContinuation? = { + lock.lock() + let continuation = self.continuation + lock.unlock() + return continuation + }() - func await() async -> T { - await withUnsafeContinuation { continuation in + if let continuation { + continuation.resume() + } + await withTaskCancellationHandler { + await withCheckedContinuation { lock.lock() - defer { lock.unlock() } - if let value { - continuation.resume(returning: value) + + let shouldResume: Bool + if finished { + shouldResume = true } else { - continuations.append(continuation) + self.continuation = $0 + shouldResume = false + } + lock.unlock() + if shouldResume { + $0.resume() } } + } onCancel: { + handleCancellation() } - func send(_ value: T) { - lock.lock() - defer { lock.unlock() } - if self.value != nil { return } - continuations.forEach { continuation in - continuation.resume(returning: value) - } - continuations = [] - self.value = value - } - } - - nonisolated func send(_ value: T) { - self.storage.send(value) - } - - var value: T { - get async { - await self.storage.await() - } - } -} - -/// Wait until the timeout period, then checks why the matcher might have timed out -/// -/// Why Dispatch? -/// -/// Using Dispatch gives us mechanisms for detecting why the matcher timed out. -/// If it timed out because the main thread was blocked, then we want to report that, -/// as that's a performance concern. If it timed out otherwise, then we need to -/// report that. -/// This **could** be done using mechanisms like locks, but instead we use -/// `DispatchSemaphore`. That's because `DispatchSemaphore` is fast and -/// platform independent. However, while `DispatchSemaphore` itself is -/// `Sendable`, the `wait` method is not safe to use in an async context. -/// To get around that, we must ensure that all usages of -/// `DispatchSemaphore.wait` are in synchronous contexts, which -/// we can ensure by dispatching to a `DispatchQueue`. Unlike directly calling -/// a synchronous closure, or using something ilke `MainActor.run`, using -/// a `DispatchQueue` to run synchronous code will actually run it in a -/// synchronous context. -/// -/// -/// Run Loop Management -/// -/// In order to properly interrupt the waiting behavior performed by this factory class, -/// this timer stops the main run loop to tell the waiter code that the result should be -/// checked. -/// -/// In addition, stopping the run loop is used to halt code executed on the main run loop. -private func timeout(timeoutQueue: DispatchQueue, timeoutInterval: NimbleTimeInterval, forcefullyAbortTimeout: NimbleTimeInterval) async -> AsyncPollResult { - do { - try await Task.sleep(nanoseconds: timeoutInterval.nanoseconds) - } catch {} - - let promise = AsyncPromise>() - - let timedOutSem = DispatchSemaphore(value: 0) - let semTimedOutOrBlocked = DispatchSemaphore(value: 0) - semTimedOutOrBlocked.signal() - - let timeoutQueue = DispatchQueue(label: "org.quick.nimble.timeoutQueue", qos: .userInteractive) - timeoutQueue.async { - if semTimedOutOrBlocked.wait(timeout: .now()) == .success { - timedOutSem.signal() - semTimedOutOrBlocked.signal() - promise.send(.timedOut) - } } - // potentially interrupt blocking code on run loop to let timeout code run - timeoutQueue.async { - let abortTimeout = DispatchTime.now() + timeoutInterval.divided.dispatchTimeInterval - let didNotTimeOut = timedOutSem.wait(timeout: abortTimeout) != .success - let timeoutWasNotTriggered = semTimedOutOrBlocked.wait(timeout: .now()) == .success - if didNotTimeOut && timeoutWasNotTriggered { - promise.send(.blockedRunLoop) + func complete() { + lock.lock() + let wasFinished = finished + finished = true + lock.unlock() + + if wasFinished { + fail( + "waitUntil(...) expects its completion closure to be only called once", + location: sourceLocation + ) } else { - promise.send(.timedOut) + self.continuation?.resume() + self.continuation = nil } } - return await promise.value -} - -private func poll(_ pollInterval: NimbleTimeInterval, expression: @escaping () async throws -> PollStatus) async -> AsyncPollResult { - for try await _ in AsyncTimerSequence(interval: pollInterval) { - do { - if case .finished(let result) = try await expression() { - return .completed(result) - } - } catch { - return .errorThrown(error) - } - } - return .completed(false) -} - -/// Blocks for an asynchronous result. -/// -/// @discussion -/// This function cannot be nested. This is because this function (and it's related methods) -/// coordinate through the main run loop. Tampering with the run loop can cause undesirable behavior. -/// -/// This method will return an AwaitResult in the following cases: -/// -/// - The main run loop is blocked by other operations and the async expectation cannot be -/// be stopped. -/// - The async expectation timed out -/// - The async expectation succeeded -/// - The async expectation raised an unexpected exception (objc) -/// - The async expectation raised an unexpected error (swift) -/// -/// The returned AsyncPollResult will NEVER be .incomplete. -private func runPoller( - timeoutInterval: NimbleTimeInterval, - pollInterval: NimbleTimeInterval, - awaiter: Awaiter, - fnName: String, - sourceLocation: SourceLocation, - expression: @escaping () async throws -> PollStatus -) async -> AsyncPollResult { - let timeoutQueue = awaiter.timeoutQueue - return await withTaskGroup(of: AsyncPollResult.self) { taskGroup in - taskGroup.addTask { - await timeout( - timeoutQueue: timeoutQueue, - timeoutInterval: timeoutInterval, - forcefullyAbortTimeout: timeoutInterval.divided - ) - } - - taskGroup.addTask { - await poll(pollInterval, expression: expression) - } + func handleCancellation() { + lock.lock() + let wasFinished = finished + lock.unlock() - defer { - taskGroup.cancelAll() + guard wasFinished == false else { + return } - - return await taskGroup.next() ?? .timedOut + continuation?.resume() + continuation = nil } } -private final class Box: @unchecked Sendable { - private var _value: T - var value: T { +final class ResultTracker: Sendable { + var result: AsyncPollResult { lock.lock() defer { lock.unlock() } - return _value + return _result } + private nonisolated(unsafe) var _result: AsyncPollResult = .incomplete private let lock = NSLock() - init(value: T) { - _value = value - } - func operate(_ closure: @Sendable (T) -> T) { + func finish(with result: AsyncPollResult) { lock.lock() - defer { lock.unlock() } - _value = closure(_value) + defer { + lock.unlock() + } + guard case .incomplete = _result else { + return + } + self._result = result } } -// swiftlint:disable:next function_parameter_count -private func runAwaitTrigger( - awaiter: Awaiter, - timeoutInterval: NimbleTimeInterval, +internal func performBlock( + timeout: NimbleTimeInterval, leeway: NimbleTimeInterval, sourceLocation: SourceLocation, - _ closure: @escaping (@escaping (T) -> Void) async throws -> Void -) async -> AsyncPollResult { - let timeoutQueue = awaiter.timeoutQueue - let completionCount = Box(value: 0) - return await withTaskGroup(of: AsyncPollResult.self) { taskGroup in - let promise = AsyncPromise() + closure: @escaping @Sendable (@escaping @Sendable () -> Void) async throws -> Void +) async -> AsyncPollResult { + precondition(timeout > .seconds(0)) + + #if canImport(Testing) +#if swift(>=6.3) + Issue.record( + "waitUntil(...) becomes less reliable the more tasks and processes your system is running. " + + "This makes it unsuitable for use with Swift Testing. Please use Swift Testing's confirmation(...) API instead.", + severity: .warning, + sourceLocation: SourceLocation( + fileID: sourceLocation.fileID, + filePath: sourceLocation.filePath, + line: sourceLocation.line, + column: sourceLocation.column + ) + ) +#endif + #endif + + return await withTaskGroup(of: Void.self, returning: AsyncPollResult.self) { taskGroup in + let blocker = BlockingTask(sourceLocation: sourceLocation) + let tracker = ResultTracker() taskGroup.addTask { - defer { - promise.send(nil) - } - return await timeout( - timeoutQueue: timeoutQueue, - timeoutInterval: timeoutInterval, - forcefullyAbortTimeout: leeway - ) + await blocker.run() } - taskGroup.addTask { + let task = Task { do { - try await closure { result in - completionCount.operate { $0 + 1 } - if completionCount.value < 2 { - promise.send(result) - } else { - fail( - "waitUntil(..) expects its completion closure to be only called once", - fileID: sourceLocation.fileID, - file: sourceLocation.filePath, - line: sourceLocation.line, - column: sourceLocation.column - ) - } - } - if let value = await promise.value { - return .completed(value) - } else { - return .timedOut + try await closure { + blocker.complete() + tracker.finish(with: .completed(())) } } catch { - return .errorThrown(error) + tracker.finish(with: .errorThrown(error)) } } - defer { - taskGroup.cancelAll() + taskGroup.addTask { + do { + try await Task.sleep(nanoseconds: (timeout + leeway).nanoseconds) + tracker.finish(with: .timedOut) + } catch {} } - return await taskGroup.next() ?? .timedOut - } -} + var result: AsyncPollResult = .incomplete -internal func performBlock( - timeoutInterval: NimbleTimeInterval, - leeway: NimbleTimeInterval, - sourceLocation: SourceLocation, - _ closure: @escaping (@escaping (T) -> Void) async throws -> Void -) async -> AsyncPollResult { - await runAwaitTrigger( - awaiter: NimbleEnvironment.activeInstance.awaiter, - timeoutInterval: timeoutInterval, - leeway: leeway, - sourceLocation: sourceLocation, - closure) + for await _ in taskGroup { + result = tracker.result + if case .incomplete = result { + continue + } + break + } + taskGroup.cancelAll() + task.cancel() + return result + } } internal func pollBlock( pollInterval: NimbleTimeInterval, timeoutInterval: NimbleTimeInterval, sourceLocation: SourceLocation, - fnName: String, - expression: @escaping () async throws -> PollStatus) async -> AsyncPollResult { - await runPoller( - timeoutInterval: timeoutInterval, - pollInterval: pollInterval, - awaiter: NimbleEnvironment.activeInstance.awaiter, - fnName: fnName, - sourceLocation: sourceLocation, - expression: expression - ) + expression: @escaping () async throws -> PollStatus +) async -> AsyncPollResult { + precondition(timeoutInterval > pollInterval) + precondition(pollInterval > .seconds(0)) + let iterations = Int(exactly: (timeoutInterval / pollInterval).rounded(.up)) ?? Int.max + + for iteration in 0.. Double { + switch (lhs, rhs) { + case let (.seconds(lhs), .seconds(rhs)): Double(lhs) / Double(rhs) + case let (.seconds(lhs), .milliseconds(rhs)): Double(lhs * 1_000) / Double(rhs) + case let (.seconds(lhs), .microseconds(rhs)): Double(lhs * 1_000_000) / Double(rhs) + case let (.seconds(lhs), .nanoseconds(rhs)): Double(lhs * 1_000_000_000) / Double(rhs) + + case let (.milliseconds(lhs), .seconds(rhs)): Double(lhs) / Double(rhs * 1_000) + case let (.milliseconds(lhs), .milliseconds(rhs)): Double(lhs) / Double(rhs) + case let (.milliseconds(lhs), .microseconds(rhs)): Double(lhs * 1_000) / Double(rhs) + case let (.milliseconds(lhs), .nanoseconds(rhs)): Double(lhs * 1_000_000) / Double(rhs) + + case let (.microseconds(lhs), .seconds(rhs)): Double(lhs) / Double(rhs * 1_000_000) + case let (.microseconds(lhs), .milliseconds(rhs)): Double(lhs) / Double(rhs * 1_000) + case let (.microseconds(lhs), .microseconds(rhs)): Double(lhs) / Double(rhs) + case let (.microseconds(lhs), .nanoseconds(rhs)): Double(lhs * 1_000) / Double(rhs) + + case let (.nanoseconds(lhs), .seconds(rhs)): Double(lhs) / Double(rhs * 1_000_000_000) + case let (.nanoseconds(lhs), .milliseconds(rhs)): Double(lhs) / Double(rhs * 1_000_000) + case let (.nanoseconds(lhs), .microseconds(rhs)): Double(lhs) / Double(rhs * 1_000) + case let (.nanoseconds(lhs), .nanoseconds(rhs)): Double(lhs) / Double(rhs) + } + } } #if canImport(Foundation) diff --git a/Sources/Nimble/Utils/PollAwait.swift b/Sources/Nimble/Utils/PollAwait.swift index edac3ae0b..c6dd95f49 100644 --- a/Sources/Nimble/Utils/PollAwait.swift +++ b/Sources/Nimble/Utils/PollAwait.swift @@ -1,14 +1,19 @@ #if !os(WASI) +#if canImport(CoreFoundation) import CoreFoundation +#endif import Dispatch import Foundation +#if canImport(Testing) +@_implementationOnly import Testing +#endif private let timeoutLeeway = NimbleTimeInterval.milliseconds(1) private let pollLeeway = NimbleTimeInterval.milliseconds(1) /// Stores debugging information about callers -internal struct WaitingInfo: CustomStringConvertible, Sendable { +private struct WaitingInfo: CustomStringConvertible, Sendable { let name: String let sourceLocation: SourceLocation @@ -17,48 +22,30 @@ internal struct WaitingInfo: CustomStringConvertible, Sendable { } } -internal protocol WaitLock { - func acquireWaitingLock(_ fnName: String, sourceLocation: SourceLocation) - func releaseWaitingLock() - func isWaitingLocked() -> Bool -} - -internal final class AssertionWaitLock: WaitLock, @unchecked Sendable { - private var currentWaiter: WaitingInfo? - private let lock = NSRecursiveLock() - - init() { } - - func acquireWaitingLock(_ fnName: String, sourceLocation: SourceLocation) { - lock.lock() - defer { lock.unlock() } - let info = WaitingInfo(name: fnName, sourceLocation: sourceLocation) - nimblePrecondition( - currentWaiter == nil, - "InvalidNimbleAPIUsage", - """ - Nested async expectations are not allowed to avoid creating flaky tests. - - The call to - \t\(info) - triggered this exception because - \t\(currentWaiter!) - is currently managing the main run loop. - """ - ) - currentWaiter = info - } +@TaskLocal private var currentWaitingInfo: WaitingInfo? = nil - func isWaitingLocked() -> Bool { - lock.lock() - defer { lock.unlock() } - return currentWaiter != nil - } - - func releaseWaitingLock() { - lock.lock() - defer { lock.unlock() } - currentWaiter = nil +private func guaranteeNotNested( + fnName: String, + sourceLocation: SourceLocation, + closure: () -> T +) -> T { + let info = WaitingInfo(name: fnName, sourceLocation: sourceLocation) + nimblePrecondition( + currentWaitingInfo == nil, + "InvalidNimbleAPIUsage", + """ + Nested async expectations are not allowed to avoid creating flaky tests. + + The call to + \t\(info) + triggered this exception because + \t\(currentWaitingInfo!) + is currently managing the main run loop. + """ + ) + + return $currentWaitingInfo.withValue(info) { + closure() } } @@ -67,12 +54,6 @@ internal enum PollResult { case incomplete /// TimedOut indicates the result reached its defined timeout limit before returning case timedOut - /// BlockedRunLoop indicates the main runloop is too busy processing other blocks to trigger - /// the timeout code. - /// - /// This may also mean the async code waiting upon may have never actually ran within the - /// required time because other timers & sources are running on the main run loop. - case blockedRunLoop /// The async block successfully executed and returned a given result case completed(T) /// When a Swift Error is thrown @@ -100,283 +81,79 @@ internal enum PollStatus { case incomplete } -/// Holds the resulting value from an asynchronous expectation. -/// This class is thread-safe at receiving a "response" to this promise. -internal final class AwaitPromise { - private(set) internal var asyncResult: PollResult = .incomplete - private var signal: DispatchSemaphore - - init() { - signal = DispatchSemaphore(value: 1) - } - - deinit { - signal.signal() - } - - /// Resolves the promise with the given result if it has not been resolved. Repeated calls to - /// this method will resolve in a no-op. - /// - /// @returns a Bool that indicates if the async result was accepted or rejected because another - /// value was received first. - @discardableResult - func resolveResult(_ result: PollResult) -> Bool { - if signal.wait(timeout: .now()) == .success { - self.asyncResult = result - return true - } else { - return false - } - } -} - -internal struct PollAwaitTrigger { - let timeoutSource: DispatchSourceTimer - let actionSource: DispatchSourceTimer? - let start: () throws -> Void -} - -/// Factory for building fully configured AwaitPromises and waiting for their results. -/// -/// This factory stores all the state for an async expectation so that Await doesn't -/// doesn't have to manage it. -internal class AwaitPromiseBuilder { - let awaiter: Awaiter - let waitLock: WaitLock - let trigger: PollAwaitTrigger - let promise: AwaitPromise - - internal init( - awaiter: Awaiter, - waitLock: WaitLock, - promise: AwaitPromise, - trigger: PollAwaitTrigger) { - self.awaiter = awaiter - self.waitLock = waitLock - self.promise = promise - self.trigger = trigger - } - - func timeout(_ timeoutInterval: NimbleTimeInterval, forcefullyAbortTimeout: NimbleTimeInterval, isContinuous: Bool) -> Self { - /// = Discussion = - /// - /// There's a lot of technical decisions here that is useful to elaborate on. This is - /// definitely more lower-level than the previous NSRunLoop based implementation. - /// - /// - /// Why Dispatch Source? - /// - /// - /// We're using a dispatch source to have better control of the run loop behavior. - /// A timer source gives us deferred-timing control without having to rely as much on - /// a run loop's traditional dispatching machinery (eg - NSTimers, DefaultRunLoopMode, etc.) - /// which is ripe for getting corrupted by application code. - /// - /// And unlike `dispatch_async()`, we can control how likely our code gets prioritized to - /// executed (see leeway parameter) + DISPATCH_TIMER_STRICT. - /// - /// This timer is assumed to run on the HIGH priority queue to ensure it maintains the - /// highest priority over normal application / test code when possible. - /// - /// - /// Run Loop Management - /// - /// In order to properly interrupt the waiting behavior performed by this factory class, - /// this timer stops the main run loop to tell the waiter code that the result should be - /// checked. - /// - /// In addition, stopping the run loop is used to halt code executed on the main run loop. - trigger.timeoutSource.schedule( - deadline: DispatchTime.now() + timeoutInterval.dispatchTimeInterval, - repeating: .never, - leeway: timeoutLeeway.dispatchTimeInterval - ) - trigger.timeoutSource.setEventHandler { - guard self.promise.asyncResult.isIncomplete() else { return } - let timedOutSem = DispatchSemaphore(value: 0) - let semTimedOutOrBlocked = DispatchSemaphore(value: 0) - semTimedOutOrBlocked.signal() - let runLoop = CFRunLoopGetMain() - #if canImport(Darwin) - let runLoopMode = CFRunLoopMode.defaultMode.rawValue - #else - let runLoopMode = kCFRunLoopDefaultMode - #endif - CFRunLoopPerformBlock(runLoop, runLoopMode) { - if semTimedOutOrBlocked.wait(timeout: .now()) == .success { - timedOutSem.signal() - semTimedOutOrBlocked.signal() - if self.promise.resolveResult(.timedOut) { - CFRunLoopStop(CFRunLoopGetMain()) - } +func synchronousWaitUntil( + timeout: NimbleTimeInterval, + fnName: String, + sourceLocation: SourceLocation, + closure: @escaping (@escaping () -> Void) throws -> Void +) -> PollResult { +#if canImport(Testing) + if Test.current != nil { + fail(""" +The synchronous `waitUntil(...)` is known to not work in Swift Testing's parallel test execution environment. +Please use Swift Testing's `confirmation(...)` APIs to accomplish (nearly) the same thing. +""", + location: sourceLocation) + } +#endif + + return guaranteeNotNested(fnName: fnName, sourceLocation: sourceLocation) { + let runloop = RunLoop.current + + nonisolated(unsafe) var result = PollResult.timedOut + let lock = NSLock() + + let doneBlock: () -> Void = { + let onFinish = { + lock.lock() + defer { lock.unlock() } + if case .completed = result { + fail("waitUntil(...) expects its completion closure to be only called once", location: sourceLocation) + return } +#if canImport(CoreFoundation) + CFRunLoopStop(CFRunLoopGetCurrent()) +#else + RunLoop.main._stop() +#endif + result = .completed(()) } - // potentially interrupt blocking code on run loop to let timeout code run - CFRunLoopStop(runLoop) - let now = DispatchTime.now() + forcefullyAbortTimeout.dispatchTimeInterval - let didNotTimeOut = timedOutSem.wait(timeout: now) != .success - let timeoutWasNotTriggered = semTimedOutOrBlocked.wait(timeout: .now()) == .success - if didNotTimeOut && timeoutWasNotTriggered { - if self.promise.resolveResult(isContinuous ? .timedOut : .blockedRunLoop) { - #if canImport(CoreFoundation) - CFRunLoopStop(CFRunLoopGetMain()) - #else - RunLoop.main._stop() - #endif - } + if Thread.isMainThread { + onFinish() + } else { + DispatchQueue.main.sync { onFinish() } } } - return self - } - /// Blocks for an asynchronous result. - /// - /// @discussion - /// This function cannot be nested. This is because this function (and it's related methods) - /// coordinate through the main run loop. Tampering with the run loop can cause undesirable behavior. - /// - /// This method will return an AwaitResult in the following cases: - /// - /// - The main run loop is blocked by other operations and the async expectation cannot be - /// be stopped. - /// - The async expectation timed out - /// - The async expectation succeeded - /// - The async expectation raised an unexpected exception (objc) - /// - The async expectation raised an unexpected error (swift) - /// - /// The returned PollResult will NEVER be .incomplete. - func wait(_ fnName: String = #function, sourceLocation: SourceLocation) -> PollResult { - waitLock.acquireWaitingLock( - fnName, - sourceLocation: sourceLocation + let capture = NMBExceptionCapture( + handler: ({ exception in + lock.lock() + defer { lock.unlock() } + result = .raisedException(exception) + }), + finally: ({ }) ) - - let capture = NMBExceptionCapture(handler: ({ exception in - _ = self.promise.resolveResult(.raisedException(exception)) - }), finally: ({ - self.waitLock.releaseWaitingLock() - })) capture.tryBlock { do { - try self.trigger.start() - } catch let error { - _ = self.promise.resolveResult(.errorThrown(error)) - } - self.trigger.timeoutSource.resume() - while self.promise.asyncResult.isIncomplete() { - // Stopping the run loop does not work unless we run only 1 mode - _ = RunLoop.current.run(mode: .default, before: .distantFuture) - } - - self.trigger.timeoutSource.cancel() - if let asyncSource = self.trigger.actionSource { - asyncSource.cancel() + try closure(doneBlock) + } catch { + lock.lock() + defer { lock.unlock() } + result = .errorThrown(error) } } - return promise.asyncResult - } -} - -internal class Awaiter { - let waitLock: WaitLock - let timeoutQueue: DispatchQueue - let asyncQueue: DispatchQueue - - internal init( - waitLock: WaitLock, - asyncQueue: DispatchQueue, - timeoutQueue: DispatchQueue) { - self.waitLock = waitLock - self.asyncQueue = asyncQueue - self.timeoutQueue = timeoutQueue - } - - internal func createTimerSource(_ queue: DispatchQueue) -> DispatchSourceTimer { - return DispatchSource.makeTimerSource(flags: .strict, queue: queue) - } - - func performBlock( - file: FileString, - line: UInt, - _ closure: @escaping (@escaping (T) -> Void) throws -> Void - ) -> AwaitPromiseBuilder { - let promise = AwaitPromise() - let timeoutSource = createTimerSource(timeoutQueue) - var completionCount = 0 - let trigger = PollAwaitTrigger(timeoutSource: timeoutSource, actionSource: nil) { - try closure { result in - completionCount += 1 - if completionCount < 2 { - func completeBlock() { - if promise.resolveResult(.completed(result)) { - #if canImport(CoreFoundation) - CFRunLoopStop(CFRunLoopGetMain()) - #else - RunLoop.main._stop() - #endif - } - } - - if Thread.isMainThread { - completeBlock() - } else { - DispatchQueue.main.async { completeBlock() } - } - } else { - fail("waitUntil(..) expects its completion closure to be only called once", - file: file, line: line) - } - } - } - - return AwaitPromiseBuilder( - awaiter: self, - waitLock: waitLock, - promise: promise, - trigger: trigger) - } - - func poll(_ pollInterval: NimbleTimeInterval, closure: @escaping () throws -> T?) -> AwaitPromiseBuilder { - let promise = AwaitPromise() - let timeoutSource = createTimerSource(timeoutQueue) - let asyncSource = createTimerSource(asyncQueue) - let trigger = PollAwaitTrigger(timeoutSource: timeoutSource, actionSource: asyncSource) { - let interval = pollInterval - asyncSource.schedule( - deadline: .now(), - repeating: interval.dispatchTimeInterval, - leeway: pollLeeway.dispatchTimeInterval - ) - asyncSource.setEventHandler { - do { - if let result = try closure() { - if promise.resolveResult(.completed(result)) { - #if canImport(CoreFoundation) - CFRunLoopStop(CFRunLoopGetCurrent()) - #else - RunLoop.current._stop() - #endif - } - } - } catch let error { - if promise.resolveResult(.errorThrown(error)) { - #if canImport(CoreFoundation) - CFRunLoopStop(CFRunLoopGetCurrent()) - #else - RunLoop.current._stop() - #endif - } - } + if Thread.isMainThread { + runloop.run(mode: .default, before: Date(timeIntervalSinceNow: timeout.timeInterval)) + } else { + DispatchQueue.main.sync { + _ = runloop.run(mode: .default, before: Date(timeIntervalSinceNow: timeout.timeInterval)) } - asyncSource.resume() } - return AwaitPromiseBuilder( - awaiter: self, - waitLock: waitLock, - promise: promise, - trigger: trigger) + lock.lock() + defer { lock.unlock() } + return result } } @@ -384,20 +161,37 @@ internal func pollBlock( pollInterval: NimbleTimeInterval, timeoutInterval: NimbleTimeInterval, sourceLocation: SourceLocation, - fnName: String = #function, + fnName: String, isContinuous: Bool, - expression: @escaping () throws -> PollStatus) -> PollResult { - let awaiter = NimbleEnvironment.activeInstance.awaiter - let result = awaiter.poll(pollInterval) { () throws -> Bool? in - if case .finished(let result) = try expression() { - return result - } - return nil + expression: @escaping () throws -> PollStatus +) -> PollResult { + guaranteeNotNested(fnName: fnName, sourceLocation: sourceLocation) { + if Test.current != nil { + fail(""" + The synchronous `\(fnName)` is known to not work in Swift Testing's parallel test execution environment. + Please use the asynchronous `\(fnName)` to accomplish the same thing. + """, + location: sourceLocation) } - .timeout(timeoutInterval, forcefullyAbortTimeout: timeoutInterval.divided, isContinuous: isContinuous) - .wait(fnName, sourceLocation: sourceLocation) + let interval = pollInterval > .nanoseconds(0) ? pollInterval : .nanoseconds(1) + precondition(timeoutInterval > interval) + let iterations = Int(exactly: (timeoutInterval / pollInterval).rounded(.up)) ?? Int.max - return result + for iteration in 0..() - - async let value = promise.value - - promise.send(3) - - let received = await value - expect(received).to(equal(3)) - } - - func testIgnoresFutureValuesSent() async { - let promise = AsyncPromise() - - promise.send(3) - promise.send(4) - - await expecta(await promise.value).to(equal(3)) - } - - func testAllowsValueToBeBackpressured() async { - let promise = AsyncPromise() - - promise.send(3) - - await expecta(await promise.value).to(equal(3)) - } - - func testSupportsMultipleAwaiters() async { - let promise = AsyncPromise() - - async let values = await withTaskGroup(of: Int.self, returning: [Int].self) { taskGroup in - for _ in 0..<10 { - taskGroup.addTask { - await promise.value - } - } - - var values = [Int]() - - for await value in taskGroup { - values.append(value) - } - - return values - } - - promise.send(4) - - let received = await values - - expect(received).to(equal(Array(repeating: 4, count: 10))) - } -} diff --git a/Tests/NimbleTests/NimbleTimeIntervalTest.swift b/Tests/NimbleTests/NimbleTimeIntervalTest.swift new file mode 100644 index 000000000..241ebada7 --- /dev/null +++ b/Tests/NimbleTests/NimbleTimeIntervalTest.swift @@ -0,0 +1,40 @@ +// +// NimbleTimeIntervalTest.swift +// Nimble +// +// Created by Rachel Brindle on 9/12/25. +// Copyright © 2025 Jeff Hui. All rights reserved. +// + +@testable import Nimble +import XCTest + +final class NimbleTimeIntervalTest: XCTestCase { + func testDivideLeftHandSeconds() { + XCTAssertEqual((NimbleTimeInterval.seconds(10) / NimbleTimeInterval.seconds(3)), (10 / 3)) + XCTAssertEqual((NimbleTimeInterval.seconds(1) / NimbleTimeInterval.milliseconds(1)), 1000.0) + XCTAssertEqual((NimbleTimeInterval.seconds(1) / NimbleTimeInterval.microseconds(100)), 10_000) + XCTAssertEqual((NimbleTimeInterval.seconds(10) / NimbleTimeInterval.nanoseconds(200)), 50_000_000) + } + + func testDivideLeftHandMilliseconds() { + XCTAssertEqual((NimbleTimeInterval.milliseconds(1) / NimbleTimeInterval.seconds(1)), 0.001) + XCTAssertEqual((NimbleTimeInterval.milliseconds(1) / NimbleTimeInterval.milliseconds(1)), 1) + XCTAssertEqual((NimbleTimeInterval.milliseconds(1) / NimbleTimeInterval.microseconds(1)), 1_000) + XCTAssertEqual((NimbleTimeInterval.milliseconds(1) / NimbleTimeInterval.nanoseconds(1)), 1_000_000) + } + + func testDivideLeftHandMicroseconds() { + XCTAssertEqual((NimbleTimeInterval.microseconds(1) / NimbleTimeInterval.seconds(1)), 0.000_001) + XCTAssertEqual((NimbleTimeInterval.microseconds(1) / NimbleTimeInterval.milliseconds(1)), 0.001) + XCTAssertEqual((NimbleTimeInterval.microseconds(1) / NimbleTimeInterval.microseconds(1)), 1) + XCTAssertEqual((NimbleTimeInterval.microseconds(1) / NimbleTimeInterval.nanoseconds(1)), 1_000) + } + + func testDivideLeftHandNanoseconds() { + XCTAssertEqual((NimbleTimeInterval.nanoseconds(1) / NimbleTimeInterval.seconds(1)), 0.000_000_001) + XCTAssertEqual((NimbleTimeInterval.nanoseconds(1) / NimbleTimeInterval.milliseconds(1)), 0.000_001) + XCTAssertEqual((NimbleTimeInterval.nanoseconds(1) / NimbleTimeInterval.microseconds(1)), 0.001) + XCTAssertEqual((NimbleTimeInterval.nanoseconds(1) / NimbleTimeInterval.nanoseconds(1)), 1) + } +} diff --git a/Tests/NimbleTests/PollingTest+Require.swift b/Tests/NimbleTests/PollingTest+Require.swift index 2276bc704..c9518693c 100644 --- a/Tests/NimbleTests/PollingTest+Require.swift +++ b/Tests/NimbleTests/PollingTest+Require.swift @@ -1,6 +1,5 @@ #if !os(WASI) -import CoreFoundation import Dispatch import Foundation import XCTest diff --git a/Tests/NimbleTests/PollingTest.swift b/Tests/NimbleTests/PollingTest.swift index b2a3a66d0..e3abae653 100644 --- a/Tests/NimbleTests/PollingTest.swift +++ b/Tests/NimbleTests/PollingTest.swift @@ -1,6 +1,8 @@ #if !os(WASI) +#if canImport(CoreFoundation) import CoreFoundation +#endif import Dispatch import Foundation import XCTest @@ -127,31 +129,10 @@ final class PollingTest: XCTestCase { } } } - - func testWaitUntilDetectsStalledMainThreadActivity() { - let msg = "-waitUntil() timed out but was unable to run the timeout handler because the main thread is unresponsive. (0.5 seconds is allowed after the wait times out) Conditions that may cause this include processing blocking IO on the main thread, calls to sleep(), deadlocks, and synchronous IPC. Nimble forcefully stopped the run loop which may cause future failures in test runs." - failsWithErrorMessage(msg) { - waitUntil(timeout: .seconds(1)) { done in - Thread.sleep(forTimeInterval: 3.0) - done() - } - } - } - - func testToEventuallyDetectsStalledMainThreadActivity() { - func spinAndReturnTrue() -> Bool { - Thread.sleep(forTimeInterval: 0.5) - return true - } - let msg = "expected to eventually be true, got (timed out, but main run loop was unresponsive)." - failsWithErrorMessage(msg) { - expect(spinAndReturnTrue()).toEventually(beTrue()) - } - } func testToNeverDoesNotFailStalledMainThreadActivity() { func spinAndReturnTrue() -> Bool { - Thread.sleep(forTimeInterval: 0.5) + Thread.sleep(forTimeInterval: 0.1) return true } expect(spinAndReturnTrue()).toNever(beFalse()) @@ -159,7 +140,7 @@ final class PollingTest: XCTestCase { func testToAlwaysDetectsStalledMainThreadActivity() { func spinAndReturnTrue() -> Bool { - Thread.sleep(forTimeInterval: 0.5) + Thread.sleep(forTimeInterval: 0.1) return true } expect(spinAndReturnTrue()).toAlways(beTrue()) @@ -193,7 +174,7 @@ final class PollingTest: XCTestCase { } func testWaitUntilErrorsIfDoneIsCalledMultipleTimes() { - failsWithErrorMessage("waitUntil(..) expects its completion closure to be only called once") { + failsWithErrorMessage("waitUntil(...) expects its completion closure to be only called once") { waitUntil { done in deferToMainQueue { done() @@ -225,9 +206,9 @@ final class PollingTest: XCTestCase { } timer.resume() - for index in 0..<100 { + for index in 0..<1000 { if failed { break } - waitUntil(line: UInt(index)) { done in + waitUntil() { done in DispatchQueue(label: "Nimble.waitUntilTest.\(index)").async { done() } diff --git a/test b/test index f48d98a3d..eaa11a7e2 100755 --- a/test +++ b/test @@ -91,7 +91,7 @@ function test_watchos { run set -o pipefail && xcodebuild -project Nimble.xcodeproj -scheme "Nimble" -configuration "Debug" -destination "generic/platform=watchOS" OTHER_SWIFT_FLAGS='$(inherited) -suppress-warnings' build | xcpretty run osascript -e 'tell app "Simulator" to quit' - run set -o pipefail && xcodebuild -project Nimble.xcodeproj -scheme "Nimble" -configuration "Debug" -sdk "watchsimulator$BUILD_WATCHOS_SDK_VERSION" -destination "name=Apple Watch Series 6 (40mm),OS=$RUNTIME_WATCHOS_VERSION" OTHER_SWIFT_FLAGS='$(inherited) -suppress-warnings' build-for-testing test-without-building | xcpretty + run set -o pipefail && xcodebuild -project Nimble.xcodeproj -scheme "Nimble" -configuration "Debug" -sdk "watchsimulator$BUILD_WATCHOS_SDK_VERSION" -destination "name=Apple Watch SE (40mm) (2nd generation),OS=$RUNTIME_WATCHOS_VERSION" OTHER_SWIFT_FLAGS='$(inherited) -suppress-warnings' build-for-testing test-without-building | xcpretty } function test_visionos {