From e54abf227d4c7ad67dabeb37aae67d571c6caa5f Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Sat, 13 Sep 2025 17:58:09 -0700 Subject: [PATCH 1/9] Reimplement polling expectations to make them more reliable under heavy system loads This trades wall-clock time accuracy for reliability. It's nowhere near as important for a polling expectation to finish within the specified duration as it is to actually poll as expected. Basically, under heavy system loads, the actual gap between polling attempts increases. Under sufficiently heavy loads, with a sufficiently low timeout, we might never have polled in the first place. Instead, this calculate how many times we should poll by dividing the timeout by the interval. We then poll exactly that many times, sleeping for however long the polling interval is. If polling interval is ever 0, then we set it to 1 nanosecond. WaitUntil is also re-implemented in both async and sync, but that's because the older infrastructure for doing polling was replaced. Starting in Swift 6.3, we will emit a warning when WaitUntil is used in Swift Testing. This change also removes the ability to detect a blocked runloop, so Nimble will no longer detect for that. In my opinion, this is a worthwhile tradeoff, as blocked runloop was a source of test flakiness. --- Nimble.xcodeproj/project.pbxproj | 8 +- .../Nimble/Adapters/NimbleEnvironment.swift | 16 - Sources/Nimble/DSL+AsyncAwait.swift | 34 +- Sources/Nimble/DSL+Wait.swift | 59 +-- Sources/Nimble/Matchers/BeLogical.swift | 10 +- Sources/Nimble/Polling+AsyncAwait.swift | 3 +- Sources/Nimble/Polling.swift | 11 +- Sources/Nimble/Utils/AsyncAwait.swift | 352 +++++--------- Sources/Nimble/Utils/NimbleTimeInterval.swift | 26 +- Sources/Nimble/Utils/PollAwait.swift | 436 +++++------------- Tests/NimbleTests/AsyncAwaitTest.swift | 8 +- Tests/NimbleTests/AsyncPromiseTest.swift | 59 --- .../NimbleTests/NimbleTimeIntervalTest.swift | 40 ++ Tests/NimbleTests/PollingTest.swift | 31 +- 14 files changed, 347 insertions(+), 746 deletions(-) delete mode 100644 Tests/NimbleTests/AsyncPromiseTest.swift create mode 100644 Tests/NimbleTests/NimbleTimeIntervalTest.swift 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/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..37d858914 100644 --- a/Sources/Nimble/Utils/AsyncAwait.swift +++ b/Sources/Nimble/Utils/AsyncAwait.swift @@ -1,8 +1,10 @@ #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 +16,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 +39,182 @@ 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() - 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() + init(sourceLocation: SourceLocation) { + self.sourceLocation = sourceLocation + } - func await() async -> T { - await withUnsafeContinuation { continuation in + func run() async { + if let continuation = lock.withLock({ self.continuation }) { + continuation.resume() + } + await withTaskCancellationHandler { + await withCheckedContinuation { lock.lock() defer { lock.unlock() } - if let value { - continuation.resume(returning: value) + + if finished { + $0.resume() } else { - continuations.append(continuation) + self.continuation = $0 } } + } 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) - } - } + func complete() { + lock.lock() + defer { lock.unlock() } - // 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) + if finished { + fail( + "waitUntil(...) expects its completion closure to be only called once", + location: sourceLocation + ) } else { - promise.send(.timedOut) - } - } - - 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) + finished = true + self.continuation?.resume() + self.continuation = nil } } - 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() + defer { lock.unlock() } - defer { - taskGroup.cancelAll() + guard finished == 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 + ) +#endif + #endif + + return await withTaskGroup { 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 { 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() + 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 i 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..1734670b0 100644 --- a/Sources/Nimble/Utils/PollAwait.swift +++ b/Sources/Nimble/Utils/PollAwait.swift @@ -3,12 +3,15 @@ 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) /// Stores debugging information about callers -internal struct WaitingInfo: CustomStringConvertible, Sendable { +private struct WaitingInfo: CustomStringConvertible, Sendable { let name: String let sourceLocation: SourceLocation @@ -17,48 +20,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 +52,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 +79,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(runloop.getCFRunLoop()) +#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 +159,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 i 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.swift b/Tests/NimbleTests/PollingTest.swift index b2a3a66d0..357932133 100644 --- a/Tests/NimbleTests/PollingTest.swift +++ b/Tests/NimbleTests/PollingTest.swift @@ -127,31 +127,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 +138,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 +172,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 +204,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() } From cdf30568ce185b4f2e03fe5c2be75c938b40cc4a Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Wed, 24 Sep 2025 14:42:40 -0700 Subject: [PATCH 2/9] Bump xcode version, drop < swift 6.0 --- .github/workflows/ci-swiftpm.yml | 21 ++++++++++----------- .github/workflows/ci-xcode.yml | 16 ++++++++-------- Package.swift | 13 +++++-------- Sources/Nimble/Utils/AsyncAwait.swift | 2 +- 4 files changed, 24 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci-swiftpm.yml b/.github/workflows/ci-swiftpm.yml index dcb26af60..7a242bdd2 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: ["15.3", "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..cc9a40745 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: ["15.4", "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/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/Utils/AsyncAwait.swift b/Sources/Nimble/Utils/AsyncAwait.swift index 37d858914..e322115d6 100644 --- a/Sources/Nimble/Utils/AsyncAwait.swift +++ b/Sources/Nimble/Utils/AsyncAwait.swift @@ -145,7 +145,7 @@ internal func performBlock( #endif #endif - return await withTaskGroup { taskGroup in + return await withTaskGroup(of: Void.self) { taskGroup in let blocker = BlockingTask(sourceLocation: sourceLocation) let tracker = ResultTracker() From 70b135f4f6dcb0cdc6edfb21b558991bcd3333b8 Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Tue, 30 Sep 2025 09:07:56 -0700 Subject: [PATCH 3/9] Update workflows to specify xcode versions including swift 6 Fix build errors on older xcodes/linux --- .github/workflows/carthage.yml | 2 +- .github/workflows/ci-swiftpm.yml | 2 +- .github/workflows/ci-xcode.yml | 2 +- .github/workflows/cocoapods.yml | 2 +- .github/workflows/release.yml | 6 +++--- Sources/Nimble/Utils/AsyncAwait.swift | 8 +++++++- Sources/Nimble/Utils/PollAwait.swift | 2 +- 7 files changed, 15 insertions(+), 9 deletions(-) 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 7a242bdd2..d57d2a66a 100644 --- a/.github/workflows/ci-swiftpm.yml +++ b/.github/workflows/ci-swiftpm.yml @@ -16,7 +16,7 @@ jobs: runs-on: macos-14 strategy: matrix: - xcode: ["15.3", "16.1"] + xcode: ["16.1"] env: DEVELOPER_DIR: "/Applications/Xcode_${{ matrix.xcode }}.app" steps: diff --git a/.github/workflows/ci-xcode.yml b/.github/workflows/ci-xcode.yml index cc9a40745..bae95891d 100644 --- a/.github/workflows/ci-xcode.yml +++ b/.github/workflows/ci-xcode.yml @@ -16,7 +16,7 @@ jobs: runs-on: macos-14 strategy: matrix: - xcode: ["15.4", "16.1"] + xcode: ["16.1"] 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/Sources/Nimble/Utils/AsyncAwait.swift b/Sources/Nimble/Utils/AsyncAwait.swift index e322115d6..00549337c 100644 --- a/Sources/Nimble/Utils/AsyncAwait.swift +++ b/Sources/Nimble/Utils/AsyncAwait.swift @@ -140,7 +140,13 @@ internal func performBlock( 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 + severity: .warning, + sourceLocation: SourceLocation( + fileID: sourceLocation.fileID, + filePath: sourceLocation.filePath, + line: sourceLocation.line, + column: sourceLocation.column + ) ) #endif #endif diff --git a/Sources/Nimble/Utils/PollAwait.swift b/Sources/Nimble/Utils/PollAwait.swift index 1734670b0..ce87ec403 100644 --- a/Sources/Nimble/Utils/PollAwait.swift +++ b/Sources/Nimble/Utils/PollAwait.swift @@ -110,7 +110,7 @@ Please use Swift Testing's `confirmation(...)` APIs to accomplish (nearly) the s return } #if canImport(CoreFoundation) - CFRunLoopStop(runloop.getCFRunLoop()) + CFRunLoopStop(CFRunLoopGetCurrent()) #else RunLoop.main._stop() #endif From 7f21319af299f76536f34a2a0339c3e363379c38 Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Tue, 30 Sep 2025 09:13:08 -0700 Subject: [PATCH 4/9] Fix swiftlint errors --- Sources/Nimble/Utils/AsyncAwait.swift | 4 ++-- Sources/Nimble/Utils/PollAwait.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Nimble/Utils/AsyncAwait.swift b/Sources/Nimble/Utils/AsyncAwait.swift index 00549337c..c1cb42c5d 100644 --- a/Sources/Nimble/Utils/AsyncAwait.swift +++ b/Sources/Nimble/Utils/AsyncAwait.swift @@ -203,7 +203,7 @@ internal func pollBlock( precondition(pollInterval > .seconds(0)) let iterations = Int(exactly: (timeoutInterval / pollInterval).rounded(.up)) ?? Int.max - for i in 0.. interval) let iterations = Int(exactly: (timeoutInterval / pollInterval).rounded(.up)) ?? Int.max - for i in 0.. Date: Wed, 1 Oct 2025 09:16:18 -0700 Subject: [PATCH 5/9] Fix async waitUntil waiting until whenever the closure finishes before returning --- Sources/Nimble/Utils/AsyncAwait.swift | 39 +++++++++++++++++--------- Tests/NimbleTests/AsyncAwaitTest.swift | 12 +++++--- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/Sources/Nimble/Utils/AsyncAwait.swift b/Sources/Nimble/Utils/AsyncAwait.swift index c1cb42c5d..51d43f5bc 100644 --- a/Sources/Nimble/Utils/AsyncAwait.swift +++ b/Sources/Nimble/Utils/AsyncAwait.swift @@ -56,18 +56,30 @@ final class BlockingTask: Sendable { } func run() async { - if let continuation = lock.withLock({ self.continuation }) { + let continuation: CheckedContinuation? = { + lock.lock() + let continuation = self.continuation + lock.unlock() + return continuation + }() + + if let continuation { continuation.resume() } await withTaskCancellationHandler { await withCheckedContinuation { lock.lock() - defer { lock.unlock() } + let shouldResume: Bool if finished { - $0.resume() + shouldResume = true } else { self.continuation = $0 + shouldResume = false + } + lock.unlock() + if shouldResume { + $0.resume() } } } onCancel: { @@ -78,15 +90,16 @@ final class BlockingTask: Sendable { func complete() { lock.lock() - defer { lock.unlock() } + let wasFinished = finished + finished = true + lock.unlock() - if finished { + if wasFinished { fail( "waitUntil(...) expects its completion closure to be only called once", location: sourceLocation ) } else { - finished = true self.continuation?.resume() self.continuation = nil } @@ -94,9 +107,10 @@ final class BlockingTask: Sendable { func handleCancellation() { lock.lock() - defer { lock.unlock() } + let wasFinished = finished + lock.unlock() - guard finished == false else { + guard wasFinished == false else { return } continuation?.resume() @@ -151,7 +165,7 @@ internal func performBlock( #endif #endif - return await withTaskGroup(of: Void.self) { taskGroup in + return await withTaskGroup(of: Void.self, returning: AsyncPollResult.self) { taskGroup in let blocker = BlockingTask(sourceLocation: sourceLocation) let tracker = ResultTracker() @@ -159,7 +173,7 @@ internal func performBlock( await blocker.run() } - taskGroup.addTask { + let task = Task { do { try await closure { blocker.complete() @@ -174,9 +188,7 @@ internal func performBlock( do { try await Task.sleep(nanoseconds: (timeout + leeway).nanoseconds) tracker.finish(with: .timedOut) - } catch { - - } + } catch {} } var result: AsyncPollResult = .incomplete @@ -189,6 +201,7 @@ internal func performBlock( break } taskGroup.cancelAll() + task.cancel() return result } } diff --git a/Tests/NimbleTests/AsyncAwaitTest.swift b/Tests/NimbleTests/AsyncAwaitTest.swift index d0974f8ce..8c87de561 100644 --- a/Tests/NimbleTests/AsyncAwaitTest.swift +++ b/Tests/NimbleTests/AsyncAwaitTest.swift @@ -272,7 +272,7 @@ final class AsyncAwaitTest: XCTestCase { // swiftlint:disable:this type_body_len let timeoutQueue = DispatchQueue(label: "Nimble.waitUntilTest.timeout", qos: .background) let timer = DispatchSource.makeTimerSource(flags: .strict, queue: timeoutQueue) timer.schedule( - deadline: DispatchTime.now() + 5, + deadline: DispatchTime.now() + 60, repeating: .never, leeway: .milliseconds(1) ) @@ -282,10 +282,14 @@ final class AsyncAwaitTest: XCTestCase { // swiftlint:disable:this type_body_len } timer.resume() - for index in 0..<1000 { - if failed { break } + let runQueue = DispatchQueue(label: "Nimble.waitUntilTest.runQueue", attributes: .concurrent) + + for _ in 0..<1000 { + if failed { + break + } await waitUntil() { done in - DispatchQueue(label: "Nimble.waitUntilTest.\(index)").async { + runQueue.async { done() } } From 7e60343b14d228da35527f37f7a87b27a4bd7fcc Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Fri, 3 Oct 2025 13:46:09 -0700 Subject: [PATCH 6/9] Remove unnecessary CoreFoundation import --- Sources/Nimble/Utils/AsyncAwait.swift | 1 - Sources/Nimble/Utils/AsyncTimerSequence.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/Sources/Nimble/Utils/AsyncAwait.swift b/Sources/Nimble/Utils/AsyncAwait.swift index 51d43f5bc..c11c79836 100644 --- a/Sources/Nimble/Utils/AsyncAwait.swift +++ b/Sources/Nimble/Utils/AsyncAwait.swift @@ -1,5 +1,4 @@ #if !os(WASI) -import CoreFoundation import Dispatch import Foundation #if canImport(Testing) diff --git a/Sources/Nimble/Utils/AsyncTimerSequence.swift b/Sources/Nimble/Utils/AsyncTimerSequence.swift index 6bd46b83c..dd14b2aef 100644 --- a/Sources/Nimble/Utils/AsyncTimerSequence.swift +++ b/Sources/Nimble/Utils/AsyncTimerSequence.swift @@ -1,6 +1,5 @@ #if !os(WASI) -import CoreFoundation import Dispatch import Foundation From afa847d1cbc699991ac5f7d933a6f9b9bd5b0b42 Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Fri, 3 Oct 2025 21:43:15 -0700 Subject: [PATCH 7/9] Update the test watch simulator --- test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 { From 0f2113e4a257362b70a0c7ebdb565ae0288f6372 Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Fri, 3 Oct 2025 21:50:06 -0700 Subject: [PATCH 8/9] Check for more CF imports, and guard it where necessary --- Sources/Nimble/Utils/PollAwait.swift | 2 ++ Tests/NimbleTests/PollingTest+Require.swift | 1 - Tests/NimbleTests/PollingTest.swift | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/Nimble/Utils/PollAwait.swift b/Sources/Nimble/Utils/PollAwait.swift index 744c733f5..c6dd95f49 100644 --- a/Sources/Nimble/Utils/PollAwait.swift +++ b/Sources/Nimble/Utils/PollAwait.swift @@ -1,6 +1,8 @@ #if !os(WASI) +#if canImport(CoreFoundation) import CoreFoundation +#endif import Dispatch import Foundation #if canImport(Testing) 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 357932133..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 From e1f6cbc258379c0cefb6f3f4bf854ee5bac1b50a Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Fri, 3 Oct 2025 21:54:50 -0700 Subject: [PATCH 9/9] Mitigate AsyncAwaitTests.testWaitUntilDoesNotCompleteBeforeRunLoopIsWaiting flakes some more --- Tests/NimbleTests/AsyncAwaitTest.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/NimbleTests/AsyncAwaitTest.swift b/Tests/NimbleTests/AsyncAwaitTest.swift index 8c87de561..4f3455661 100644 --- a/Tests/NimbleTests/AsyncAwaitTest.swift +++ b/Tests/NimbleTests/AsyncAwaitTest.swift @@ -288,7 +288,7 @@ final class AsyncAwaitTest: XCTestCase { // swiftlint:disable:this type_body_len if failed { break } - await waitUntil() { done in + await waitUntil(timeout: .milliseconds(100)) { done in runQueue.async { done() }