Skip to content

Commit 9f7f243

Browse files
authored
refactor(AsyncObject)!: propagate cancellation error instead of swallowing (#8)
* refactor(`AsyncObject`)!: propagate cancellation error instead of swallowing * wip: add delay to deinit tests * wip: add delay for tests where needed * wip: make future initializer synchrnous * refactor: remove logic duplication for continuation management * refactor: remove unnecessary class from test * refactor: refactor test helper functions * fix: manage cancelling operation before starting properly * deps: update swift-format with Swift 5.7 support * wip: refactor safe continuation * style: add logging parameters to public interfaces * style: add logging parameters to public interfaces * wip: remove `@preconcurrency` from `Foundation` for Swift >=5.7 * wip: standardize method names
1 parent bca8299 commit 9f7f243

32 files changed

+3066
-1824
lines changed

AsyncObjects.xcodeproj/project.pbxproj

+471-424
Large diffs are not rendered by default.

Package.swift

+5-5
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ let package = Package(
2121
dependencies: [
2222
.package(url: "\(appleGitHub)/swift-collections.git", from: "1.0.0"),
2323
.package(url: "\(appleGitHub)/swift-docc-plugin", from: "1.0.0"),
24-
.package(url: "\(appleGitHub)/swift-format", from: "0.50600.1"),
24+
.package(url: "\(appleGitHub)/swift-format", from: "0.50700.0"),
2525
],
2626
targets: [
2727
.target(
@@ -42,7 +42,7 @@ let package = Package(
4242
]
4343
)
4444

45-
var swiftSettings: [SwiftSetting] {
45+
var swiftSettings: [SwiftSetting] = {
4646
var swiftSettings: [SwiftSetting] = []
4747

4848
if ProcessInfo.processInfo.environment[
@@ -77,9 +77,9 @@ var swiftSettings: [SwiftSetting] {
7777
}
7878

7979
return swiftSettings
80-
}
80+
}()
8181

82-
var testingSwiftSettings: [SwiftSetting] {
82+
var testingSwiftSettings: [SwiftSetting] = {
8383
var swiftSettings: [SwiftSetting] = []
8484

8585
if ProcessInfo.processInfo.environment[
@@ -96,4 +96,4 @@ var testingSwiftSettings: [SwiftSetting] {
9696
}
9797

9898
return swiftSettings
99-
}
99+
}()

Sources/AsyncObjects/AsyncCountdownEvent.swift

+140-50
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Foundation
33
#else
44
@preconcurrency import Foundation
55
#endif
6+
67
import OrderedCollections
78

89
/// An event object that controls access to a resource between high and low priority tasks
@@ -15,20 +16,42 @@ import OrderedCollections
1516
/// You can indicate high priority usage of resource by using ``increment(by:)`` method,
1617
/// and indicate free of resource by calling ``signal(repeat:)`` or ``signal()`` methods.
1718
/// For low priority resource usage or detect resource idling use ``wait()`` method
18-
/// or its timeout variation ``wait(forNanoseconds:)``.
19+
/// or its timeout variation ``wait(forNanoseconds:)``:
20+
///
21+
/// ```swift
22+
/// // create event with initial count and count down limit
23+
/// let event = AsyncCountdownEvent()
24+
/// // increment countdown count from high priority tasks
25+
/// event.increment(by: 1)
26+
///
27+
/// // wait for countdown signal from low priority tasks,
28+
/// // fails only if task cancelled
29+
/// try await event.wait()
30+
/// // or wait with some timeout
31+
/// try await event.wait(forNanoseconds: 1_000_000_000)
32+
///
33+
/// // signal countdown after completing high priority tasks
34+
/// event.signal()
35+
/// ```
1936
///
2037
/// Use the ``limit`` parameter to indicate concurrent low priority usage, i.e. if limit set to zero,
2138
/// only one low priority usage allowed at one time.
22-
public actor AsyncCountdownEvent: AsyncObject {
39+
public actor AsyncCountdownEvent: AsyncObject, ContinuableCollection {
2340
/// The suspended tasks continuation type.
2441
@usableFromInline
25-
typealias Continuation = SafeContinuation<GlobalContinuation<Void, Error>>
42+
internal typealias Continuation = SafeContinuation<
43+
GlobalContinuation<Void, Error>
44+
>
2645
/// The platform dependent lock used to synchronize continuations tracking.
2746
@usableFromInline
28-
let locker: Locker = .init()
47+
internal let locker: Locker = .init()
2948
/// The continuations stored with an associated key for all the suspended task that are waiting to be resumed.
3049
@usableFromInline
31-
private(set) var continuations: OrderedDictionary<UUID, Continuation> = [:]
50+
internal private(set) var continuations:
51+
OrderedDictionary<
52+
UUID,
53+
Continuation
54+
> = [:]
3255
/// The limit up to which the countdown counts and triggers event.
3356
///
3457
/// By default this is set to zero and can be changed during initialization.
@@ -42,7 +65,7 @@ public actor AsyncCountdownEvent: AsyncObject {
4265
///
4366
/// Can be changed after initialization
4467
/// by using ``reset(to:)`` method.
45-
public private(set) var initialCount: UInt
68+
public var initialCount: UInt
4669
/// Indicates whether countdown event current count is within ``limit``.
4770
///
4871
/// Queued tasks are resumed from suspension when event is set and until current count exceeds limit.
@@ -54,13 +77,13 @@ public actor AsyncCountdownEvent: AsyncObject {
5477
///
5578
/// - Returns: Whether to wait to be resumed later.
5679
@inlinable
57-
func _wait() -> Bool { !isSet || !continuations.isEmpty }
80+
internal func shouldWait() -> Bool { !isSet || !continuations.isEmpty }
5881

5982
/// Resume provided continuation with additional changes based on the associated flags.
6083
///
6184
/// - Parameter continuation: The queued continuation to resume.
6285
@inlinable
63-
func _resumeContinuation(_ continuation: Continuation) {
86+
internal func resumeContinuation(_ continuation: Continuation) {
6487
currentCount += 1
6588
continuation.resume()
6689
}
@@ -71,12 +94,12 @@ public actor AsyncCountdownEvent: AsyncObject {
7194
/// - continuation: The `continuation` to add.
7295
/// - key: The key in the map.
7396
@inlinable
74-
func _addContinuation(
97+
internal func addContinuation(
7598
_ continuation: Continuation,
7699
withKey key: UUID
77100
) {
78101
guard !continuation.resumed else { return }
79-
guard _wait() else { _resumeContinuation(continuation); return }
102+
guard shouldWait() else { resumeContinuation(continuation); return }
80103
continuations[key] = continuation
81104
}
82105

@@ -85,49 +108,52 @@ public actor AsyncCountdownEvent: AsyncObject {
85108
///
86109
/// - Parameter key: The key in the map.
87110
@inlinable
88-
func _removeContinuation(withKey key: UUID) {
111+
internal func removeContinuation(withKey key: UUID) {
89112
continuations.removeValue(forKey: key)
90113
}
91114

92115
/// Decrements countdown count by the provided number.
93116
///
94117
/// - Parameter number: The number to decrement count by.
95118
@inlinable
96-
func _decrementCount(by number: UInt = 1) {
97-
defer { _resumeContinuations() }
119+
internal func decrementCount(by number: UInt = 1) {
120+
defer { resumeContinuations() }
98121
guard currentCount > 0 else { return }
99122
currentCount -= number
100123
}
101124

102125
/// Resume previously waiting continuations for countdown event.
103126
@inlinable
104-
func _resumeContinuations() {
127+
internal func resumeContinuations() {
105128
while !continuations.isEmpty && isSet {
106129
let (_, continuation) = continuations.removeFirst()
107-
_resumeContinuation(continuation)
130+
resumeContinuation(continuation)
108131
}
109132
}
110133

111-
/// Suspends the current task, then calls the given closure with a throwing continuation for the current task.
112-
/// Continuation can be cancelled with error if current task is cancelled, by invoking `_removeContinuation`.
134+
/// Increments the countdown event current count by the specified value.
113135
///
114-
/// Spins up a new continuation and requests to track it with key by invoking `_addContinuation`.
115-
/// This operation cooperatively checks for cancellation and reacting to it by invoking `_removeContinuation`.
116-
/// Continuation can be resumed with error and some cleanup code can be run here.
136+
/// - Parameter count: The value by which to increase ``currentCount``.
137+
@inlinable
138+
internal func incrementCount(by count: UInt = 1) {
139+
self.currentCount += count
140+
}
141+
142+
/// Resets current count to initial count.
143+
@inlinable
144+
internal func resetCount() {
145+
self.currentCount = initialCount
146+
resumeContinuations()
147+
}
148+
149+
/// Resets initial count and current count to specified value.
117150
///
118-
/// - Throws: If `resume(throwing:)` is called on the continuation, this function throws that error.
151+
/// - Parameter count: The new initial count.
119152
@inlinable
120-
nonisolated func _withPromisedContinuation() async throws {
121-
let key = UUID()
122-
try await Continuation.withCancellation(synchronizedWith: locker) {
123-
Task { [weak self] in
124-
await self?._removeContinuation(withKey: key)
125-
}
126-
} operation: { continuation in
127-
Task { [weak self] in
128-
await self?._addContinuation(continuation, withKey: key)
129-
}
130-
}
153+
internal func resetCount(to count: UInt) {
154+
initialCount = count
155+
self.currentCount = count
156+
resumeContinuations()
131157
}
132158

133159
// MARK: Public
@@ -158,47 +184,97 @@ public actor AsyncCountdownEvent: AsyncObject {
158184
/// Use this to indicate usage of resource from high priority tasks.
159185
///
160186
/// - Parameter count: The value by which to increase ``currentCount``.
161-
public func increment(by count: UInt = 1) {
162-
self.currentCount += count
187+
public nonisolated func increment(
188+
by count: UInt = 1,
189+
file: String = #fileID,
190+
function: String = #function,
191+
line: UInt = #line
192+
) {
193+
Task { await incrementCount(by: count) }
163194
}
164195

165196
/// Resets current count to initial count.
166197
///
167198
/// If the current count becomes less or equal to limit, multiple queued tasks
168199
/// are resumed from suspension until current count exceeds limit.
169-
public func reset() {
170-
self.currentCount = initialCount
171-
_resumeContinuations()
200+
///
201+
/// - Parameters:
202+
/// - file: The file reset originates from (there's usually no need to pass it
203+
/// explicitly as it defaults to `#fileID`).
204+
/// - function: The function reset originates from (there's usually no need to
205+
/// pass it explicitly as it defaults to `#function`).
206+
/// - line: The line reset originates from (there's usually no need to pass it
207+
/// explicitly as it defaults to `#line`).
208+
public nonisolated func reset(
209+
file: String = #fileID,
210+
function: String = #function,
211+
line: UInt = #line
212+
) {
213+
Task { await resetCount() }
172214
}
173215

174216
/// Resets initial count and current count to specified value.
175217
///
176218
/// If the current count becomes less or equal to limit, multiple queued tasks
177219
/// are resumed from suspension until current count exceeds limit.
178220
///
179-
/// - Parameter count: The new initial count.
180-
public func reset(to count: UInt) {
181-
initialCount = count
182-
self.currentCount = count
183-
_resumeContinuations()
221+
/// - Parameters:
222+
/// - count: The new initial count.
223+
/// - file: The file reset originates from (there's usually no need to pass it
224+
/// explicitly as it defaults to `#fileID`).
225+
/// - function: The function reset originates from (there's usually no need to
226+
/// pass it explicitly as it defaults to `#function`).
227+
/// - line: The line reset originates from (there's usually no need to pass it
228+
/// explicitly as it defaults to `#line`).
229+
public nonisolated func reset(
230+
to count: UInt,
231+
file: String = #fileID,
232+
function: String = #function,
233+
line: UInt = #line
234+
) {
235+
Task { await resetCount(to: count) }
184236
}
185237

186238
/// Registers a signal (decrements) with the countdown event.
187239
///
188240
/// Decrement the countdown. If the current count becomes less or equal to limit,
189241
/// one queued task is resumed from suspension.
190-
public func signal() {
191-
signal(repeat: 1)
242+
///
243+
/// - Parameters:
244+
/// - file: The file signal originates from (there's usually no need to pass it
245+
/// explicitly as it defaults to `#fileID`).
246+
/// - function: The function signal originates from (there's usually no need to
247+
/// pass it explicitly as it defaults to `#function`).
248+
/// - line: The line signal originates from (there's usually no need to pass it
249+
/// explicitly as it defaults to `#line`).
250+
public nonisolated func signal(
251+
file: String = #fileID,
252+
function: String = #function,
253+
line: UInt = #line
254+
) {
255+
Task { await decrementCount(by: 1) }
192256
}
193257

194258
/// Registers multiple signals (decrements by provided count) with the countdown event.
195259
///
196260
/// Decrement the countdown by the provided count. If the current count becomes less or equal to limit,
197261
/// multiple queued tasks are resumed from suspension until current count exceeds limit.
198262
///
199-
/// - Parameter count: The number of signals to register.
200-
public func signal(repeat count: UInt) {
201-
_decrementCount(by: count)
263+
/// - Parameters:
264+
/// - count: The number of signals to register.
265+
/// - file: The file signal originates from (there's usually no need to pass it
266+
/// explicitly as it defaults to `#fileID`).
267+
/// - function: The function signal originates from (there's usually no need to
268+
/// pass it explicitly as it defaults to `#function`).
269+
/// - line: The line signal originates from (there's usually no need to pass it
270+
/// explicitly as it defaults to `#line`).
271+
public nonisolated func signal(
272+
repeat count: UInt,
273+
file: String = #fileID,
274+
function: String = #function,
275+
line: UInt = #line
276+
) {
277+
Task { await decrementCount(by: count) }
202278
}
203279

204280
/// Waits for, or increments, a countdown event.
@@ -207,9 +283,23 @@ public actor AsyncCountdownEvent: AsyncObject {
207283
/// Otherwise, current task is suspended until either a signal occurs or event is reset.
208284
///
209285
/// Use this to wait for high priority tasks completion to start low priority ones.
286+
///
287+
/// - Parameters:
288+
/// - file: The file wait request originates from (there's usually no need to pass it
289+
/// explicitly as it defaults to `#fileID`).
290+
/// - function: The function wait request originates from (there's usually no need to
291+
/// pass it explicitly as it defaults to `#function`).
292+
/// - line: The line wait request originates from (there's usually no need to pass it
293+
/// explicitly as it defaults to `#line`).
294+
///
295+
/// - Throws: `CancellationError` if cancelled.
210296
@Sendable
211-
public func wait() async {
212-
guard _wait() else { currentCount += 1; return }
213-
try? await _withPromisedContinuation()
297+
public func wait(
298+
file: String = #fileID,
299+
function: String = #function,
300+
line: UInt = #line
301+
) async throws {
302+
guard shouldWait() else { currentCount += 1; return }
303+
try await withPromisedContinuation()
214304
}
215305
}

0 commit comments

Comments
 (0)