Skip to content

Commit

Permalink
Merge pull request #15 from Quick/dynamic-pendable
Browse files Browse the repository at this point in the history
Make Pendable resolvable after being called.
  • Loading branch information
younata authored Apr 13, 2024
2 parents a7fac6f + d6ba208 commit e962902
Show file tree
Hide file tree
Showing 10 changed files with 438 additions and 134 deletions.
91 changes: 0 additions & 91 deletions Sources/Fakes/Pendable.swift

This file was deleted.

178 changes: 178 additions & 0 deletions Sources/Fakes/Pendable/Pendable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import Foundation

protocol ResolvableWithFallback {
func resolveWithFallback()
}

/// Pendable is a safe way to represent the 2 states that an asynchronous call can be in
///
/// - `pending`, the state while waiting for the call to finish.
/// - `finished`, the state once the call has finished.
///
/// Pendable allows you to finish a pending call after it's been made. This makes Pendable behave very
/// similarly to something like Combine's `Future`.
///
/// - Note: The reason you must provide a fallback value is to prevent deadlock when used in test.
/// Unlike something like Combine's `Future`, it is very often the case that you will write
/// tests which end while the call is in the pending state. If you do this too much, then your
/// entire test suite will deadlock, as Swift Concurrency works under the assumption that
/// blocked tasks of work will always eventually be unblocked. To help prevent this, pending calls
/// are always resolved with the fallback after a given delay. You can also manually force this
/// by calling the ``Pendable\resolveWithFallback()`` method.
public final class Pendable<Value: Sendable>: @unchecked Sendable, ResolvableWithFallback {
private enum State: Sendable {
case pending
case finished(Value)
}

private let lock = NSRecursiveLock()
private var state = State.pending

private var inProgressCalls = [UnsafeContinuation<Value, Never>]()

private let fallbackValue: Value

private var currentValue: Value {
switch state {
case .pending:
return fallbackValue
case .finished(let value):
return value
}
}

deinit {
resolveWithFallback()
}

/// Initializes a new `Pendable`, in a pending state, with the given fallback value.
public init(fallbackValue: Value) {
self.fallbackValue = fallbackValue
}

/// Gets the value for the `Pendable`, possibly waiting until it's resolved.
///
/// - parameter fallbackDelay: The amount of time (in seconds) to wait until the call returns
/// the fallback value. This is only used when the `Pendable` is in a pending state.
public func call(fallbackDelay: TimeInterval = PendableDefaults.delay) async -> Value {
return await withTaskGroup(of: Value.self) { taskGroup in
taskGroup.addTask { await self.handleCall() }
taskGroup.addTask { await self.resolveAfterDelay(fallbackDelay) }

guard let value = await taskGroup.next() else {
fatalError("There were no tasks in the task group. This should not ever happen.")
}
taskGroup.cancelAll()
return value

}
}

/// Resolves the `Pendable` with the fallback value.
///
/// - Note: This no-ops if the pendable is already in a resolved state.
/// - Note: This is called for when you re-stub a `Spy` in ``Spy/stub(_:)``
public func resolveWithFallback() {
lock.lock()
defer { lock.unlock() }

if case .pending = state {
resolve(with: fallbackValue)
}
}

/// Resolves the `Pendable` with the given value.
///
/// Even if the pendable is already resolves, this resets the resolved value to the given value.
public func resolve(with value: Value) {
lock.lock()
defer { lock.unlock() }
state = .finished(value)
inProgressCalls.forEach {
$0.resume(returning: value)
}
inProgressCalls = []

}

/// Resolves any outstanding calls to the `Pendable` with the current value,
/// and resets it back into the pending state.
public func reset() {
lock.lock()
defer { lock.unlock() }

inProgressCalls.forEach {
$0.resume(returning: currentValue)
}
inProgressCalls = []
state = .pending
}

// MARK: - Private
private func handleCall() async -> Value {
return await withUnsafeContinuation { continuation in
lock.lock()
defer { lock.unlock() }
switch state {
case .pending:
inProgressCalls.append(continuation)
case .finished(let value):
continuation.resume(returning: value)
}
}
}

private func resolveAfterDelay(_ delay: TimeInterval) async -> Value {
do {
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
} catch {}
resolveWithFallback()
return fallbackValue
}
}

public typealias ThrowingDynamicPendable<Success, Failure: Error> = Pendable<Result<Success, Failure>>

extension Pendable {
/// Gets or throws value for the `Pendable`, possibly waiting until it's resolved.
///
/// - parameter resolveDelay: The amount of time (in seconds) to wait until the call returns
/// the fallback value. This is only used when the `Pendable` is in a pending state.
public func call<Success, Failure: Error>(
resolveDelay: TimeInterval = PendableDefaults.delay
) async throws -> Success where Value == Result<Success, Failure> {
try await call(fallbackDelay: resolveDelay).get()
}
}

extension Pendable {
/// Creates a new finished `Pendable` pre-resolved with the given value.
public static func finished(_ value: Value) -> Pendable<Value> {
let pendable = Pendable(fallbackValue: value)
pendable.resolve(with: value)
return pendable
}

/// Creates a new finished `Pendable` pre-resolved with Void.
public static func finished() -> Pendable where Value == Void {
return Pendable.finished(())
}
}

