Skip to content

Commit

Permalink
Eliminate concurrency warnings in polling expectations
Browse files Browse the repository at this point in the history
  • Loading branch information
younata committed Oct 14, 2024
1 parent 27cef92 commit 322f9be
Show file tree
Hide file tree
Showing 26 changed files with 151 additions and 105 deletions.
29 changes: 22 additions & 7 deletions Sources/Nimble/Adapters/NMBExpectation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,42 @@ private func from(objcMatcher: NMBMatcher) -> Matcher<NSObject> {
}

// Equivalent to Expectation, but for Nimble's Objective-C interface
public class NMBExpectation: NSObject {
internal let _actualBlock: () -> NSObject?
internal var _negative: Bool
public final class NMBExpectation: NSObject, Sendable {
internal let _actualBlock: @Sendable () -> NSObject?
internal let _negative: Bool
internal let _file: FileString
internal let _line: UInt
internal var _timeout: NimbleTimeInterval = .seconds(1)
internal let _timeout: NimbleTimeInterval

@objc public init(actualBlock: @escaping () -> NSObject?, negative: Bool, file: FileString, line: UInt) {
@objc public init(actualBlock: @escaping @Sendable () -> sending NSObject?, negative: Bool, file: FileString, line: UInt) {
self._actualBlock = actualBlock
self._negative = negative
self._file = file
self._line = line
self._timeout = .seconds(1)
}

private init(actualBlock: @escaping @Sendable () -> sending NSObject?, negative: Bool, file: FileString, line: UInt, timeout: NimbleTimeInterval) {
self._actualBlock = actualBlock
self._negative = negative
self._file = file
self._line = line
self._timeout = timeout
}

private var expectValue: SyncExpectation<NSObject> {
return expect(file: _file, line: _line, self._actualBlock() as NSObject?)
}

@objc public var withTimeout: (TimeInterval) -> NMBExpectation {
return { timeout in self._timeout = timeout.nimbleInterval
return self
return { timeout in
NMBExpectation(
actualBlock: self._actualBlock,
negative: self._negative,
file: self._file,
line: self._line,
timeout: timeout.nimbleInterval
)
}
}

Expand Down
14 changes: 8 additions & 6 deletions Sources/Nimble/AsyncExpression.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

/// Memoizes the given closure, only calling the passed closure once; even if repeat calls to the returned closure
private final class MemoizedClosure<T>: Sendable {
private final class MemoizedClosure<T: Sendable>: Sendable {
enum State {
case notStarted
case inProgress
Expand All @@ -13,17 +13,17 @@ private final class MemoizedClosure<T>: Sendable {
nonisolated(unsafe) private var _continuations = [CheckedContinuation<T, Error>]()
nonisolated(unsafe) private var _task: Task<Void, Never>?

nonisolated(unsafe) let closure: () async throws -> sending T
let closure: @Sendable () async throws -> T

init(_ closure: @escaping () async throws -> sending T) {
init(_ closure: @escaping @Sendable () async throws -> T) {
self.closure = closure
}

deinit {
_task?.cancel()
}

@Sendable func callAsFunction(_ withoutCaching: Bool) async throws -> sending T {
@Sendable func callAsFunction(_ withoutCaching: Bool) async throws -> T {
if withoutCaching {
try await closure()
} else {
Expand Down Expand Up @@ -66,7 +66,9 @@ private final class MemoizedClosure<T>: Sendable {

// Memoizes the given closure, only calling the passed
// closure once; even if repeat calls to the returned closure
private func memoizedClosure<T>(_ closure: sending @escaping () async throws -> sending T) -> @Sendable (Bool) async throws -> sending T {
private func memoizedClosure<T: Sendable>(
_ closure: sending @escaping @Sendable () async throws -> T
) -> @Sendable (Bool) async throws -> T {
let memoized = MemoizedClosure(closure)
return memoized.callAsFunction(_:)
}
Expand All @@ -82,7 +84,7 @@ private func memoizedClosure<T>(_ closure: sending @escaping () async throws ->
///
/// This provides a common consumable API for matchers to utilize to allow
/// Nimble to change internals to how the captured closure is managed.
public struct AsyncExpression<Value> {
public actor AsyncExpression<Value: Sendable> {
internal let _expression: @Sendable (Bool) async throws -> sending Value?
internal let _withoutCaching: Bool
public let location: SourceLocation
Expand Down
6 changes: 3 additions & 3 deletions Sources/Nimble/DSL+AsyncAwait.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public func waitUntil(
file: FileString = #filePath,
line: UInt = #line,
column: UInt = #column,
action: sending @escaping (@escaping @Sendable () -> Void) async -> Void
action: @escaping @Sendable (@escaping @Sendable () -> Void) async -> Void
) async {
await throwableUntil(
timeout: timeout,
Expand All @@ -116,7 +116,7 @@ public func waitUntil(
file: FileString = #filePath,
line: UInt = #line,
column: UInt = #column,
action: sending @escaping (@escaping @Sendable () -> Void) -> Void
action: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void
) async {
await throwableUntil(
timeout: timeout,
Expand All @@ -134,7 +134,7 @@ private enum ErrorResult {
private func throwableUntil(
timeout: NimbleTimeInterval,
sourceLocation: SourceLocation,
action: sending @escaping (@escaping @Sendable () -> Void) async throws -> Void) async {
action: @escaping @Sendable (@escaping @Sendable () -> Void) async throws -> Void) async {
let leeway = timeout.divided
let result = await performBlock(
timeoutInterval: timeout,
Expand Down
24 changes: 12 additions & 12 deletions Sources/Nimble/DSL+Require.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/// `require` will return the result of the expression if the matcher passes, and throw an error if not.
/// if a `customError` is given, then that will be thrown. Otherwise, a ``RequireError`` will be thrown.
@discardableResult
public func require<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure @escaping () throws -> sending T?) -> SyncRequirement<T> {
public func require<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure @escaping @Sendable () throws -> sending T?) -> SyncRequirement<T> {
return SyncRequirement(
expression: Expression(
expression: expression,
Expand All @@ -17,7 +17,7 @@ public func require<T>(fileID: String = #fileID, file: FileString = #filePath, l
/// `require` will return the result of the expression if the matcher passes, and throw an error if not.
/// if a `customError` is given, then that will be thrown. Otherwise, a ``RequireError`` will be thrown.
@discardableResult
public func require<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> sending (() throws -> sending T)) -> SyncRequirement<T> {
public func require<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> sending T)) -> SyncRequirement<T> {
return SyncRequirement(
expression: Expression(
expression: expression(),
Expand All @@ -31,7 +31,7 @@ public func require<T>(fileID: String = #fileID, file: FileString = #filePath, l
/// `require` will return the result of the expression if the matcher passes, and throw an error if not.
/// if a `customError` is given, then that will be thrown. Otherwise, a ``RequireError`` will be thrown.
@discardableResult
public func require<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> sending (() throws -> sending T?)) -> SyncRequirement<T> {
public func require<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> sending T?)) -> SyncRequirement<T> {
return SyncRequirement(
expression: Expression(
expression: expression(),
Expand All @@ -45,7 +45,7 @@ public func require<T>(fileID: String = #fileID, file: FileString = #filePath, l
/// `require` will return the result of the expression if the matcher passes, and throw an error if not.
/// if a `customError` is given, then that will be thrown. Otherwise, a ``RequireError`` will be thrown.
@discardableResult
public func require(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> sending (() throws -> sending Void)) -> SyncRequirement<Void> {
public func require(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> sending Void)) -> SyncRequirement<Void> {
return SyncRequirement(
expression: Expression(
expression: expression(),
Expand All @@ -61,7 +61,7 @@ public func require(fileID: String = #fileID, file: FileString = #filePath, line
///
/// This is provided as an alternative to ``require``, for when you want to be specific about whether you're using ``SyncRequirement`` or ``AsyncRequirement``.
@discardableResult
public func requires<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure @escaping () throws -> sending T?) -> SyncRequirement<T> {
public func requires<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure @escaping @Sendable () throws -> sending T?) -> SyncRequirement<T> {
return SyncRequirement(
expression: Expression(
expression: expression,
Expand All @@ -77,7 +77,7 @@ public func requires<T>(fileID: String = #fileID, file: FileString = #filePath,
///
/// This is provided as an alternative to ``require``, for when you want to be specific about whether you're using ``SyncRequirement`` or ``AsyncRequirement``.
@discardableResult
public func requires<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> sending (() throws -> sending T)) -> SyncRequirement<T> {
public func requires<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> sending T)) -> SyncRequirement<T> {
return SyncRequirement(
expression: Expression(
expression: expression(),
Expand All @@ -93,7 +93,7 @@ public func requires<T>(fileID: String = #fileID, file: FileString = #filePath,
///
/// This is provided as an alternative to ``require``, for when you want to be specific about whether you're using ``SyncRequirement`` or ``AsyncRequirement``.
@discardableResult
public func requires<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> sending (() throws -> sending T?)) -> SyncRequirement<T> {
public func requires<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> sending T?)) -> SyncRequirement<T> {
return SyncRequirement(
expression: Expression(
expression: expression(),
Expand All @@ -109,7 +109,7 @@ public func requires<T>(fileID: String = #fileID, file: FileString = #filePath,
///
/// This is provided as an alternative to ``require``, for when you want to be specific about whether you're using ``SyncRequirement`` or ``AsyncRequirement``.
@discardableResult
public func requires(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> sending (() throws -> sending Void)) -> SyncRequirement<Void> {
public func requires(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> sending Void)) -> SyncRequirement<Void> {
return SyncRequirement(
expression: Expression(
expression: expression(),
Expand Down Expand Up @@ -216,7 +216,7 @@ public func requirea<T: Sendable>(fileID: String = #fileID, file: FileString = #
/// `unwrap` will return the result of the expression if it is non-nil, and throw an error if the value is nil.
/// if a `customError` is given, then that will be thrown. Otherwise, a ``RequireError`` will be thrown.
@discardableResult
public func unwrap<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: @autoclosure @escaping () throws -> sending T?) throws -> T {
public func unwrap<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: @autoclosure @escaping @Sendable () throws -> sending T?) throws -> T {
try requires(fileID: fileID, file: file, line: line, column: column, customError: customError, expression()).toNot(beNil(), description: description)
}

Expand All @@ -226,7 +226,7 @@ public func unwrap<T>(fileID: String = #fileID, file: FileString = #filePath, li
/// `unwrap` will return the result of the expression if it is non-nil, and throw an error if the value is nil.
/// if a `customError` is given, then that will be thrown. Otherwise, a ``RequireError`` will be thrown.
@discardableResult
public func unwrap<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: @autoclosure () -> sending (() throws -> sending T?)) throws -> T {
public func unwrap<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> sending T?)) throws -> T {
try requires(fileID: fileID, file: file, line: line, column: column, customError: customError, expression()).toNot(beNil(), description: description)
}

Expand All @@ -236,7 +236,7 @@ public func unwrap<T>(fileID: String = #fileID, file: FileString = #filePath, li
/// `unwraps` will return the result of the expression if it is non-nil, and throw an error if the value is nil.
/// if a `customError` is given, then that will be thrown. Otherwise, a ``RequireError`` will be thrown.
@discardableResult
public func unwraps<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: @autoclosure @escaping () throws -> sending T?) throws -> T {
public func unwraps<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: @autoclosure @escaping @Sendable () throws -> sending T?) throws -> T {
try requires(fileID: fileID, file: file, line: line, column: column, customError: customError, expression()).toNot(beNil(), description: description)
}

Expand All @@ -246,7 +246,7 @@ public func unwraps<T>(fileID: String = #fileID, file: FileString = #filePath, l
/// `unwraps` will return the result of the expression if it is non-nil, and throw an error if the value is nil.
/// if a `customError` is given, then that will be thrown. Otherwise, a ``RequireError`` will be thrown.
@discardableResult
public func unwraps<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: @autoclosure () -> sending (() throws -> sending T?)) throws -> T {
public func unwraps<T>(fileID: String = #fileID, file: FileString = #filePath, line: UInt = #line, column: UInt = #column, customError: Error? = nil, description: String? = nil, _ expression: @autoclosure () -> (@Sendable () throws -> sending T?)) throws -> T {
try requires(fileID: fileID, file: file, line: line, column: column, customError: customError, expression()).toNot(beNil(), description: description)
}

Expand Down
29 changes: 20 additions & 9 deletions Sources/Nimble/DSL+Wait.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public class NMBWait: NSObject {
file: FileString = #filePath,
line: UInt = #line,
column: UInt = #column,
action: sending @escaping (@escaping @Sendable () -> Void) -> Void) {
action: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void) {
// Convert TimeInterval to NimbleTimeInterval
until(timeout: timeout.nimbleInterval, file: file, line: line, action: action)
}
Expand All @@ -35,7 +35,7 @@ public class NMBWait: NSObject {
file: FileString = #filePath,
line: UInt = #line,
column: UInt = #column,
action: sending @escaping (@escaping @Sendable () -> Void) -> Void) {
action: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void) {
return throwableUntil(timeout: timeout, file: file, line: line) { done in
action(done)
}
Expand All @@ -48,9 +48,10 @@ public class NMBWait: NSObject {
file: FileString = #filePath,
line: UInt = #line,
column: UInt = #column,
action: sending @escaping (@escaping @Sendable () -> Void) throws -> Void) {
action: @escaping @Sendable (@escaping @Sendable () -> Void) throws -> Void) {
let awaiter = NimbleEnvironment.activeInstance.awaiter
let leeway = timeout.divided
let location = SourceLocation(fileID: fileID, filePath: file, line: line, column: column)
let result = awaiter.performBlock(file: file, line: line) { (done: @escaping @Sendable (ErrorResult) -> Void) throws -> Void in

Check warning on line 55 in Sources/Nimble/DSL+Wait.swift

View workflow job for this annotation

GitHub Actions / lint

Redundant Void Return Violation: Returning Void in a function declaration is redundant (redundant_void_return)
DispatchQueue.main.async {
let capture = NMBExceptionCapture(
Expand All @@ -69,10 +70,12 @@ public class NMBWait: NSObject {
}
}
}
}.timeout(timeout, forcefullyAbortTimeout: leeway).wait(
"waitUntil(...)",
sourceLocation: SourceLocation(fileID: fileID, filePath: file, line: line, column: column)
)
}
.timeout(timeout, forcefullyAbortTimeout: leeway)
.wait(
"waitUntil(...)",
sourceLocation: location
)

switch result {
case .incomplete: internalError("Reached .incomplete state for waitUntil(...).")
Expand Down Expand Up @@ -110,7 +113,8 @@ public class NMBWait: NSObject {
file: FileString = #filePath,
line: UInt = #line,
column: UInt = #column,
action: sending @escaping (@escaping @Sendable () -> Void) -> Void) {
action: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void) {
until(timeout: .seconds(1), fileID: fileID, file: file, line: line, column: column, action: action)
}
#else
public class func until(
Expand All @@ -137,7 +141,14 @@ 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: sending @escaping (@escaping @Sendable () -> Void) -> Void) {
public func waitUntil(
timeout: NimbleTimeInterval = PollingDefaults.timeout,
fileID: String = #fileID,
file: FileString = #filePath,
line: UInt = #line,
column: UInt = #column,
action: @escaping @Sendable (@escaping @Sendable () -> Void) -> Void
) {
NMBWait.until(timeout: timeout, fileID: fileID, file: file, line: line, column: column, action: action)
}

Expand Down
Loading

0 comments on commit 322f9be

Please sign in to comment.