From b5044036beaba9dbdd226ad588649b0990b003e5 Mon Sep 17 00:00:00 2001 From: Rachel Brindle Date: Tue, 18 Jun 2024 16:05:33 -0700 Subject: [PATCH] Update the waitUntil DSL to be sendable... ish Still more to do here. Will likely to require further refactoring --- Sources/Nimble/AsyncExpression.swift | 2 ++ Sources/Nimble/DSL+AsyncAwait.swift | 8 ++--- Sources/Nimble/DSL+Wait.swift | 13 ++++--- Sources/Nimble/Utils/AsyncAwait.swift | 4 +-- Sources/Nimble/Utils/PollAwait.swift | 51 ++++++++++++++------------- 5 files changed, 41 insertions(+), 37 deletions(-) diff --git a/Sources/Nimble/AsyncExpression.swift b/Sources/Nimble/AsyncExpression.swift index 8ceb2f129..40f0e5e7a 100644 --- a/Sources/Nimble/AsyncExpression.swift +++ b/Sources/Nimble/AsyncExpression.swift @@ -1,3 +1,5 @@ +import Foundation + /// Memoizes the given closure, only calling the passed closure once; even if repeat calls to the returned closure private final class MemoizedClosure: Sendable { enum State { diff --git a/Sources/Nimble/DSL+AsyncAwait.swift b/Sources/Nimble/DSL+AsyncAwait.swift index d9dba629c..d90b16b0c 100644 --- a/Sources/Nimble/DSL+AsyncAwait.swift +++ b/Sources/Nimble/DSL+AsyncAwait.swift @@ -93,7 +93,7 @@ public func waitUntil( file: FileString = #filePath, line: UInt = #line, column: UInt = #column, - action: @escaping (@escaping () -> Void) async -> Void + action: sending @escaping (@escaping @Sendable () -> Void) async -> Void ) async { await throwableUntil( timeout: timeout, @@ -116,7 +116,7 @@ public func waitUntil( file: FileString = #filePath, line: UInt = #line, column: UInt = #column, - action: @escaping (@escaping () -> Void) -> Void + action: sending @escaping (@escaping @Sendable () -> Void) -> Void ) async { await throwableUntil( timeout: timeout, @@ -134,12 +134,12 @@ private enum ErrorResult { private func throwableUntil( timeout: NimbleTimeInterval, sourceLocation: SourceLocation, - action: @escaping (@escaping () -> Void) async throws -> Void) async { + action: sending @escaping (@escaping @Sendable () -> Void) async throws -> Void) async { let leeway = timeout.divided let result = await performBlock( timeoutInterval: timeout, leeway: leeway, - sourceLocation: sourceLocation) { @MainActor (done: @escaping (ErrorResult) -> Void) async throws -> Void in + sourceLocation: sourceLocation) { @MainActor (done: @escaping @Sendable (ErrorResult) -> Void) async throws -> Void in do { try await action { done(.none) diff --git a/Sources/Nimble/DSL+Wait.swift b/Sources/Nimble/DSL+Wait.swift index 8995bc964..911cbc9a2 100644 --- a/Sources/Nimble/DSL+Wait.swift +++ b/Sources/Nimble/DSL+Wait.swift @@ -23,7 +23,7 @@ public class NMBWait: NSObject { file: FileString = #filePath, line: UInt = #line, column: UInt = #column, - action: @escaping (@escaping () -> Void) -> Void) { + action: sending @escaping (@escaping @Sendable () -> Void) -> Void) { // Convert TimeInterval to NimbleTimeInterval until(timeout: timeout.nimbleInterval, file: file, line: line, action: action) } @@ -35,7 +35,7 @@ public class NMBWait: NSObject { file: FileString = #filePath, line: UInt = #line, column: UInt = #column, - action: @escaping (@escaping () -> Void) -> Void) { + action: sending @escaping (@escaping @Sendable () -> Void) -> Void) { return throwableUntil(timeout: timeout, file: file, line: line) { done in action(done) } @@ -48,10 +48,10 @@ public class NMBWait: NSObject { file: FileString = #filePath, line: UInt = #line, column: UInt = #column, - action: @escaping (@escaping () -> Void) throws -> Void) { + action: sending @escaping (@escaping @Sendable () -> Void) throws -> Void) { let awaiter = NimbleEnvironment.activeInstance.awaiter let leeway = timeout.divided - let result = awaiter.performBlock(file: file, line: line) { (done: @escaping (ErrorResult) -> Void) throws -> Void in + let result = awaiter.performBlock(file: file, line: line) { (done: @escaping @Sendable (ErrorResult) -> Void) throws -> Void in DispatchQueue.main.async { let capture = NMBExceptionCapture( handler: ({ exception in @@ -110,8 +110,7 @@ public class NMBWait: NSObject { file: FileString = #filePath, line: UInt = #line, column: UInt = #column, - action: @escaping (@escaping () -> Void) -> Void) { - until(timeout: .seconds(1), fileID: fileID, file: file, line: line, column: column, action: action) + action: sending @escaping (@escaping @Sendable () -> Void) -> Void) { } #else public class func until( @@ -138,7 +137,7 @@ internal func blockedRunLoopErrorMessageFor(_ fnName: String, leeway: NimbleTime /// This function manages the main run loop (`NSRunLoop.mainRunLoop()`) while this function /// is executing. Any attempts to touch the run loop may cause non-deterministic behavior. @available(*, noasync, message: "the sync variant of `waitUntil` does not work in async contexts. Use the async variant as a drop-in replacement") -public func waitUntil(timeout: NimbleTimeInterval = PollingDefaults.timeout, fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, action: @escaping (@escaping () -> Void) -> Void) { +public func waitUntil(timeout: NimbleTimeInterval = PollingDefaults.timeout, fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, action: sending @escaping (@escaping @Sendable () -> Void) -> Void) { NMBWait.until(timeout: timeout, fileID: fileID, file: file, line: line, column: column, action: action) } diff --git a/Sources/Nimble/Utils/AsyncAwait.swift b/Sources/Nimble/Utils/AsyncAwait.swift index 22cf94ca4..cd4dd9d55 100644 --- a/Sources/Nimble/Utils/AsyncAwait.swift +++ b/Sources/Nimble/Utils/AsyncAwait.swift @@ -259,7 +259,7 @@ private func runAwaitTrigger( timeoutInterval: NimbleTimeInterval, leeway: NimbleTimeInterval, sourceLocation: SourceLocation, - _ closure: @escaping @Sendable (@escaping @Sendable (T) -> Void) async throws -> Void + _ closure: @escaping (@escaping @Sendable (T) -> Void) async throws -> Void ) async -> AsyncPollResult { let timeoutQueue = awaiter.timeoutQueue let completionCount = Box(value: 0) @@ -315,7 +315,7 @@ internal func performBlock( timeoutInterval: NimbleTimeInterval, leeway: NimbleTimeInterval, sourceLocation: SourceLocation, - _ closure: @escaping @Sendable (@escaping @Sendable (T) -> Void) async throws -> Void + _ closure: @escaping (@escaping @Sendable (T) -> Void) async throws -> Void ) async -> AsyncPollResult { await runAwaitTrigger( awaiter: NimbleEnvironment.activeInstance.awaiter, diff --git a/Sources/Nimble/Utils/PollAwait.swift b/Sources/Nimble/Utils/PollAwait.swift index 1bc1311ba..76f315309 100644 --- a/Sources/Nimble/Utils/PollAwait.swift +++ b/Sources/Nimble/Utils/PollAwait.swift @@ -64,7 +64,7 @@ internal final class AssertionWaitLock: WaitLock, @unchecked Sendable { } } -internal enum PollResult { +internal enum PollResult: Sendable { /// Incomplete indicates None (aka - this value hasn't been fulfilled yet) case incomplete /// TimedOut indicates the result reached its defined timeout limit before returning @@ -104,9 +104,9 @@ internal enum PollStatus { /// Holds the resulting value from an asynchronous expectation. /// This class is thread-safe at receiving a "response" to this promise. -internal final class AwaitPromise { - private(set) internal var asyncResult: PollResult = .incomplete - private var signal: DispatchSemaphore +internal final class AwaitPromise: Sendable { + nonisolated(unsafe) private(set) internal var asyncResult: PollResult = .incomplete + private let signal: DispatchSemaphore init() { signal = DispatchSemaphore(value: 1) @@ -142,7 +142,7 @@ internal struct PollAwaitTrigger { /// /// This factory stores all the state for an async expectation so that Await doesn't /// doesn't have to manage it. -internal class AwaitPromiseBuilder { +internal class AwaitPromiseBuilder { let awaiter: Awaiter let waitLock: WaitLock let trigger: PollAwaitTrigger @@ -313,36 +313,39 @@ internal class Awaiter { return DispatchSource.makeTimerSource(flags: .strict, queue: queue) } - func performBlock( + func performBlock( file: FileString, line: UInt, - _ closure: @escaping (@escaping (T) -> Void) throws -> Void + _ closure: sending @escaping (@escaping @Sendable (T) -> Void) throws -> Void ) -> AwaitPromiseBuilder { let promise = AwaitPromise() let timeoutSource = createTimerSource(timeoutQueue) - var completionCount = 0 + nonisolated(unsafe) var completionCount = 0 + let lock = NSRecursiveLock() let trigger = PollAwaitTrigger(timeoutSource: timeoutSource, actionSource: nil) { try closure { result in - completionCount += 1 - if completionCount < 2 { - func completeBlock() { - if promise.resolveResult(.completed(result)) { - #if canImport(CoreFoundation) - CFRunLoopStop(CFRunLoopGetMain()) - #else - RunLoop.main._stop() - #endif + lock.withLock { + completionCount += 1 + if completionCount < 2 { + @Sendable func completeBlock() { + if promise.resolveResult(.completed(result)) { +#if canImport(CoreFoundation) + CFRunLoopStop(CFRunLoopGetMain()) +#else + RunLoop.main._stop() +#endif + } } - } - if Thread.isMainThread { - completeBlock() + if Thread.isMainThread { + completeBlock() + } else { + DispatchQueue.main.async { completeBlock() } + } } else { - DispatchQueue.main.async { completeBlock() } + fail("waitUntil(..) expects its completion closure to be only called once", + file: file, line: line) } - } else { - fail("waitUntil(..) expects its completion closure to be only called once", - file: file, line: line) } } }