diff --git a/Nimble.xcodeproj/project.pbxproj b/Nimble.xcodeproj/project.pbxproj index 7191d9a7c..703d1a227 100644 --- a/Nimble.xcodeproj/project.pbxproj +++ b/Nimble.xcodeproj/project.pbxproj @@ -377,6 +377,14 @@ 899441F92902EF2600C1FAF9 /* DSL+AsyncAwait.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899441F32902EF0900C1FAF9 /* DSL+AsyncAwait.swift */; }; 899441FA2902EF2700C1FAF9 /* DSL+AsyncAwait.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899441F32902EF0900C1FAF9 /* DSL+AsyncAwait.swift */; }; 899441FB2902EF2800C1FAF9 /* DSL+AsyncAwait.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899441F32902EF0900C1FAF9 /* DSL+AsyncAwait.swift */; }; + 89C297CC2A911CDA002A143F /* AsyncTimerSequenceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C297CB2A911CDA002A143F /* AsyncTimerSequenceTest.swift */; }; + 89C297CE2A92AB34002A143F /* AsyncPromiseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C297CD2A92AB34002A143F /* AsyncPromiseTest.swift */; }; + 89C297CF2A92E80D002A143F /* AsyncTimerSequenceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C297CB2A911CDA002A143F /* AsyncTimerSequenceTest.swift */; }; + 89C297D02A92E80E002A143F /* AsyncTimerSequenceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C297CB2A911CDA002A143F /* AsyncTimerSequenceTest.swift */; }; + 89C297D12A92E80F002A143F /* AsyncTimerSequenceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C297CB2A911CDA002A143F /* AsyncTimerSequenceTest.swift */; }; + 89C297D32A92E814002A143F /* AsyncPromiseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C297CD2A92AB34002A143F /* AsyncPromiseTest.swift */; }; + 89C297D42A92E815002A143F /* AsyncPromiseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C297CD2A92AB34002A143F /* AsyncPromiseTest.swift */; }; + 89C297D52A92E816002A143F /* AsyncPromiseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89C297CD2A92AB34002A143F /* AsyncPromiseTest.swift */; }; 89EEF5A52A03293100988224 /* AsyncPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5A42A03293100988224 /* AsyncPredicate.swift */; }; 89EEF5A62A03293100988224 /* AsyncPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5A42A03293100988224 /* AsyncPredicate.swift */; }; 89EEF5A72A03293100988224 /* AsyncPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89EEF5A42A03293100988224 /* AsyncPredicate.swift */; }; @@ -794,6 +802,8 @@ 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 = ""; }; + 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 /* AsyncPredicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncPredicate.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 = ""; }; @@ -1002,6 +1012,8 @@ 89F5E095290C37B8001F9377 /* StatusTest.swift */, 1F0648D31963AAB2001F9C46 /* SynchronousTest.swift */, 899441EE2902EE4B00C1FAF9 /* AsyncAwaitTest.swift */, + 89C297CB2A911CDA002A143F /* AsyncTimerSequenceTest.swift */, + 89C297CD2A92AB34002A143F /* AsyncPromiseTest.swift */, 965B0D0B1B62C06D0005AE66 /* UserDescriptionTest.swift */, 6CAEDD091CAEA86F003F1584 /* LinuxSupport.swift */, 1F14FB61194180A7009F2A08 /* Helpers */, @@ -1732,6 +1744,7 @@ 8969624B2A5FAD6000A7929D /* AsyncAllPassTest.swift in Sources */, 1F4A56661A3B305F009E1637 /* ObjCAsyncTest.m in Sources */, 1F925EFC195C186800ED456B /* BeginWithTest.swift in Sources */, + 89C297D52A92E816002A143F /* AsyncPromiseTest.swift in Sources */, 89F5E06E290765BB001F9377 /* PollingTest.swift in Sources */, DDB4D5F019FE442800E9D9FE /* MatchTest.swift in Sources */, 1F4A56731A3B3210009E1637 /* ObjCBeginWithTest.m in Sources */, @@ -1779,6 +1792,7 @@ 1F4A567F1A3B333F009E1637 /* ObjCBeLessThanTest.m in Sources */, 857D18502536124400D8693A /* BeWithinTest.swift in Sources */, 1F0648CC19639F5A001F9C46 /* ObjectWithLazyProperty.swift in Sources */, + 89C297CF2A92E80D002A143F /* AsyncTimerSequenceTest.swift in Sources */, 1F4A56851A3B33A0009E1637 /* ObjCBeTruthyTest.m in Sources */, DD9A9A8F19CF439B00706F49 /* BeIdenticalToObjectTest.swift in Sources */, 891364B229E6963C00AD535E /* utils.swift in Sources */, @@ -1891,6 +1905,7 @@ 8969624C2A5FAD6100A7929D /* AsyncAllPassTest.swift in Sources */, 1F5DF1981BDCA10200C3A531 /* BeAKindOfTest.swift in Sources */, 1F5DF19B1BDCA10200C3A531 /* BeEmptyTest.swift in Sources */, + 89C297D42A92E815002A143F /* AsyncPromiseTest.swift in Sources */, 7B5358BC1C3846C900A23FAA /* SatisfyAnyOfTest.swift in Sources */, 89F5E06F290765BB001F9377 /* PollingTest.swift in Sources */, 1F5DF1A11BDCA10200C3A531 /* BeLessThanOrEqualToTest.swift in Sources */, @@ -1938,6 +1953,7 @@ 7A6AB2C41E7F547E00A2F694 /* ToSucceedTest.swift in Sources */, CD79C9A71D2CC848004B6F9A /* ObjCBeGreaterThanTest.m in Sources */, CD79C9A51D2CC848004B6F9A /* ObjCBeginWithTest.m in Sources */, + 89C297D02A92E80E002A143F /* AsyncTimerSequenceTest.swift in Sources */, 1F5DF1AA1BDCA10200C3A531 /* RaisesExceptionTest.swift in Sources */, 89F5E0A1290C37F7001F9377 /* ObjCBeLessThanTest.m in Sources */, 8913649229E6925D00AD535E /* utils.swift in Sources */, @@ -2055,6 +2071,7 @@ 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 */, @@ -2102,6 +2119,7 @@ 106112C52251E13B000A5848 /* BeResultTest.swift in Sources */, 1F4A56861A3B33A0009E1637 /* ObjCBeTruthyTest.m in Sources */, 89F5E09B290C37B8001F9377 /* OnFailureThrowsTest.swift in Sources */, + 89C297CC2A911CDA002A143F /* AsyncTimerSequenceTest.swift in Sources */, DD9A9A9019CF43AD00706F49 /* BeIdenticalToObjectTest.swift in Sources */, 1F4BB8B61DACA0E30048464B /* ThrowAssertionTest.swift in Sources */, 8913649429E6925F00AD535E /* utils.swift in Sources */, @@ -2214,6 +2232,7 @@ 8969624D2A5FAD6300A7929D /* AsyncAllPassTest.swift in Sources */, D95F8939267EA1E8004B1B4D /* BeIdenticalToObjectTest.swift in Sources */, 891364AC29E695F300AD535E /* ObjCBeCloseToTest.m in Sources */, + 89C297D32A92E814002A143F /* AsyncPromiseTest.swift in Sources */, 899441F22902EE4B00C1FAF9 /* AsyncAwaitTest.swift in Sources */, 891364AD29E695F300AD535E /* ObjCBeNilTest.m in Sources */, D95F8937267EA1E8004B1B4D /* BeginWithTest.swift in Sources */, @@ -2261,6 +2280,7 @@ D95F892F267EA1D9004B1B4D /* SynchronousTest.swift in Sources */, 8913649E29E695F300AD535E /* ObjCSatisfyAllOfTest.m in Sources */, D95F8953267EA1EE004B1B4D /* AlwaysFailMatcher.swift in Sources */, + 89C297D12A92E80F002A143F /* AsyncTimerSequenceTest.swift in Sources */, 89F5E09A290C37B8001F9377 /* StatusTest.swift in Sources */, D95F892A267EA1D9004B1B4D /* UserDescriptionTest.swift in Sources */, D95F893F267EA1E8004B1B4D /* BeLessThanOrEqualToTest.swift in Sources */, diff --git a/Sources/Nimble/DSL+AsyncAwait.swift b/Sources/Nimble/DSL+AsyncAwait.swift index 524ff01c8..7cf51c6ac 100644 --- a/Sources/Nimble/DSL+AsyncAwait.swift +++ b/Sources/Nimble/DSL+AsyncAwait.swift @@ -116,9 +116,8 @@ private func throwableUntil( file: FileString = #file, line: UInt = #line, action: @escaping (@escaping () -> Void) async throws -> Void) async { - let awaiter = NimbleEnvironment.activeInstance.awaiter let leeway = timeout.divided - let result = await awaiter.performBlock(file: file, line: line) { @MainActor (done: @escaping (ErrorResult) -> Void) async throws -> Void in + let result = await performBlock(timeoutInterval: timeout, leeway: leeway, file: file, line: line) { @MainActor (done: @escaping (ErrorResult) -> Void) async throws -> Void in do { try await action { done(.none) @@ -127,8 +126,6 @@ private func throwableUntil( done(.error(e)) } } - .timeout(timeout, forcefullyAbortTimeout: leeway) - .wait("waitUntil(...)", file: file, line: line) switch result { case .incomplete: internalError("Reached .incomplete state for waitUntil(...).") @@ -137,8 +134,6 @@ private func throwableUntil( file: file, line: line) case .timedOut: fail("Waited more than \(timeout.description)", file: file, line: line) - case let .raisedException(exception): - fail("Unexpected exception raised: \(exception)") case let .errorThrown(error): fail("Unexpected error thrown: \(error)") case .completed(.error(let error)): diff --git a/Sources/Nimble/Polling+AsyncAwait.swift b/Sources/Nimble/Polling+AsyncAwait.swift index 04b11ed12..eacd1d8a2 100644 --- a/Sources/Nimble/Polling+AsyncAwait.swift +++ b/Sources/Nimble/Polling+AsyncAwait.swift @@ -42,7 +42,7 @@ private actor Poller { self.updatePredicateResult(result: try await predicateRunner()) .toBoolean(expectation: style) } - return processPollResult(result, matchStyle: matchStyle, lastPredicateResult: lastPredicateResult, fnName: fnName) + return processPollResult(result.toPollResult(), matchStyle: matchStyle, lastPredicateResult: lastPredicateResult, fnName: fnName) } func updatePredicateResult(result: PredicateResult) -> PredicateResult { diff --git a/Sources/Nimble/Utils/AsyncAwait.swift b/Sources/Nimble/Utils/AsyncAwait.swift index 560adc0ba..b92e74e16 100644 --- a/Sources/Nimble/Utils/AsyncAwait.swift +++ b/Sources/Nimble/Utils/AsyncAwait.swift @@ -7,214 +7,378 @@ import Foundation private let timeoutLeeway = NimbleTimeInterval.milliseconds(1) private let pollLeeway = NimbleTimeInterval.milliseconds(1) -internal struct AsyncAwaitTrigger { - let timeoutSource: DispatchSourceTimer - let actionSource: DispatchSourceTimer? - let start: () async throws -> Void +// Similar to (made by directly referencing) swift-async-algorithm's AsyncTimerSequence. +// https://github.com/apple/swift-async-algorithms/blob/main/Sources/AsyncAlgorithms/AsyncTimerSequence.swift +// Only this one is compatible with OS versions that Nimble supports. +struct AsyncTimerSequence: AsyncSequence { + typealias Element = Void + let interval: NimbleTimeInterval + + struct AsyncIterator: AsyncIteratorProtocol { + typealias Element = Void + + let interval: NimbleTimeInterval + + var last: Date? = nil + + func nextDeadline() -> Date { + let now = Date() + + let last = self.last ?? now + let next = last.advanced(by: interval.timeInterval) + if next < now { + let nextTimestep = interval.timeInterval * ((now.timeIntervalSince(next)) / interval.timeInterval).rounded(.up) + return last.advanced(by: nextTimestep) + } else { + return next + } + } + + mutating func next() async -> Void? { + let next = nextDeadline() + let nextDeadlineNanoseconds = UInt64(Swift.max(0, next.timeIntervalSinceNow * 1_000_000_000)) + do { + try await Task.sleep(nanoseconds: nextDeadlineNanoseconds) + } catch { + return nil + } + last = next + return () + } + } + + func makeAsyncIterator() -> AsyncIterator { + return AsyncIterator(interval: interval) + } } -/// 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 AsyncAwaitPromiseBuilder { - let awaiter: Awaiter - let waitLock: WaitLock - let trigger: AsyncAwaitTrigger - let promise: AwaitPromise - - internal init( - awaiter: Awaiter, - waitLock: WaitLock, - promise: AwaitPromise, - trigger: AsyncAwaitTrigger) { - self.awaiter = awaiter - self.waitLock = waitLock - self.promise = promise - self.trigger = trigger - } - - func timeout(_ timeoutInterval: NimbleTimeInterval, forcefullyAbortTimeout: NimbleTimeInterval) -> 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() - DispatchQueue.main.async { - if semTimedOutOrBlocked.wait(timeout: .now()) == .success { - timedOutSem.signal() - semTimedOutOrBlocked.signal() - self.promise.resolveResult(.timedOut) +// Like PollResult, except it doesn't support objective-c exceptions. +// Which is tolerable because Swift Concurrency doesn't support recording objective-c exceptions. +internal enum AsyncPollResult { + /// Incomplete indicates None (aka - this value hasn't been fulfilled yet) + 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 + case errorThrown(Error) + + func isIncomplete() -> Bool { + switch self { + case .incomplete: return true + default: return false + } + } + + func isCompleted() -> Bool { + switch self { + case .completed: return true + default: return false + } + } + + func toPollResult() -> PollResult { + switch self { + case .incomplete: return .incomplete + case .timedOut: return .timedOut + case .blockedRunLoop: return .blockedRunLoop + case .completed(let t): return .completed(t) + 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() + + private final class Storage { + 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 await() async -> T { + await withUnsafeContinuation { continuation in + lock.withLock { + if let value { + continuation.resume(returning: value) + } else { + continuations.append(continuation) + } } } - // potentially interrupt blocking code on run loop to let timeout code run - let now = DispatchTime.now() + forcefullyAbortTimeout.dispatchTimeInterval - let didNotTimeOut = timedOutSem.wait(timeout: now) != .success - let timeoutWasNotTriggered = semTimedOutOrBlocked.wait(timeout: .now()) == .success - if didNotTimeOut && timeoutWasNotTriggered { - self.promise.resolveResult(.blockedRunLoop) + } + + func send(_ value: T) { + lock.withLock { + if self.value != nil { return } + continuations.forEach { continuation in + continuation.resume(returning: value) + } + continuations = [] + self.value = value } } - 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 AwaitResult will NEVER be .incomplete. - @MainActor - func wait(_ fnName: String = #function, file: FileString = #file, line: UInt = #line) async -> PollResult { - waitLock.acquireWaitingLock( - fnName, - file: file, - line: line) + nonisolated func send(_ value: T) { + Task { + await self._send(value) + } + } - defer { - self.waitLock.releaseWaitingLock() + private 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() + + DispatchQueue.main.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) + } else { + promise.send(.timedOut) } + } + + return await promise.value +} + +private func poll(_ pollInterval: NimbleTimeInterval, expression: @escaping () async throws -> Bool) async -> AsyncPollResult { + for try await _ in AsyncTimerSequence(interval: pollInterval) { do { - try await self.trigger.start() - } catch let error { - self.promise.resolveResult(.errorThrown(error)) + if try await expression() { + return .completed(true) + } + } catch { + return .errorThrown(error) } - self.trigger.timeoutSource.resume() - while self.promise.asyncResult.isIncomplete() { - await Task.yield() + } + 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 = #function, file: FileString = #file, line: UInt = #line, + expression: @escaping () async throws -> Bool +) async -> AsyncPollResult { + awaiter.waitLock.acquireWaitingLock( + fnName, + file: file, + line: line) + + defer { + awaiter.waitLock.releaseWaitingLock() + } + let timeoutQueue = awaiter.timeoutQueue + return await withTaskGroup(of: AsyncPollResult.self) { taskGroup in + taskGroup.addTask { + await timeout( + timeoutQueue: timeoutQueue, + timeoutInterval: timeoutInterval, + forcefullyAbortTimeout: timeoutInterval.divided + ) } - self.trigger.timeoutSource.cancel() - if let asyncSource = self.trigger.actionSource { - asyncSource.cancel() + taskGroup.addTask { + await poll(pollInterval, expression: expression) } - return promise.asyncResult + defer { + taskGroup.cancelAll() + } + + return await taskGroup.next() ?? .timedOut } } -extension Awaiter { - func performBlock( - file: FileString, - line: UInt, - _ closure: @escaping (@escaping (T) -> Void) async throws -> Void - ) async -> AsyncAwaitPromiseBuilder { - let promise = AwaitPromise() - let timeoutSource = createTimerSource(timeoutQueue) - var completionCount = 0 - let trigger = AsyncAwaitTrigger(timeoutSource: timeoutSource, actionSource: nil) { +private final class Box: @unchecked Sendable { + private var _value: T + var value: T { + lock.withLock { + _value + } + } + + private let lock = NSLock() + + init(value: T) { + _value = value + } + + func operate(_ closure: @Sendable (T) -> T) { + lock.withLock { + _value = closure(_value) + } + } +} + +private func runAwaitTrigger( + awaiter: Awaiter, + timeoutInterval: NimbleTimeInterval, + leeway: NimbleTimeInterval, + file: FileString, line: UInt, + _ 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() + + taskGroup.addTask { + defer { + promise.send(nil) + } + return await timeout( + timeoutQueue: timeoutQueue, + timeoutInterval: timeoutInterval, + forcefullyAbortTimeout: leeway + ) + } + + taskGroup.addTask { + do { try await closure { result in - completionCount += 1 - if completionCount < 2 { - promise.resolveResult(.completed(result)) + completionCount.operate { $0 + 1 } + if completionCount.value < 2 { + promise.send(result) } else { fail("waitUntil(..) expects its completion closure to be only called once", file: file, line: line) } } - } - - return AsyncAwaitPromiseBuilder( - awaiter: self, - waitLock: waitLock, - promise: promise, - trigger: trigger) - } - - func poll(_ pollInterval: NimbleTimeInterval, closure: @escaping () async throws -> T?) async -> AsyncAwaitPromiseBuilder { - let promise = AwaitPromise() - let timeoutSource = createTimerSource(timeoutQueue) - let asyncSource = createTimerSource(asyncQueue) - let trigger = AsyncAwaitTrigger(timeoutSource: timeoutSource, actionSource: asyncSource) { - let interval = pollInterval - asyncSource.schedule( - deadline: .now(), - repeating: interval.dispatchTimeInterval, - leeway: pollLeeway.dispatchTimeInterval - ) - asyncSource.setEventHandler { - Task { - do { - if let result = try await closure() { - promise.resolveResult(.completed(result)) - } - } catch let error { - promise.resolveResult(.errorThrown(error)) - } + if let value = await promise.value { + return .completed(value) + } else { + return .timedOut } + } catch { + return .errorThrown(error) } - asyncSource.resume() } - return AsyncAwaitPromiseBuilder( - awaiter: self, - waitLock: waitLock, - promise: promise, - trigger: trigger) + defer { + taskGroup.cancelAll() + } + + return await taskGroup.next() ?? .timedOut } } +internal func performBlock( + timeoutInterval: NimbleTimeInterval, + leeway: NimbleTimeInterval, + file: FileString, line: UInt, + _ closure: @escaping (@escaping (T) -> Void) async throws -> Void +) async -> AsyncPollResult { + await runAwaitTrigger( + awaiter: NimbleEnvironment.activeInstance.awaiter, + timeoutInterval: timeoutInterval, + leeway: leeway, + file: file, line: line, closure) +} + internal func pollBlock( pollInterval: NimbleTimeInterval, timeoutInterval: NimbleTimeInterval, file: FileString, line: UInt, fnName: String = #function, - expression: @escaping () async throws -> Bool) async -> PollResult { - let awaiter = NimbleEnvironment.activeInstance.awaiter - let result = await awaiter.poll(pollInterval) { () throws -> Bool? in - if try await expression() { - return true - } - return nil - } - .timeout(timeoutInterval, forcefullyAbortTimeout: timeoutInterval.divided) - .wait(fnName, file: file, line: line) + expression: @escaping () async throws -> Bool) async -> AsyncPollResult { + await runPoller( + timeoutInterval: timeoutInterval, + pollInterval: pollInterval, + awaiter: NimbleEnvironment.activeInstance.awaiter, + expression: expression + ) + } - return result -} #endif // #if !os(WASI) diff --git a/Sources/Nimble/Utils/NimbleTimeInterval.swift b/Sources/Nimble/Utils/NimbleTimeInterval.swift index 8a368e77f..516dafe1c 100644 --- a/Sources/Nimble/Utils/NimbleTimeInterval.swift +++ b/Sources/Nimble/Utils/NimbleTimeInterval.swift @@ -38,6 +38,15 @@ extension NimbleTimeInterval: CustomStringConvertible { } } + public var nanoseconds: UInt64 { + switch self { + case .seconds(let int): return UInt64(int) * 1_000_000_000 + case .milliseconds(let int): return UInt64(int) * 1_000_000 + case .microseconds(let int): return UInt64(int) * 1_000 + case .nanoseconds(let int): return UInt64(int) + } + } + public var description: String { switch self { case let .seconds(val): return val == 1 ? "\(Float(val)) second" : "\(Float(val)) seconds" @@ -48,16 +57,45 @@ extension NimbleTimeInterval: CustomStringConvertible { } } +@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) +extension NimbleTimeInterval { + public var duration: Duration { + switch self { + case .seconds(let int): return .seconds(int) + case .milliseconds(let int): return .milliseconds(int) + case .microseconds(let int): return .microseconds(int) + case .nanoseconds(let int): return .nanoseconds(int) + } + } +} + #if canImport(Foundation) -import typealias Foundation.TimeInterval +import Foundation + +extension NimbleTimeInterval { + public var timeInterval: TimeInterval { + switch self { + case .seconds(let int): return TimeInterval(int) + case .milliseconds(let int): return TimeInterval(int) / 1_000 + case .microseconds(let int): return TimeInterval(int) / 1_000_000 + case .nanoseconds(let int): return TimeInterval(int) / 1_000_000_000 + } + } +} extension TimeInterval { - var nimbleInterval: NimbleTimeInterval { + public var nimbleInterval: NimbleTimeInterval { let microseconds = Int64(self * TimeInterval(USEC_PER_SEC)) // perhaps use nanoseconds, though would more often be > Int.max return microseconds < Int.max ? .microseconds(Int(microseconds)) : .seconds(Int(self)) } } + +extension Date { + public func advanced(by nimbleTimeInterval: NimbleTimeInterval) -> Date { + self.advanced(by: nimbleTimeInterval.timeInterval) + } +} #endif #endif // #if !os(WASI) diff --git a/Sources/Nimble/Utils/PollAwait.swift b/Sources/Nimble/Utils/PollAwait.swift index 6d10bc3f9..196853e8a 100644 --- a/Sources/Nimble/Utils/PollAwait.swift +++ b/Sources/Nimble/Utils/PollAwait.swift @@ -8,7 +8,7 @@ private let timeoutLeeway = NimbleTimeInterval.milliseconds(1) private let pollLeeway = NimbleTimeInterval.milliseconds(1) /// Stores debugging information about callers -internal struct WaitingInfo: CustomStringConvertible { +internal struct WaitingInfo: CustomStringConvertible, Sendable { let name: String let file: FileString let lineNumber: UInt @@ -24,30 +24,18 @@ internal protocol WaitLock { func isWaitingLocked() -> Bool } -internal class AssertionWaitLock: WaitLock { - private var currentWaiter: WaitingInfo? { - get { - return dispatchQueue.sync { - _currentWaiter - } - } - set { - dispatchQueue.sync { - _currentWaiter = newValue - } - } - } - - private var _currentWaiter: WaitingInfo? - private let dispatchQueue = DispatchQueue(label: "quick.nimble.AssertionWaitLock") +internal final class AssertionWaitLock: WaitLock, @unchecked Sendable { + private var currentWaiter: WaitingInfo? + private let lock = NSRecursiveLock() init() { } func acquireWaitingLock(_ fnName: String, file: FileString, line: UInt) { - let info = WaitingInfo(name: fnName, file: file, lineNumber: line) - nimblePrecondition( - currentWaiter == nil, - "InvalidNimbleAPIUsage", + lock.withLock { + let info = WaitingInfo(name: fnName, file: file, lineNumber: line) + nimblePrecondition( + currentWaiter == nil, + "InvalidNimbleAPIUsage", """ Nested async expectations are not allowed to avoid creating flaky tests. @@ -57,16 +45,21 @@ internal class AssertionWaitLock: WaitLock { \t\(currentWaiter!) is currently managing the main run loop. """ - ) - currentWaiter = info + ) + currentWaiter = info + } } func isWaitingLocked() -> Bool { - return currentWaiter != nil + lock.withLock { + currentWaiter != nil + } } func releaseWaitingLock() { - currentWaiter = nil + lock.withLock { + currentWaiter = nil + } } } @@ -104,7 +97,7 @@ internal enum PollResult { } /// Holds the resulting value from an asynchronous expectation. -/// This class is thread-safe at receiving an "response" to this promise. +/// 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 @@ -243,7 +236,7 @@ internal class AwaitPromiseBuilder { /// - The async expectation raised an unexpected exception (objc) /// - The async expectation raised an unexpected error (swift) /// - /// The returned AwaitResult will NEVER be .incomplete. + /// The returned PollResult will NEVER be .incomplete. func wait(_ fnName: String = #function, file: FileString = #file, line: UInt = #line) -> PollResult { waitLock.acquireWaitingLock( fnName, diff --git a/Tests/NimbleTests/AsyncPromiseTest.swift b/Tests/NimbleTests/AsyncPromiseTest.swift new file mode 100644 index 000000000..4acfc440e --- /dev/null +++ b/Tests/NimbleTests/AsyncPromiseTest.swift @@ -0,0 +1,59 @@ +import XCTest +import Foundation +@testable import Nimble + +final class AsyncPromiseTest: XCTestCase { + func testSuspendsUntilValueIsSent() async { + let promise = AsyncPromise() + + 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/AsyncTimerSequenceTest.swift b/Tests/NimbleTests/AsyncTimerSequenceTest.swift new file mode 100644 index 000000000..624ba5149 --- /dev/null +++ b/Tests/NimbleTests/AsyncTimerSequenceTest.swift @@ -0,0 +1,18 @@ +import XCTest +import Foundation +@testable import Nimble + +final class AsyncTimerSequenceTest: XCTestCase { + func testOutputsVoidAtSpecifiedIntervals() async throws { + var times: [Date] = [] + for try await _ in AsyncTimerSequence(interval: .milliseconds(10)) { + times.append(Date()) + if times.count > 4 { break } + } + + expect(times[1].timeIntervalSince(times[0]) * 1_000).to(beCloseTo(10, within: 5)) + expect(times[2].timeIntervalSince(times[1]) * 1_000).to(beCloseTo(10, within: 5)) + expect(times[3].timeIntervalSince(times[2]) * 1_000).to(beCloseTo(10, within: 5)) + expect(times[4].timeIntervalSince(times[3]) * 1_000).to(beCloseTo(10, within: 5)) + } +}