From 9a383e3c3d5b7c5d5c350e5835acbc9a1608d81f Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Sun, 20 Aug 2023 17:35:50 -0700 Subject: [PATCH 1/5] 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. --- Nimble.xcodeproj/project.pbxproj | 20 + Sources/Nimble/DSL+AsyncAwait.swift | 7 +- Sources/Nimble/Polling+AsyncAwait.swift | 2 +- Sources/Nimble/Utils/AsyncAwait.swift | 504 ++++++++++++------ Sources/Nimble/Utils/NimbleTimeInterval.swift | 42 +- Sources/Nimble/Utils/PollAwait.swift | 47 +- Tests/NimbleTests/AsyncPromiseTest.swift | 59 ++ .../NimbleTests/AsyncTimerSequenceTest.swift | 18 + 8 files changed, 493 insertions(+), 206 deletions(-) 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..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)) + } +} From b8ade82226ba6c6163f4741950babeb6465f15a9 Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Sun, 20 Aug 2023 19:45:54 -0700 Subject: [PATCH 2/5] NSLocking.withLock is not available in earlier versions of the OS --- Sources/Nimble/Utils/AsyncAwait.swift | 38 +++++++++++++-------------- Sources/Nimble/Utils/PollAwait.swift | 28 ++++++++++---------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/Sources/Nimble/Utils/AsyncAwait.swift b/Sources/Nimble/Utils/AsyncAwait.swift index b92e74e16..5051ec9bc 100644 --- a/Sources/Nimble/Utils/AsyncAwait.swift +++ b/Sources/Nimble/Utils/AsyncAwait.swift @@ -113,25 +113,25 @@ internal actor AsyncPromise { func await() async -> T { await withUnsafeContinuation { continuation in - lock.withLock { - if let value { - continuation.resume(returning: value) - } else { - continuations.append(continuation) - } + lock.lock() + defer { lock.unlock() } + if let value { + continuation.resume(returning: value) + } else { + continuations.append(continuation) } } } func send(_ value: T) { - lock.withLock { - if self.value != nil { return } - continuations.forEach { continuation in - continuation.resume(returning: value) - } - continuations = [] - self.value = value + lock.lock() + defer { lock.unlock() } + if self.value != nil { return } + continuations.forEach { continuation in + continuation.resume(returning: value) } + continuations = [] + self.value = value } } @@ -282,9 +282,9 @@ private func runPoller( private final class Box: @unchecked Sendable { private var _value: T var value: T { - lock.withLock { - _value - } + lock.lock() + defer { lock.unlock() } + return _value } private let lock = NSLock() @@ -294,9 +294,9 @@ private final class Box: @unchecked Sendable { } func operate(_ closure: @Sendable (T) -> T) { - lock.withLock { - _value = closure(_value) - } + lock.lock() + defer { lock.unlock() } + _value = closure(_value) } } diff --git a/Sources/Nimble/Utils/PollAwait.swift b/Sources/Nimble/Utils/PollAwait.swift index 196853e8a..177bd094b 100644 --- a/Sources/Nimble/Utils/PollAwait.swift +++ b/Sources/Nimble/Utils/PollAwait.swift @@ -31,11 +31,12 @@ internal final class AssertionWaitLock: WaitLock, @unchecked Sendable { init() { } func acquireWaitingLock(_ fnName: String, file: FileString, line: UInt) { - lock.withLock { - let info = WaitingInfo(name: fnName, file: file, lineNumber: line) - nimblePrecondition( - currentWaiter == nil, - "InvalidNimbleAPIUsage", + lock.lock() + defer { lock.unlock() } + let info = WaitingInfo(name: fnName, file: file, lineNumber: line) + nimblePrecondition( + currentWaiter == nil, + "InvalidNimbleAPIUsage", """ Nested async expectations are not allowed to avoid creating flaky tests. @@ -45,21 +46,20 @@ internal final class AssertionWaitLock: WaitLock, @unchecked Sendable { \t\(currentWaiter!) is currently managing the main run loop. """ - ) - currentWaiter = info - } + ) + currentWaiter = info } func isWaitingLocked() -> Bool { - lock.withLock { - currentWaiter != nil - } + lock.lock() + defer { lock.unlock() } + return currentWaiter != nil } func releaseWaitingLock() { - lock.withLock { - currentWaiter = nil - } + lock.lock() + defer { lock.unlock() } + currentWaiter = nil } } From 5a99653e052230533b4f724b96a8ef7e03272c9f Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Sun, 20 Aug 2023 22:21:45 -0700 Subject: [PATCH 3/5] Remove NimbleTimeInterval+Duration --- Sources/Nimble/Utils/NimbleTimeInterval.swift | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/Sources/Nimble/Utils/NimbleTimeInterval.swift b/Sources/Nimble/Utils/NimbleTimeInterval.swift index 516dafe1c..abe0218e6 100644 --- a/Sources/Nimble/Utils/NimbleTimeInterval.swift +++ b/Sources/Nimble/Utils/NimbleTimeInterval.swift @@ -57,18 +57,6 @@ 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 Foundation From 98777931f6ff08a6e75970e49e2904bbd26ef34e Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Mon, 11 Sep 2023 17:49:40 -0700 Subject: [PATCH 4/5] 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 --- Sources/Nimble/Utils/AsyncAwait.swift | 112 +++++++++++++-- .../NimbleTests/AsyncTimerSequenceTest.swift | 130 ++++++++++++++++-- 2 files changed, 222 insertions(+), 20 deletions(-) diff --git a/Sources/Nimble/Utils/AsyncAwait.swift b/Sources/Nimble/Utils/AsyncAwait.swift index 5051ec9bc..f38400fb0 100644 --- a/Sources/Nimble/Utils/AsyncAwait.swift +++ b/Sources/Nimble/Utils/AsyncAwait.swift @@ -7,28 +7,111 @@ import Foundation private let timeoutLeeway = NimbleTimeInterval.milliseconds(1) private let pollLeeway = NimbleTimeInterval.milliseconds(1) +// 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 { +struct AsyncTimerSequence: AsyncSequence { typealias Element = Void - let interval: NimbleTimeInterval + let clock: Clock + let interval: Clock.Instant.Interval struct AsyncIterator: AsyncIteratorProtocol { typealias Element = Void - let interval: NimbleTimeInterval + let clock: Clock + let interval: Clock.Instant.Interval - var last: Date? = nil + var last: Clock.Instant? = nil - func nextDeadline() -> Date { - let now = Date() + func nextDeadline() -> Clock.Instant { + let now = clock.now() let last = self.last ?? now - let next = last.advanced(by: interval.timeInterval) + let next = last.advanced(byInterval: interval) if next < now { - let nextTimestep = interval.timeInterval * ((now.timeIntervalSince(next)) / interval.timeInterval).rounded(.up) - return last.advanced(by: nextTimestep) + let nextTimestep = interval * (now.intervalSince(next) / interval).rounded(.up) + return last.advanced(byInterval: nextTimestep) } else { return next } @@ -36,9 +119,8 @@ struct AsyncTimerSequence: AsyncSequence { 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) + try await clock.sleep(until: next) } catch { return nil } @@ -48,7 +130,13 @@ struct AsyncTimerSequence: AsyncSequence { } func makeAsyncIterator() -> AsyncIterator { - return AsyncIterator(interval: interval) + return AsyncIterator(clock: clock, interval: interval) + } +} + +extension AsyncTimerSequence { + init(interval: NimbleTimeInterval) { + self.init(clock: DateClock(), interval: interval) } } diff --git a/Tests/NimbleTests/AsyncTimerSequenceTest.swift b/Tests/NimbleTests/AsyncTimerSequenceTest.swift index 624ba5149..faf352536 100644 --- a/Tests/NimbleTests/AsyncTimerSequenceTest.swift +++ b/Tests/NimbleTests/AsyncTimerSequenceTest.swift @@ -4,15 +4,129 @@ import Foundation 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 } + 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 { + get { + lock.lock() + defer { lock.unlock() } + return _now + } } - 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)) + var _recordedInstants: [FakeInstant] = [FakeInstant(now: 0)] + var recordedInstants: [FakeInstant] { + get { + 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 } } From 1e1023524d0a456b03f06a8f33fc77456c605b9a Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Mon, 11 Sep 2023 20:53:15 -0700 Subject: [PATCH 5/5] Fix swiftlint errors --- Nimble.xcodeproj/project.pbxproj | 10 ++ Sources/Nimble/AsyncExpression.swift | 1 - Sources/Nimble/DSL.swift | 1 - Sources/Nimble/Utils/AsyncAwait.swift | 138 +----------------- Sources/Nimble/Utils/AsyncTimerSequence.swift | 138 ++++++++++++++++++ .../NimbleTests/AsyncTimerSequenceTest.swift | 22 ++- 6 files changed, 159 insertions(+), 151 deletions(-) create mode 100644 Sources/Nimble/Utils/AsyncTimerSequence.swift diff --git a/Nimble.xcodeproj/project.pbxproj b/Nimble.xcodeproj/project.pbxproj index 703d1a227..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 */; }; @@ -796,6 +800,7 @@ 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 = ""; }; @@ -1140,6 +1145,7 @@ children = ( 1FD8CD261968AB07008ED995 /* PollAwait.swift */, 89F5E08B290B8D22001F9377 /* AsyncAwait.swift */, + 891A04702AB0164500B46613 /* AsyncTimerSequence.swift */, 1FD8CD271968AB07008ED995 /* SourceLocation.swift */, 1FD8CD281968AB07008ED995 /* Stringers.swift */, AE4BA9AC1C88DDB500B73906 /* Errors.swift */, @@ -1718,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 */, @@ -1844,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 */, @@ -2045,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 */, @@ -2171,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 */, 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.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/Utils/AsyncAwait.swift b/Sources/Nimble/Utils/AsyncAwait.swift index f38400fb0..556d54585 100644 --- a/Sources/Nimble/Utils/AsyncAwait.swift +++ b/Sources/Nimble/Utils/AsyncAwait.swift @@ -7,139 +7,6 @@ import Foundation private let timeoutLeeway = NimbleTimeInterval.milliseconds(1) private let pollLeeway = NimbleTimeInterval.milliseconds(1) -// 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 { - typealias Element = Void - - let clock: Clock - let interval: Clock.Instant.Interval - - var last: Clock.Instant? = nil - - 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) - } -} - // 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 { @@ -177,7 +44,7 @@ internal enum AsyncPollResult { case .incomplete: return .incomplete case .timedOut: return .timedOut case .blockedRunLoop: return .blockedRunLoop - case .completed(let t): return .completed(t) + case .completed(let value): return .completed(value) case .errorThrown(let error): return .errorThrown(error) } } @@ -240,7 +107,7 @@ internal actor AsyncPromise { } } -///.Wait until the timeout period, then checks why the matcher might have timed out +/// Wait until the timeout period, then checks why the matcher might have timed out /// /// Why Dispatch? /// @@ -468,5 +335,4 @@ internal func pollBlock( ) } - #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/Tests/NimbleTests/AsyncTimerSequenceTest.swift b/Tests/NimbleTests/AsyncTimerSequenceTest.swift index faf352536..34ebc7046 100644 --- a/Tests/NimbleTests/AsyncTimerSequenceTest.swift +++ b/Tests/NimbleTests/AsyncTimerSequenceTest.swift @@ -13,7 +13,7 @@ final class AsyncTimerSequenceTest: XCTestCase { FakeInstant(now: 1), FakeInstant(now: 2), FakeInstant(now: 3), - FakeInstant(now: 4) + FakeInstant(now: 4), ])) } @@ -27,7 +27,7 @@ final class AsyncTimerSequenceTest: XCTestCase { FakeInstant(now: 2), FakeInstant(now: 4), FakeInstant(now: 6), - FakeInstant(now: 8) + FakeInstant(now: 8), ])) } func testOutputsVoidAtSpecifiedIntervals3() async throws { @@ -40,7 +40,7 @@ final class AsyncTimerSequenceTest: XCTestCase { FakeInstant(now: 3), FakeInstant(now: 6), FakeInstant(now: 9), - FakeInstant(now: 12) + FakeInstant(now: 12), ])) } } @@ -62,20 +62,16 @@ struct FakeClock: NimbleClockProtocol { private final class Implementation: @unchecked Sendable { var _now = FakeInstant(now: 0) var now: FakeInstant { - get { - lock.lock() - defer { lock.unlock() } - return _now - } + lock.lock() + defer { lock.unlock() } + return _now } var _recordedInstants: [FakeInstant] = [FakeInstant(now: 0)] var recordedInstants: [FakeInstant] { - get { - lock.lock() - defer { lock.unlock() } - return _recordedInstants - } + lock.lock() + defer { lock.unlock() } + return _recordedInstants } let lock = NSLock()