Skip to content

Commit e962902

Browse files
authored
Merge pull request #15 from Quick/dynamic-pendable
Make Pendable resolvable after being called.
2 parents a7fac6f + d6ba208 commit e962902

File tree

10 files changed

+438
-134
lines changed

10 files changed

+438
-134
lines changed

Sources/Fakes/Pendable.swift

Lines changed: 0 additions & 91 deletions
This file was deleted.
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import Foundation
2+
3+
protocol ResolvableWithFallback {
4+
func resolveWithFallback()
5+
}
6+
7+
/// Pendable is a safe way to represent the 2 states that an asynchronous call can be in
8+
///
9+
/// - `pending`, the state while waiting for the call to finish.
10+
/// - `finished`, the state once the call has finished.
11+
///
12+
/// Pendable allows you to finish a pending call after it's been made. This makes Pendable behave very
13+
/// similarly to something like Combine's `Future`.
14+
///
15+
/// - Note: The reason you must provide a fallback value is to prevent deadlock when used in test.
16+
/// Unlike something like Combine's `Future`, it is very often the case that you will write
17+
/// tests which end while the call is in the pending state. If you do this too much, then your
18+
/// entire test suite will deadlock, as Swift Concurrency works under the assumption that
19+
/// blocked tasks of work will always eventually be unblocked. To help prevent this, pending calls
20+
/// are always resolved with the fallback after a given delay. You can also manually force this
21+
/// by calling the ``Pendable\resolveWithFallback()`` method.
22+
public final class Pendable<Value: Sendable>: @unchecked Sendable, ResolvableWithFallback {
23+
private enum State: Sendable {
24+
case pending
25+
case finished(Value)
26+
}
27+
28+
private let lock = NSRecursiveLock()
29+
private var state = State.pending
30+
31+
private var inProgressCalls = [UnsafeContinuation<Value, Never>]()
32+
33+
private let fallbackValue: Value
34+
35+
private var currentValue: Value {
36+
switch state {
37+
case .pending:
38+
return fallbackValue
39+
case .finished(let value):
40+
return value
41+
}
42+
}
43+
44+
deinit {
45+
resolveWithFallback()
46+
}
47+
48+
/// Initializes a new `Pendable`, in a pending state, with the given fallback value.
49+
public init(fallbackValue: Value) {
50+
self.fallbackValue = fallbackValue
51+
}
52+
53+
/// Gets the value for the `Pendable`, possibly waiting until it's resolved.
54+
///
55+
/// - parameter fallbackDelay: The amount of time (in seconds) to wait until the call returns
56+
/// the fallback value. This is only used when the `Pendable` is in a pending state.
57+
public func call(fallbackDelay: TimeInterval = PendableDefaults.delay) async -> Value {
58+
return await withTaskGroup(of: Value.self) { taskGroup in
59+
taskGroup.addTask { await self.handleCall() }
60+
taskGroup.addTask { await self.resolveAfterDelay(fallbackDelay) }
61+
62+
guard let value = await taskGroup.next() else {
63+
fatalError("There were no tasks in the task group. This should not ever happen.")
64+
}
65+
taskGroup.cancelAll()
66+
return value
67+
68+
}
69+
}
70+
71+
/// Resolves the `Pendable` with the fallback value.
72+
///
73+
/// - Note: This no-ops if the pendable is already in a resolved state.
74+
/// - Note: This is called for when you re-stub a `Spy` in ``Spy/stub(_:)``
75+
public func resolveWithFallback() {
76+
lock.lock()
77+
defer { lock.unlock() }
78+
79+
if case .pending = state {
80+
resolve(with: fallbackValue)
81+
}
82+
}
83+
84+
/// Resolves the `Pendable` with the given value.
85+
///
86+
/// Even if the pendable is already resolves, this resets the resolved value to the given value.
87+
public func resolve(with value: Value) {
88+
lock.lock()
89+
defer { lock.unlock() }
90+
state = .finished(value)
91+
inProgressCalls.forEach {
92+
$0.resume(returning: value)
93+
}
94+
inProgressCalls = []
95+
96+
}
97+
98+
/// Resolves any outstanding calls to the `Pendable` with the current value,
99+
/// and resets it back into the pending state.
100+
public func reset() {
101+
lock.lock()
102+
defer { lock.unlock() }
103+
104+
inProgressCalls.forEach {
105+
$0.resume(returning: currentValue)
106+
}
107+
inProgressCalls = []
108+
state = .pending
109+
}
110+
111+
// MARK: - Private
112+
private func handleCall() async -> Value {
113+
return await withUnsafeContinuation { continuation in
114+
lock.lock()
115+
defer { lock.unlock() }
116+
switch state {
117+
case .pending:
118+
inProgressCalls.append(continuation)
119+
case .finished(let value):
120+
continuation.resume(returning: value)
121+
}
122+
}
123+
}
124+
125+
private func resolveAfterDelay(_ delay: TimeInterval) async -> Value {
126+
do {
127+
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
128+
} catch {}
129+
resolveWithFallback()
130+
return fallbackValue
131+
}
132+
}
133+
134+
public typealias ThrowingDynamicPendable<Success, Failure: Error> = Pendable<Result<Success, Failure>>
135+
136+
extension Pendable {
137+
/// Gets or throws value for the `Pendable`, possibly waiting until it's resolved.
138+
///
139+
/// - parameter resolveDelay: The amount of time (in seconds) to wait until the call returns
140+
/// the fallback value. This is only used when the `Pendable` is in a pending state.
141+
public func call<Success, Failure: Error>(
142+
resolveDelay: TimeInterval = PendableDefaults.delay
143+
) async throws -> Success where Value == Result<Success, Failure> {
144+
try await call(fallbackDelay: resolveDelay).get()
145+
}
146+
}
147+
148+
extension Pendable {
149+
/// Creates a new finished `Pendable` pre-resolved with the given value.
150+
public static func finished(_ value: Value) -> Pendable<Value> {
151+
let pendable = Pendable(fallbackValue: value)
152+
pendable.resolve(with: value)
153+
return pendable
154+
}
155+
156+
/// Creates a new finished `Pendable` pre-resolved with Void.
157+
public static func finished() -> Pendable where Value == Void {
158+
return Pendable.finished(())
159+
}
160+
}
161+
162+
extension Pendable {
163+
/// Creates a new pending `Pendable` with the given fallback value.
164+
public static func pending(fallback: Value) -> Pendable<Value> {
165+
return Pendable(fallbackValue: fallback)
166+
}
167+
168+
/// Creates a new pending `Pendable` with a fallback value of Void.
169+
public static func pending() -> Pendable<Value> where Value == Void {
170+
return Pendable(fallbackValue: ())
171+
}
172+
173+
/// Creates a new pending `Pendable` with a fallback value of nil.
174+
public static func pending<Wrapped>() -> Pendable<Value> where Value == Optional<Wrapped> {
175+
// swiftlint:disable:previous syntactic_sugar
176+
return Pendable(fallbackValue: nil)
177+
}
178+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import Foundation
2+
3+
/// Default values for use with Pendable.
4+
public final class PendableDefaults: @unchecked Sendable {
5+
public static let shared = PendableDefaults()
6+
private let lock = NSLock()
7+
8+
public init() {}
9+
10+
/// The amount of time to delay before resolving a pending Pendable with the fallback value.
11+
/// By default this is 2 seconds. Conveniently, just long enough to be twice Nimble's default polling timeout.
12+
/// In general, you should keep this set to some number greater than Nimble's default polling timeout,
13+
/// in order to allow polling matchers to work correctly.
14+
public static var delay: TimeInterval {
15+
get {
16+
PendableDefaults.shared.delay
17+
}
18+
set {
19+
PendableDefaults.shared.delay = newValue
20+
}
21+
}
22+
23+
private var _delay: TimeInterval = 2
24+
public var delay: TimeInterval {
25+
get {
26+
lock.lock()
27+
defer { lock.unlock() }
28+
return _delay
29+
}
30+
set {
31+
lock.lock()
32+
_delay = newValue
33+
lock.unlock()
34+
}
35+
}
36+
}
File renamed without changes.

Sources/Fakes/Spy+StaticPendable.swift renamed to Sources/Fakes/Spy/Spy+Pendable.swift

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ extension Spy {
1919
}
2020
}
2121

