From 3f9773e08ba09fb6119466968989936e27e99e8e Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Tue, 12 Sep 2023 21:34:51 -0700 Subject: [PATCH] Implement the async variant of toEventually using structured concurrency (#1079) * Implement the async variant of toEventually using structured concurrency ... mostly. This replaces the Dispatch-based version of async toEventually with one that uses native Swift Concurrency components This was done to eliminate the chance that the matcher could be polled more than one at a time, and to also make way for future Sendable requirements. * NSLocking.withLock is not available in earlier versions of the OS * Remove NimbleTimeInterval+Duration * Reimplement AsyncTimerSequence to use a simplified form of the Clock protocol This allows us to longer have to verify time intervals in test, removing a source of test flakiness * Fix swiftlint errors --- Nimble.xcodeproj/project.pbxproj | 30 ++ Sources/Nimble/AsyncExpression.swift | 1 - Sources/Nimble/DSL+AsyncAwait.swift | 7 +- Sources/Nimble/DSL.swift | 1 - Sources/Nimble/Polling+AsyncAwait.swift | 2 +- Sources/Nimble/Utils/AsyncAwait.swift | 460 +++++++++++------- Sources/Nimble/Utils/AsyncTimerSequence.swift | 138 ++++++ Sources/Nimble/Utils/NimbleTimeInterval.swift | 30 +- Sources/Nimble/Utils/PollAwait.swift | 31 +- Tests/NimbleTests/AsyncPromiseTest.swift | 59 +++ .../NimbleTests/AsyncTimerSequenceTest.swift | 128 +++++ 11 files changed, 686 insertions(+), 201 deletions(-) create mode 100644 Sources/Nimble/Utils/AsyncTimerSequence.swift create mode 100644 Tests/NimbleTests/AsyncPromiseTest.swift create mode 100644 Tests/NimbleTests/AsyncTimerSequenceTest.swift diff --git a/Nimble.xcodeproj/project.pbxproj b/Nimble.xcodeproj/project.pbxproj index 7191d9a7c..56164a620 100644 --- a/Nimble.xcodeproj/project.pbxproj +++ b/Nimble.xcodeproj/project.pbxproj @@ -354,6 +354,10 @@ 891364B029E695F300AD535E /* ObjCAllPassTest.m in Sources */ = {isa = PBXBuildFile; fileRef = DDEFAEB31A93CBE6005CA37A /* ObjCAllPassTest.m */; }; 891364B129E695F300AD535E /* ObjcStringersTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 8DF1C3F61C94FC75004B2D36 /* ObjcStringersTest.m */; }; 891364B229E6963C00AD535E /* utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F14FB63194180C5009F2A08 /* utils.swift */; }; + 891A04712AB0164500B46613 /* AsyncTimerSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891A04702AB0164500B46613 /* AsyncTimerSequence.swift */; }; + 891A04722AB0164500B46613 /* AsyncTimerSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891A04702AB0164500B46613 /* AsyncTimerSequence.swift */; }; + 891A04732AB0164500B46613 /* AsyncTimerSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891A04702AB0164500B46613 /* AsyncTimerSequence.swift */; }; + 891A04742AB0164500B46613 /* AsyncTimerSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891A04702AB0164500B46613 /* AsyncTimerSequence.swift */; }; 892FDF1329D3EA7700523A80 /* AsyncExpression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FDF1229D3EA7700523A80 /* AsyncExpression.swift */; }; 892FDF1429D3EA7700523A80 /* AsyncExpression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FDF1229D3EA7700523A80 /* AsyncExpression.swift */; }; 892FDF1529D3EA7700523A80 /* AsyncExpression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FDF1229D3EA7700523A80 /* AsyncExpression.swift */; }; @@ -377,6 +381,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 */; }; @@ -788,12 +800,15 @@ 7B5358C11C39155600A23FAA /* ObjCSatisfyAnyOfTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ObjCSatisfyAnyOfTest.m; sourceTree = ""; }; 857D1848253610A900D8693A /* BeWithin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeWithin.swift; sourceTree = ""; }; 857D184D2536123F00D8693A /* BeWithinTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeWithinTest.swift; sourceTree = ""; }; + 891A04702AB0164500B46613 /* AsyncTimerSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncTimerSequence.swift; sourceTree = ""; }; 892FDF1229D3EA7700523A80 /* AsyncExpression.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsyncExpression.swift; sourceTree = ""; }; 896962402A5FABD000A7929D /* AsyncAllPass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAllPass.swift; sourceTree = ""; }; 896962452A5FAD4500A7929D /* AsyncAllPassTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncAllPassTest.swift; sourceTree = ""; }; 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 +1017,8 @@ 89F5E095290C37B8001F9377 /* StatusTest.swift */, 1F0648D31963AAB2001F9C46 /* SynchronousTest.swift */, 899441EE2902EE4B00C1FAF9 /* AsyncAwaitTest.swift */, + 89C297CB2A911CDA002A143F /* AsyncTimerSequenceTest.swift */, + 89C297CD2A92AB34002A143F /* AsyncPromiseTest.swift */, 965B0D0B1B62C06D0005AE66 /* UserDescriptionTest.swift */, 6CAEDD091CAEA86F003F1584 /* LinuxSupport.swift */, 1F14FB61194180A7009F2A08 /* Helpers */, @@ -1128,6 +1145,7 @@ children = ( 1FD8CD261968AB07008ED995 /* PollAwait.swift */, 89F5E08B290B8D22001F9377 /* AsyncAwait.swift */, + 891A04702AB0164500B46613 /* AsyncTimerSequence.swift */, 1FD8CD271968AB07008ED995 /* SourceLocation.swift */, 1FD8CD281968AB07008ED995 /* Stringers.swift */, AE4BA9AC1C88DDB500B73906 /* Errors.swift */, @@ -1706,6 +1724,7 @@ 896962422A5FABD000A7929D /* AsyncAllPass.swift in Sources */, 1FD8CD381968AB07008ED995 /* Expression.swift in Sources */, 1FD8CD3A1968AB07008ED995 /* FailureMessage.swift in Sources */, + 891A04722AB0164500B46613 /* AsyncTimerSequence.swift in Sources */, CDFB6A4C1F7E082500AD8CC7 /* mach_excServer.c in Sources */, 89EEF5A62A03293100988224 /* AsyncPredicate.swift in Sources */, 472FD1351B9E085700C7B8DA /* HaveCount.swift in Sources */, @@ -1732,6 +1751,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 +1799,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 */, @@ -1830,6 +1851,7 @@ 899441FA2902EF2700C1FAF9 /* DSL+AsyncAwait.swift in Sources */, 1F5DF1781BDCA0F500C3A531 /* BeAnInstanceOf.swift in Sources */, 1F5DF1771BDCA0F500C3A531 /* BeAKindOf.swift in Sources */, + 891A04732AB0164500B46613 /* AsyncTimerSequence.swift in Sources */, 1F5DF17F1BDCA0F500C3A531 /* BeLessThan.swift in Sources */, 1F5DF17C1BDCA0F500C3A531 /* BeGreaterThan.swift in Sources */, 1F91DD331C74BF61002C309F /* BeVoid.swift in Sources */, @@ -1891,6 +1913,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 +1961,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 */, @@ -2029,6 +2053,7 @@ 896962412A5FABD000A7929D /* AsyncAllPass.swift in Sources */, CDFB6A251F7E07C700AD8CC7 /* CwlCatchException.m in Sources */, 1FD8CD391968AB07008ED995 /* Expression.swift in Sources */, + 891A04712AB0164500B46613 /* AsyncTimerSequence.swift in Sources */, CDFB6A4B1F7E082500AD8CC7 /* mach_excServer.c in Sources */, 89EEF5A52A03293100988224 /* AsyncPredicate.swift in Sources */, 1FD8CD3B1968AB07008ED995 /* FailureMessage.swift in Sources */, @@ -2055,6 +2080,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 +2128,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 */, @@ -2153,6 +2180,7 @@ 899441FB2902EF2800C1FAF9 /* DSL+AsyncAwait.swift in Sources */, D95F8968267EA20A004B1B4D /* BeGreaterThan.swift in Sources */, D95F8972267EA20A004B1B4D /* Match.swift in Sources */, + 891A04742AB0164500B46613 /* AsyncTimerSequence.swift in Sources */, D95F8986267EA20E004B1B4D /* Stringers.swift in Sources */, D95F8985267EA20E004B1B4D /* NimbleTimeInterval.swift in Sources */, D95F895A267EA205004B1B4D /* Expression.swift in Sources */, @@ -2214,6 +2242,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 +2290,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/AsyncExpression.swift b/Sources/Nimble/AsyncExpression.swift index e643637c8..59667840e 100644 --- a/Sources/Nimble/AsyncExpression.swift +++ b/Sources/Nimble/AsyncExpression.swift @@ -120,4 +120,3 @@ public struct AsyncExpression { ) } } - 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/DSL.swift b/Sources/Nimble/DSL.swift index 0af089091..d61ac60d5 100644 --- a/Sources/Nimble/DSL.swift +++ b/Sources/Nimble/DSL.swift @@ -111,7 +111,6 @@ internal func internalError(_ msg: String, file: FileString = #file, line: UInt Please file a bug to Nimble: https://github.com/Quick/Nimble/issues with the code snippet that caused this error. """ ) - // swiftlint:enable line_length } #if canImport(Darwin) 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..556d54585 100644 --- a/Sources/Nimble/Utils/AsyncAwait.swift +++ b/Sources/Nimble/Utils/AsyncAwait.swift @@ -7,214 +7,332 @@ 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 +// 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 value): return .completed(value) + case .errorThrown(let error): return .errorThrown(error) + } + } } -/// 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) +// 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.lock() + defer { lock.unlock() } + 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.lock() + defer { lock.unlock() } + 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) + } + } + 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.resume() - while self.promise.asyncResult.isIncomplete() { - await Task.yield() + + taskGroup.addTask { + await poll(pollInterval, expression: expression) } - self.trigger.timeoutSource.cancel() - if let asyncSource = self.trigger.actionSource { - asyncSource.cancel() + defer { + taskGroup.cancelAll() } - return promise.asyncResult + return await taskGroup.next() ?? .timedOut + } +} + +private final class Box: @unchecked Sendable { + private var _value: T + var value: T { + lock.lock() + defer { lock.unlock() } + return _value + } + + private let lock = NSLock() + + init(value: T) { + _value = value + } + + func operate(_ closure: @Sendable (T) -> T) { + lock.lock() + defer { lock.unlock() } + _value = closure(_value) } } -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 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) - - return result -} + expression: @escaping () async throws -> Bool) async -> AsyncPollResult { + await runPoller( + timeoutInterval: timeoutInterval, + pollInterval: pollInterval, + awaiter: NimbleEnvironment.activeInstance.awaiter, + expression: expression + ) + } #endif // #if !os(WASI) diff --git a/Sources/Nimble/Utils/AsyncTimerSequence.swift b/Sources/Nimble/Utils/AsyncTimerSequence.swift new file mode 100644 index 000000000..6bd46b83c --- /dev/null +++ b/Sources/Nimble/Utils/AsyncTimerSequence.swift @@ -0,0 +1,138 @@ +#if !os(WASI) + +import CoreFoundation +import Dispatch +import Foundation + +// Basically a re-implementation of Clock and InstantProtocol. +// This can be removed once we drop support for iOS < 16. +internal protocol NimbleClockProtocol: Sendable { + associatedtype Instant: NimbleInstantProtocol + + func now() -> Instant + + func sleep(until: Instant) async throws +} + +internal protocol NimbleInstantProtocol: Sendable, Comparable { + associatedtype Interval: NimbleIntervalProtocol + + func advanced(byInterval: Interval) -> Self + + func intervalSince(_: Self) -> Interval +} + +internal protocol NimbleIntervalProtocol: Sendable, Comparable { + static func + (lhs: Self, rhs: Self) -> Self + static func - (lhs: Self, rhs: Self) -> Self + static func * (lhs: Self, rhs: Self) -> Self + static func / (lhs: Self, rhs: Self) -> Self + + func rounded(_ rule: FloatingPointRoundingRule) -> Self +} + +internal struct DateClock: NimbleClockProtocol { + typealias Instant = Date + + func now() -> Instant { + Date() + } + + func sleep(until: Instant) async throws { + try await Task.sleep(nanoseconds: UInt64(Swift.max(0, until.timeIntervalSinceNow * 1_000_000_000))) + } +} + +// Date is Sendable as of at least iOS 16. +// But as of Swift 5.9, it's still not Sendable in the open source version. +extension Date: @unchecked Sendable {} + +extension Date: NimbleInstantProtocol { + typealias Interval = NimbleTimeInterval + + func advanced(byInterval interval: NimbleTimeInterval) -> Date { + advanced(by: interval.timeInterval) + } + + func intervalSince(_ other: Date) -> NimbleTimeInterval { + timeIntervalSince(other).nimbleInterval + } +} + +extension NimbleTimeInterval: NimbleIntervalProtocol { + func rounded(_ rule: FloatingPointRoundingRule) -> NimbleTimeInterval { + timeInterval.rounded(rule).nimbleInterval + } + + static func + (lhs: NimbleTimeInterval, rhs: NimbleTimeInterval) -> NimbleTimeInterval { + (lhs.timeInterval + rhs.timeInterval).nimbleInterval + } + + static func - (lhs: NimbleTimeInterval, rhs: NimbleTimeInterval) -> NimbleTimeInterval { + (lhs.timeInterval - rhs.timeInterval).nimbleInterval + } + + static func * (lhs: NimbleTimeInterval, rhs: NimbleTimeInterval) -> NimbleTimeInterval { + (lhs.timeInterval * rhs.timeInterval).nimbleInterval + } + + static func / (lhs: NimbleTimeInterval, rhs: NimbleTimeInterval) -> NimbleTimeInterval { + (lhs.timeInterval / rhs.timeInterval).nimbleInterval + } + + public static func < (lhs: NimbleTimeInterval, rhs: NimbleTimeInterval) -> Bool { + lhs.timeInterval < rhs.timeInterval + } +} + +// 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 clock: Clock + let interval: Clock.Instant.Interval + + struct AsyncIterator: AsyncIteratorProtocol { + let clock: Clock + let interval: Clock.Instant.Interval + + var last: Clock.Instant? + + func nextDeadline() -> Clock.Instant { + let now = clock.now() + + let last = self.last ?? now + let next = last.advanced(byInterval: interval) + if next < now { + let nextTimestep = interval * (now.intervalSince(next) / interval).rounded(.up) + return last.advanced(byInterval: nextTimestep) + } else { + return next + } + } + + mutating func next() async -> Void? { + let next = nextDeadline() + do { + try await clock.sleep(until: next) + } catch { + return nil + } + last = next + return () + } + } + + func makeAsyncIterator() -> AsyncIterator { + return AsyncIterator(clock: clock, interval: interval) + } +} + +extension AsyncTimerSequence { + init(interval: NimbleTimeInterval) { + self.init(clock: DateClock(), interval: interval) + } +} + +#endif // os(WASI) diff --git a/Sources/Nimble/Utils/NimbleTimeInterval.swift b/Sources/Nimble/Utils/NimbleTimeInterval.swift index 8a368e77f..abe0218e6 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" @@ -49,15 +58,32 @@ extension NimbleTimeInterval: CustomStringConvertible { } #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..177bd094b 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,26 +24,15 @@ 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) { + lock.lock() + defer { lock.unlock() } let info = WaitingInfo(name: fnName, file: file, lineNumber: line) nimblePrecondition( currentWaiter == nil, @@ -62,10 +51,14 @@ internal class AssertionWaitLock: WaitLock { } func isWaitingLocked() -> Bool { + lock.lock() + defer { lock.unlock() } return currentWaiter != nil } func releaseWaitingLock() { + lock.lock() + defer { lock.unlock() } 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..34ebc7046 --- /dev/null +++ b/Tests/NimbleTests/AsyncTimerSequenceTest.swift @@ -0,0 +1,128 @@ +import XCTest +import Foundation +@testable import Nimble + +final class AsyncTimerSequenceTest: XCTestCase { + func testOutputsVoidAtSpecifiedIntervals() async throws { + let clock = FakeClock() + + _ = await AsyncTimerSequence(clock: clock, interval: 1).collect(upTo: 4) + + expect(clock.recordedInstants).to(equal([ + FakeInstant(now: 0), + FakeInstant(now: 1), + FakeInstant(now: 2), + FakeInstant(now: 3), + FakeInstant(now: 4), + ])) + } + + func testOutputsVoidAtSpecifiedIntervals2() async throws { + let clock = FakeClock() + + _ = await AsyncTimerSequence(clock: clock, interval: 2).collect(upTo: 4) + + expect(clock.recordedInstants).to(equal([ + FakeInstant(now: 0), + FakeInstant(now: 2), + FakeInstant(now: 4), + FakeInstant(now: 6), + FakeInstant(now: 8), + ])) + } + func testOutputsVoidAtSpecifiedIntervals3() async throws { + let clock = FakeClock() + + _ = await AsyncTimerSequence(clock: clock, interval: 3).collect(upTo: 4) + + expect(clock.recordedInstants).to(equal([ + FakeInstant(now: 0), + FakeInstant(now: 3), + FakeInstant(now: 6), + FakeInstant(now: 9), + FakeInstant(now: 12), + ])) + } +} + +extension AsyncSequence { + func collect(upTo: Int? = nil) async rethrows -> [Element] { + var values = [Element]() + for try await value in self { + values.append(value) + if let upTo, values.count >= upTo { break } + } + return values + } +} + +struct FakeClock: NimbleClockProtocol { + typealias Instant = FakeInstant + + private final class Implementation: @unchecked Sendable { + var _now = FakeInstant(now: 0) + var now: FakeInstant { + lock.lock() + defer { lock.unlock() } + return _now + } + + var _recordedInstants: [FakeInstant] = [FakeInstant(now: 0)] + var recordedInstants: [FakeInstant] { + lock.lock() + defer { lock.unlock() } + return _recordedInstants + } + + let lock = NSLock() + + func sleep(until: FakeInstant) { + lock.lock() + + defer { lock.unlock() } + + _now = until + _recordedInstants.append(_now) + } + } + + private let current = Implementation() + + var recordedInstants: [FakeInstant] { current.recordedInstants } + + func now() -> FakeInstant { + current.now + } + + func sleep(until: FakeInstant) async throws { + current.sleep(until: until) + } +} + +struct FakeInstant: NimbleInstantProtocol { + typealias Interval = Int + + private let now: Interval + + init(now: Interval) { + self.now = now + } + + func advanced(byInterval interval: Interval) -> FakeInstant { + FakeInstant(now: self.now + interval) + } + + func intervalSince(_ other: FakeInstant) -> Interval { + now - other.now + } + + static func < (lhs: FakeInstant, rhs: FakeInstant) -> Bool { + lhs.now < rhs.now + } +} + +extension Int: NimbleIntervalProtocol { + public func rounded(_ rule: FloatingPointRoundingRule) -> Int { + self + } +}