Skip to content

Commit 3f29750

Browse files
committed
Implement AsyncTrigger
`AsyncTrigger` is intended for suspending asynchronous tasks that should proceed only after a one-time resuming event.
1 parent 2dc8a78 commit 3f29750

File tree

3 files changed

+144
-0
lines changed

3 files changed

+144
-0
lines changed

Package.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ let package = Package(
2121
.target(
2222
name: "AsyncMaterializedSequence"
2323
),
24+
.target(
25+
name: "AsyncTrigger"
26+
),
2427
.target(
2528
name: "TestingSupport",
2629
dependencies: [
@@ -40,6 +43,13 @@ let package = Package(
4043
"TestingSupport",
4144
]
4245
),
46+
.testTarget(
47+
name: "AsyncTriggerTests",
48+
dependencies: [
49+
"AsyncTrigger",
50+
"TestingSupport",
51+
]
52+
),
4353
.testTarget(
4454
name: "TestingSupportTests",
4555
dependencies: [
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import AsyncAlgorithms
2+
3+
/// `AsyncTrigger` is intended for suspending asynchronous tasks that should proceed only after a one-time resuming event.
4+
///
5+
/// ### Usage
6+
///
7+
/// ```swift
8+
/// let trigger = AsyncTrigger()
9+
///
10+
/// // These tasks will suspend until the trigger is fired.
11+
/// let task1 = Task {
12+
/// await trigger()
13+
/// }
14+
/// let task2 = Task {
15+
/// await trigger()
16+
/// }
17+
///
18+
/// trigger.fire() // Resumes all awaiting tasks
19+
///
20+
/// let result1 = await task1.value // .triggered
21+
/// let result2 = await task2.value // .triggered
22+
/// ```
23+
///
24+
/// If a task is cancelled before the trigger fires, it returns `.cancelled`:
25+
/// ```swift
26+
/// let trigger = AsyncTrigger()
27+
/// let task = Task {
28+
/// await trigger()
29+
/// }
30+
/// task.cancel()
31+
/// let result = await task.value // .cancelled
32+
/// ```
33+
///
34+
/// `AsyncTrigger` can also be used as an `AsyncSequence`:
35+
/// ```swift
36+
/// let trigger = AsyncTrigger()
37+
/// async let result = trigger.reduce(into: [], { $0.append($1) })
38+
/// trigger.fire()
39+
/// let output = await result // [.triggered]
40+
/// ```
41+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
42+
public struct AsyncTrigger: Sendable {
43+
44+
public enum Result: Sendable, Equatable {
45+
case triggered
46+
case cancelled
47+
}
48+
49+
private let channel = AsyncChannel<Never>()
50+
51+
public init() {}
52+
53+
/// Immediately resumes all the suspended operations.
54+
public func fire() {
55+
channel.finish()
56+
}
57+
58+
@discardableResult
59+
public func callAsFunction() async -> Result {
60+
var iterator = makeAsyncIterator()
61+
// Although next() can return nil, AsyncTrigger.Iterator guarantees that the first value cannot be nil.
62+
return await iterator.next()!
63+
}
64+
}
65+
66+
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
67+
extension AsyncTrigger: AsyncSequence {
68+
69+
public struct Iterator: AsyncIteratorProtocol {
70+
71+
var base: AsyncChannel<Never>.AsyncIterator
72+
var hasFired: Bool = false
73+
74+
public mutating func next() async -> AsyncTrigger.Result? {
75+
guard !hasFired else { return nil }
76+
77+
_ = await base.next()
78+
hasFired = true
79+
return Task.isCancelled ? .cancelled : .triggered
80+
}
81+
}
82+
83+
public func makeAsyncIterator() -> Iterator {
84+
Iterator(base: channel.makeAsyncIterator())
85+
}
86+
}
87+
88+
@available(*, unavailable)
89+
extension AsyncTrigger.Iterator: Sendable {}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import AsyncTrigger
2+
import Testing
3+
import TestingSupport
4+
5+
struct AsyncTriggerTests {
6+
7+
@Test func trigger_resumes_consumers_when_fire_is_called() async throws {
8+
let trigger = AsyncTrigger()
9+
10+
async let work1 = trigger()
11+
async let work2 = trigger()
12+
13+
trigger.fire()
14+
15+
await #expect((work1, work2) == (.triggered, .triggered))
16+
}
17+
18+
@Test func trigger_resumes_consumers_immediately_given_trigger_is_fired() async throws {
19+
let trigger = AsyncTrigger()
20+
trigger.fire()
21+
22+
async let work1 = trigger()
23+
async let work2 = trigger()
24+
25+
await #expect((work1, work2) == (.triggered, .triggered))
26+
}
27+
28+
@Test func trigger_consumer_resumes_when_task_is_cancelled() async throws {
29+
let trigger = AsyncTrigger()
30+
let work = Task(operation: trigger.callAsFunction)
31+
32+
work.cancel()
33+
34+
await #expect(work.value == .cancelled)
35+
}
36+
37+
@Test func trigger_is_async_sequence() async throws {
38+
let trigger = AsyncTrigger()
39+
async let work = trigger.reduce(into: [], { $0.append($1) })
40+
41+
trigger.fire()
42+
43+
await #expect(work == [.triggered])
44+
}
45+
}

0 commit comments

Comments
 (0)