-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathProbingDispatcher.swift
More file actions
422 lines (404 loc) · 17.6 KB
/
Copy pathProbingDispatcher.swift
File metadata and controls
422 lines (404 loc) · 17.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
//
// ProbingDispatcher.swift
// Probing
//
// Created by Kamil Strzelecki on 05/02/2025.
// Copyright © 2025 Kamil Strzelecki. All rights reserved.
//
import Probing
import Testing
/// Object that controls execution of the `body` during a test.
///
/// You don't create instances of `ProbingDispatcher` directly.
/// Instead, an instance is provided to you within the `test` closure of ``withProbing(options:sourceLocation:isolation:of:dispatchedBy:)``.
///
/// `ProbingDispatcher` always performs the minimal necessary work to drive execution toward a desired state.
/// After calling one of its methods, it eagerly suspends the `body` and any effects it created by its invocation at two points:
/// - Explicitly, at declared `#probe()` macros within the `body` and nested effects
/// - Implicitly, immediately after initializing effects with `#Effect` macros, preventing them from starting until required
///
/// This ensures that no part of the tested code runs concurrently with the expectations defined in the `test`,
/// as long as your code uses `#Effect` macros instead of the `Task` APIs.
///
/// - SeeAlso: For details on how probe and effect identifiers are constructed, see the `Probing` documentation.
///
public struct ProbingDispatcher: ~Escapable, Sendable {
private let coordinator: ProbingCoordinator
init(coordinator: inout ProbingCoordinator) {
// @lifetime(immortal)
// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0446-non-escapable.md
// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0465-nonescapable-stdlib-primitives.md
self.coordinator = coordinator
}
}
extension ProbingDispatcher {
private func withIssueRecording(
at sourceLocation: SourceLocation,
isolation: isolated (any Actor)?,
perform dispatch: () async throws -> Void
) async throws {
do {
_ = isolation
try await dispatch()
} catch let error as any RecordableProbingError {
throw RecordedError(
underlying: error,
sourceLocation: sourceLocation
)
}
try Task.checkCancellation()
}
private func withIssueRecording<R>(
at sourceLocation: SourceLocation,
perform block: () throws -> R
) rethrows -> R {
do {
return try block()
} catch let error as any RecordableProbingError {
throw RecordedError(
underlying: error,
sourceLocation: sourceLocation
)
}
}
}
extension ProbingDispatcher {
/// Resumes execution of `body`, performing the minimal necessary work to install the specified probe, and suspends `body` again before returning.
///
/// - Parameter id: Identifier of the probe, which is guaranteed to be installed when this function returns.
///
/// If any effect along the `id.effect.path` has not yet been created, this function resumes its closest ancestor until the required effect is initialized,
/// suspending that ancestor at the next available probe. Once the parent effect (`id.effect.path.last`) is created , it is resumed and suspended at the first probe matching `id.name`.
///
/// - Throws: If the probe is unreachable, fails to install, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions.
///
/// ```swift
/// try await withProbing {
/// await #probe("1") // id: "1"
/// print("1")
/// #Effect("first") { // <- SUSPENDED
/// print("Not called until dispatch is given.")
/// }
/// #Effect("second") {
/// await #probe("1") // id: "second.1"
/// print("second.1")
/// await #probe("2") // id: "second.2" <- SUSPENDED
/// print("Not called until dispatch is given.")
/// }
/// await #probe("2") // id: "2" <- SUSPENDED
/// print("Not called until dispatch is given.")
/// } dispatchedBy: { dispatcher in
/// try await dispatcher.runUpToProbe("second.2")
/// // Always prints:
/// // 1
/// // second.1
/// }
/// ```
///
/// - Tip: Conceptually, this algorithm resembles [breadth-first search](https://en.wikipedia.org/wiki/Breadth-first_search),
/// where effects form the nodes and probes are the leaves of the execution tree.
///
public func runUpToProbe(
_ id: ProbeIdentifier,
sourceLocation: SourceLocation = #_sourceLocation,
isolation: isolated (any Actor)? = #isolation
) async throws {
try await withIssueRecording(
at: sourceLocation,
isolation: isolation,
perform: {
try await coordinator.runUntilProbeInstalled(
withID: id,
isolation: isolation
)
}
)
}
/// Resumes execution of `body`, performing the minimal necessary work to install a default probe in the specified effect, and suspends `body` again before returning.
///
/// - Parameter effectID: Identifier of the effect in which a default probe is guaranteed to be installed when this function returns.
///
/// - Attention: A **default probe** is a probe created via `#probe()` macro without specifying a name, or via `#probe(.default)`.
///
/// - Note: This function is equivalent to calling ``runUpToProbe(_:sourceLocation:isolation:)`` with `ProbeIdentifier(effect: effectID, name: .default)`
///
/// If any effect along the `effectID.path` has not yet been created, this function resumes its closest ancestor until the required effect is initialized,
/// suspending that ancestor at the next available probe. Once the parent effect (`effectID.path.last`) is created , it is resumed and suspended at the first probe with `.default` name.
///
/// - Throws: If the probe is unreachable, fails to install, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions.
///
/// ```swift
/// try await withProbing {
/// await #probe() // id: "probe"
/// print("probe")
/// #Effect("first") { // <- SUSPENDED
/// print("Not called until dispatch is given.")
/// }
/// #Effect("second") {
/// await #probe("1") // id: "second.1"
/// print("second.1")
/// await #probe() // id: "second.probe" <- SUSPENDED
/// print("Not called until dispatch is given.")
/// }
/// await #probe() // id: "probe" <- SUSPENDED
/// print("Not called until dispatch is given.")
/// } dispatchedBy: { dispatcher in
/// try await dispatcher.runUpToProbe(inEffect: "second")
/// // Always prints:
/// // probe
/// // second.1
/// }
/// ```
///
/// - Tip: Conceptually, this algorithm resembles [breadth-first search](https://en.wikipedia.org/wiki/Breadth-first_search),
/// where effects form the nodes and probes are the leaves of the execution tree.
///
public func runUpToProbe(
inEffect effectID: EffectIdentifier,
sourceLocation: SourceLocation = #_sourceLocation,
isolation: isolated (any Actor)? = #isolation
) async throws {
try await runUpToProbe(
.init(effect: effectID, name: .default),
sourceLocation: sourceLocation,
isolation: isolation
)
}
/// Resumes execution of `body`, performing the minimal necessary work to install a default probe that is not nested in any effect, and suspends `body` again before returning.
///
/// - Attention: A **default probe** is a probe created via `#probe()` macro without specifying a name, or via `#probe(.default)`.
///
/// - Note: This function is equivalent to calling ``runUpToProbe(_:sourceLocation:isolation:)`` with `ProbeIdentifier(effect: .root, name: .default)`
///
/// - Throws: If the probe is unreachable, fails to install, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions.
///
/// ```swift
/// try await withProbing {
/// await #probe("1") // id: "1"
/// print("1")
/// #Effect("first") { // <- SUSPENDED
/// print("Not called until dispatch is given.")
/// }
/// #Effect("second") { // <- SUSPENDED
/// await #probe() // id: "second.probe"
/// print("Not called until dispatch is given.")
/// await #probe() // id: "second.probe"
/// print("Not called until dispatch is given.")
/// }
/// await #probe() // id: "probe" <- SUSPENDED
/// print("Not called until dispatch is given.")
/// } dispatchedBy: { dispatcher in
/// try await dispatcher.runUpToProbe()
/// // Always prints:
/// // 1
/// }
/// ```
///
public func runUpToProbe(
sourceLocation: SourceLocation = #_sourceLocation,
isolation: isolated (any Actor)? = #isolation
) async throws {
try await runUpToProbe(
inEffect: .root,
sourceLocation: sourceLocation,
isolation: isolation
)
}
}
extension ProbingDispatcher {
/// Resumes execution of `body`, performing the minimal necessary work to complete the specified effect, and suspends `body` again before returning.
///
/// - Parameters:
/// - id: Identifier of the effect, which is guaranteed to be completed when this function returns.
/// - includeDescendants: If `true`, all descendants of the specified effect will also be completed.
/// Defaults to `false`, meaning they will remain suspended in their current state.
///
/// If any effect along the `id.path` has not yet been created, this function resumes its closest ancestor until the required effect is initialized,
/// suspending that ancestor at the next available probe. Once the specified effect (`id.path.last`) is created , it is resumed and run until completion.
///
/// - Throws: If the effect is unreachable, fails to be created, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions.
///
/// ```swift
/// try await withProbing {
/// await #probe("1") // id: "1"
/// print("1")
/// #Effect("first") { // <- SUSPENDED
/// print("Not called until dispatch is given.")
/// }
/// #Effect("second") {
/// await #probe("1") // id: "second.1"
/// print("second.1")
/// await #probe("2") // id: "second.2"
/// print("second.2") // <- COMPLETED
/// }
/// await #probe("2") // id: "2" <- SUSPENDED
/// print("Not called until dispatch is given.")
/// } dispatchedBy: { dispatcher in
/// try await dispatcher.runUntilEffectCompleted("second.2")
/// // Always prints:
/// // 1
/// // second.1
/// // second.2
/// }
/// ```
///
/// - Tip: Conceptually, this algorithm resembles [breadth-first search](https://en.wikipedia.org/wiki/Breadth-first_search),
/// where effects form the nodes of the execution tree.
///
public func runUntilEffectCompleted(
_ id: EffectIdentifier,
includingDescendants includeDescendants: Bool = false,
sourceLocation: SourceLocation = #_sourceLocation,
isolation: isolated (any Actor)? = #isolation
) async throws {
try await withIssueRecording(
at: sourceLocation,
isolation: isolation,
perform: {
try await coordinator.runUntilEffectCompleted(
withID: id,
includingDescendants: includeDescendants,
isolation: isolation
)
}
)
}
/// Resumes execution of `body`, completing all remaining work within it, as well as all effects and their descendants, before returning.
///
/// - Note: This function is equivalent to calling ``runUntilEffectCompleted(_:includingDescendants:sourceLocation:isolation:)``
/// with `EffectIdentifier.root` and `includeDescendants` set to `true`.
///
/// - Throws: If API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions.
///
/// ```swift
/// try await withProbing {
/// await #probe("1") // id: "1"
/// print("1")
/// #Effect("first") {
/// print("first") // <- COMPLETED
/// }
/// #Effect("second") {
/// await #probe("1") // id: "second.1"
/// print("second.1")
/// await #probe("2") // id: "second.2"
/// print("second.2") // <- COMPLETED
/// }
/// await #probe("2") // id: "2"
/// print("2") // <- COMPLETED
/// } dispatchedBy: { dispatcher in
/// try await dispatcher.runUntilEverythingCompleted()
/// // Always prints:
/// // 1
/// // 2
/// // first
/// // second.1
/// // second.2
/// // Note: Exact order of prints may vary, as effects may execute concurrently.
/// }
/// ```
///
public func runUntilEverythingCompleted(
sourceLocation: SourceLocation = #_sourceLocation,
isolation: isolated (any Actor)? = #isolation
) async throws {
try await runUntilEffectCompleted(
.root,
includingDescendants: true,
sourceLocation: sourceLocation,
isolation: isolation
)
}
/// Resumes execution of `body`, completing all remaining work within it, while leaving effects suspended in their current state, before returning.
///
/// - Note: This function is equivalent to calling ``runUntilEffectCompleted(_:includingDescendants:sourceLocation:isolation:)``
/// with `EffectIdentifier.root` and `includeDescendants` set to `false`.
///
/// - Throws: If API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions.
///
/// ```swift
/// try await withProbing {
/// await #probe("1") // id: "1"
/// print("1")
/// #Effect("first") { // <- SUSPENDED
/// print("Not called until dispatch is given.")
/// }
/// #Effect("second") { // <- SUSPENDED
/// await #probe("1") // id: "second.1"
/// print("Not called until dispatch is given.")
/// await #probe("2") // id: "second.2"
/// print("Not called until dispatch is given.")
/// }
/// await #probe("2") // id: "2"
/// print("2") // <- COMPLETED
/// } dispatchedBy: { dispatcher in
/// try await dispatcher.runUntilExitOfBody()
/// // Always prints:
/// // 1
/// // 2
/// }
/// ```
///
public func runUntilExitOfBody(
sourceLocation: SourceLocation = #_sourceLocation,
isolation: isolated (any Actor)? = #isolation
) async throws {
try await runUntilEffectCompleted(
.root,
includingDescendants: false,
sourceLocation: sourceLocation,
isolation: isolation
)
}
}
extension ProbingDispatcher {
/// Retrieves the return value of the specified effect, ensuring it has completed successfully.
///
/// - Parameters:
/// - id: Identifier of the effect, which is expected to have completed successfully.
/// - successType: The expected type of the value returned by the effect.
///
/// - Returns: The return value of the effect, if it completed successfully.
///
/// - Important: This method does not resume the execution of `body`. You must ensure the effect has finished running through prior dispatches.
///
/// - Throws: If the effect has not been completed successfully yet, was cancelled, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions.
///
public func getValue<Success: Sendable>(
fromEffect id: EffectIdentifier,
as successType: Success.Type = Success.self,
sourceLocation: SourceLocation = #_sourceLocation
) throws -> Success {
precondition(
id != .root,
"To get value from the root effect use result from withProbing function."
)
return try withIssueRecording(at: sourceLocation) {
try coordinator.getValue(fromEffectWithID: id, as: successType)
}
}
/// Retrieves the return value of the specified effect, ensuring it has been cancelled.
///
/// - Parameters:
/// - id: Identifier of the effect, which is expected to have been cancelled.
/// - successType: The expected type of the value returned by the effect.
///
/// - Returns: The return value of the effect, if it was cancelled.
///
/// - Important: This method does not resume the execution of `body`. You must ensure the effect has finished running through prior dispatches.
///
/// - Throws: If the effect has not been cancelled yet, was completed successfully, or if API misuse is detected, an `Issue` is recorded containing the error and possible recovery suggestions.
///
public func getCancelledValue<Success: Sendable>(
fromEffect id: EffectIdentifier,
as successType: Success.Type = Success.self,
sourceLocation: SourceLocation = #_sourceLocation
) throws -> Success {
precondition(
id != .root,
"Root effect does not support cancellation."
)
return try withIssueRecording(at: sourceLocation) {
try coordinator.getCancelledValue(fromEffectWithID: id, as: successType)
}
}
}