extension Pendable {
/// Creates a new pending `Pendable` with the given fallback value.
public static func pending(fallback: Value) -> Pendable<Value> {
return Pendable(fallbackValue: fallback)
}

/// Creates a new pending `Pendable` with a fallback value of Void.
public static func pending() -> Pendable<Value> where Value == Void {
return Pendable(fallbackValue: ())
}

/// Creates a new pending `Pendable` with a fallback value of nil.
public static func pending<Wrapped>() -> Pendable<Value> where Value == Optional<Wrapped> {
// swiftlint:disable:previous syntactic_sugar
return Pendable(fallbackValue: nil)
}
}
36 changes: 36 additions & 0 deletions Sources/Fakes/Pendable/PendableDefaults.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation

/// Default values for use with Pendable.
public final class PendableDefaults: @unchecked Sendable {
public static let shared = PendableDefaults()
private let lock = NSLock()

public init() {}

/// The amount of time to delay before resolving a pending Pendable with the fallback value.
/// By default this is 2 seconds. Conveniently, just long enough to be twice Nimble's default polling timeout.
/// In general, you should keep this set to some number greater than Nimble's default polling timeout,
/// in order to allow polling matchers to work correctly.
public static var delay: TimeInterval {
get {
PendableDefaults.shared.delay
}
set {
PendableDefaults.shared.delay = newValue
}
}

private var _delay: TimeInterval = 2
public var delay: TimeInterval {
get {
lock.lock()
defer { lock.unlock() }
return _delay
}
set {
lock.lock()
_delay = newValue
lock.unlock()
}
}
}
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ extension Spy {
}
}

extension Spy {
/// Resolve the pendable Spy's stub with Void
public func resolveStub() where Returning == Pendable<Void> {
self.resolveStub(with: ())
}
}

extension Spy {
/// Update the pendable Spy's stub to be in a pending state.
public func stub<Value>(pendingFallback: Value) where Returning == Pendable<Value> {
Expand All @@ -30,6 +37,12 @@ extension Spy {
self.stub(.pending(fallback: ()))
}

/// Update the pendable Spy's stub to be in a pending state.
public func stubPending<Wrapped>() where Returning == Pendable<Optional<Wrapped>> {
// swiftlint:disable:previous syntactic_sugar
self.stub(.pending(fallback: nil))
}

/// Update the pendable Spy's stub to return the given value.
///
/// - parameter finished: The value to return when `callAsFunction` is called.
Expand All @@ -44,31 +57,25 @@ extension Spy {
}

extension Spy {
/// Records the arguments and handles the result according to ``Pendable/resolve(delay:)-hvhg``.
/// Records the arguments and handles the result according to ``Pendable/call(fallbackDelay:)``.
///
/// - parameter arguments: The arguments to record.
/// - parameter pendingDelay: The amount of seconds to delay if the `Pendable` is .pending before
/// returning the `pendingFallback`. If the `Pendable` is .finished, then this value is ignored.
///
/// Because of how ``Pendable`` currently works, you must provide a fallback option for when the Pendable is pending.
/// Alternatively, you can use the throwing version of `callAsFunction`, which will thorw an error instead of returning the fallback.
/// - parameter fallbackDelay: The amount of seconds to delay if the `Pendable` is pending before
/// returning its fallback value. If the `Pendable` is finished, then this value is ignored.
public func callAsFunction<Value>(
_ arguments: Arguments,
pendingDelay: TimeInterval = PendableDefaults.delay
fallbackDelay: TimeInterval = PendableDefaults.delay
) async -> Value where Returning == Pendable<Value> {
return await call(arguments).resolve(delay: pendingDelay)
return await call(arguments).call(fallbackDelay: fallbackDelay)
}

/// Records that a call was made and handles the result according to ``Pendable/resolve(delay:)-hvhg``.
///
/// - parameter pendingDelay: The amount of seconds to delay if the `Pendable` is .pending before
/// returning the `pendingFallback`. If the `Pendable` is .finished, then this value is ignored.
/// Records that a call was made and handles the result according to ``Pendable/call(fallbackDelay:)``.
///
/// Because of how ``Pendable`` currently works, you must provide a fallback option for when the Pendable is pending.
/// Alternatively, you can use the throwing version of `callAsFunction`, which will thorw an error instead of returning the fallback.
/// - parameter fallbackDelay: The amount of seconds to delay if the `Pendable` is pending before
/// returning its fallback value. If the `Pendable` is finished, then this value is ignored.
public func callAsFunction<Value>(
pendingDelay: TimeInterval = PendableDefaults.delay
fallbackDelay: TimeInterval = PendableDefaults.delay
) async -> Value where Arguments == Void, Returning == Pendable<Value> {
return await call(()).resolve(delay: pendingDelay)
return await call(()).call(fallbackDelay: fallbackDelay)
}
}
File renamed without changes.
Loading

0 comments on commit e962902

Please sign in to comment.