22+
extension Spy {
23+
/// Resolve the pendable Spy's stub with Void
24+
public func resolveStub() where Returning == Pendable<Void> {
25+
self.resolveStub(with: ())
26+
}
27+
}
28+
2229
extension Spy {
2330
/// Update the pendable Spy's stub to be in a pending state.
2431
public func stub<Value>(pendingFallback: Value) where Returning == Pendable<Value> {
@@ -30,6 +37,12 @@ extension Spy {
3037
self.stub(.pending(fallback: ()))
3138
}
3239

40+
/// Update the pendable Spy's stub to be in a pending state.
41+
public func stubPending<Wrapped>() where Returning == Pendable<Optional<Wrapped>> {
42+
// swiftlint:disable:previous syntactic_sugar
43+
self.stub(.pending(fallback: nil))
44+
}
45+
3346
/// Update the pendable Spy's stub to return the given value.
3447
///
3548
/// - parameter finished: The value to return when `callAsFunction` is called.
@@ -44,31 +57,25 @@ extension Spy {
4457
}
4558

4659
extension Spy {
47-
/// Records the arguments and handles the result according to ``Pendable/resolve(delay:)-hvhg``.
60+
/// Records the arguments and handles the result according to ``Pendable/call(fallbackDelay:)``.
4861
///
4962
/// - parameter arguments: The arguments to record.
50-
/// - parameter pendingDelay: The amount of seconds to delay if the `Pendable` is .pending before
51-
/// returning the `pendingFallback`. If the `Pendable` is .finished, then this value is ignored.
52-
///
53-
/// Because of how ``Pendable`` currently works, you must provide a fallback option for when the Pendable is pending.
54-
/// Alternatively, you can use the throwing version of `callAsFunction`, which will thorw an error instead of returning the fallback.
63+
/// - parameter fallbackDelay: The amount of seconds to delay if the `Pendable` is pending before
64+
/// returning its fallback value. If the `Pendable` is finished, then this value is ignored.
5565
public func callAsFunction<Value>(
5666
_ arguments: Arguments,
57-
pendingDelay: TimeInterval = PendableDefaults.delay
67+
fallbackDelay: TimeInterval = PendableDefaults.delay
5868
) async -> Value where Returning == Pendable<Value> {
59-
return await call(arguments).resolve(delay: pendingDelay)
69+
return await call(arguments).call(fallbackDelay: fallbackDelay)
6070
}
6171

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

0 commit comments

Comments
 (0)