Skip to content

Commit 6c62d03

Browse files
committed
fix: pre-release corrections across SDK, tests, and docs
- Replace AsyncCancellable protocol with concrete AnyCancellable wrapper - Add AsyncGuard namespace for process-wide configuration - Fix store(in:) strong retention to prevent silent cancel no-ops - Replace Task<Any, Error> force cast with type-preserving Flight<T> box - Add StrictConcurrency swiftSettings to Package.swift targets - Fix CI workflow trigger branch from main to master - Replace placeholder your-org URLs with ANSCoder in README - Replace captured var bool flags with BoolFlag actor (Swift 6 compliance) - Remove duplicate NSLock.withLock from TestHelpers - Increase cancellation wait times for reliable CI - Update DESIGN.md to reflect current API surface - Update .gitignore
1 parent cb97d40 commit 6c62d03

File tree

12 files changed

+205
-103
lines changed

12 files changed

+205
-103
lines changed

.gitignore

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,15 @@
55
.build/
66
.swiftpm/
77

8-
# Xcode user data
8+
# Xcode
9+
DerivedData/
10+
*.xcworkspace
911
xcuserdata/
1012
*.xcuserstate
11-
*.xcscmblueprint
12-
13-
# Xcode workspace
14-
*.xcworkspace
15-
16-
# Derived data
17-
DerivedData/
1813

19-
# Fastlane (if ever used)
20-
fastlane/report.xml
21-
fastlane/Preview.html
22-
fastlane/screenshots/
23-
fastlane/test_output/
14+
# Xcode Project user data
15+
**/xcuserdata/
16+
**/*.xcuserstate
2417

25-
# Logs
26-
*.log
18+
# App icon cache
19+
Assets.xcassets/AppIcon.appiconset/*.png

Documentation/DESIGN.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
# AsyncGuardKit – Structured Concurrency Coordination Toolkit
2-
3-
## Authors
4-
5-
Anand (AsyncGuardKit)
1+
# AsyncGuardKit – Design Document
62

73
## Status
4+
Implemented — v1.0.0
85

9-
Design Proposal (Swift Package)
6+
## Authors
7+
Anand (ANSCoder)
108

119
---
1210

@@ -94,7 +92,7 @@ AsyncGuardKit does not:
9492
### Task Lifetime Binding
9593

9694
```swift
97-
public final class AsyncTask: AsyncCancellable
95+
public final class AsyncTask
9896
public final class AsyncLifetime
9997
````
10098

Package.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
// swift-tools-version: 5.10
1+
// swift-tools-version: 5.9
22
import PackageDescription
33

44
let package = Package(
55
name: "AsyncGuardKit",
66
platforms: [
77
.iOS(.v16),
8-
.macOS(.v13)
8+
.macOS(.v13),
9+
.tvOS(.v16),
10+
.watchOS(.v9)
911
],
1012
products: [
1113
.library(
@@ -16,12 +18,18 @@ let package = Package(
1618
targets: [
1719
.target(
1820
name: "AsyncGuardKit",
19-
path: "Sources/AsyncGuardKit"
21+
path: "Sources/AsyncGuardKit",
22+
swiftSettings: [
23+
.enableExperimentalFeature("StrictConcurrency")
24+
]
2025
),
2126
.testTarget(
2227
name: "AsyncGuardKitTests",
2328
dependencies: ["AsyncGuardKit"],
24-
path: "Tests/AsyncGuardKitTests"
29+
path: "Tests/AsyncGuardKitTests",
30+
swiftSettings: [
31+
.enableExperimentalFeature("StrictConcurrency")
32+
]
2533
)
2634
]
2735
)

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
[![iOS 16+](https://img.shields.io/badge/iOS-16+-000000?style=flat&logo=apple&logoColor=white)](https://developer.apple.com/ios/)
1111
[![macOS 13+](https://img.shields.io/badge/macOS-13+-000000?style=flat&logo=apple&logoColor=white)](https://developer.apple.com/macos/)
1212
[![MIT License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE)
13-
[![CI](https://img.shields.io/github/actions/workflow/status/your-org/AsyncGuardKit/ci.yml?style=flat&label=CI)](https://github.com/your-org/AsyncGuardKit/actions)
13+
[![CI](https://img.shields.io/github/actions/workflow/status/ANSCoder/AsyncGuardKit/ci.yml?branch=master&style=flat&label=CI)](https://github.com/ANSCoder/AsyncGuardKit/actions)
1414

1515
[Installation](#installation) · [The Problem](#the-problem) · [API](#api) · [Examples](#real-world-patterns) · [Architecture](#architecture) · [Design](#design-principles) · [Contributing](#contributing)
1616

@@ -143,7 +143,7 @@ Add to your `Package.swift`:
143143

144144
```swift
145145
dependencies: [
146-
.package(url: "https://github.com/your-org/AsyncGuardKit", from: "1.0.0")
146+
.package(url: "https://github.com/ANSCoder/AsyncGuardKit", from: "1.0.0")
147147
]
148148
```
149149

Sources/AsyncGuardKit/Core/AsyncCancellable.swift

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,32 +18,46 @@ import Foundation
1818
/// AsyncTask { await loadData() }
1919
/// .store(in: &cancellables)
2020
///
21-
/// // Cancel everything at once
2221
/// cancellables.cancelAll()
2322
/// ```
2423
///
25-
/// ## Identity
24+
/// ## Ownership
25+
///
26+
/// `AnyCancellable` **strongly retains** the wrapped `AsyncTask`. This ensures
27+
/// the task is not deallocated between `.store(in:)` and `cancelAll()`, which
28+
/// would silently prevent cancellation from being delivered.
2629
///
27-
/// Two `AnyCancellable` instances are equal if and only if they wrap the
28-
/// same underlying object. This is determined by object identity (`===`),
29-
/// not by value equality.
30+
/// ## Identity
3031
///
31-
/// - Note: `AnyCancellable` is `Hashable` via `ObjectIdentifier`, making
32-
/// it safe to store in `Set` and use as a `Dictionary` key.
32+
/// Two `AnyCancellable` instances are equal if and only if they wrap the same
33+
/// underlying object, determined by object identity (`ObjectIdentifier`).
3334
public final class AnyCancellable: Hashable, @unchecked Sendable {
3435

36+
// MARK: - Storage
37+
3538
private let _cancel: () -> Void
3639
private let _id: ObjectIdentifier
40+
/// Strong reference — keeps the wrapped AsyncTask alive for the
41+
/// full lifetime of this AnyCancellable. Without this, a task
42+
/// stored via .store(in:) with no other owner would deallocate
43+
/// immediately, making cancel() a no-op.
44+
private let _retained: AnyObject
45+
46+
// MARK: - Init
3747

3848
/// Creates a type-erased cancellable wrapping the given object.
3949
///
40-
/// - Parameter cancellable: Any object that conforms to the internal
41-
/// cancellation contract. Typically an ``AsyncTask``.
50+
/// - Parameters:
51+
/// - cancellable: The object to retain and identify this cancellable by.
52+
/// - cancel: The closure to invoke when ``cancel()`` is called.
4253
internal init<C: AnyObject>(_ cancellable: C, cancel: @escaping () -> Void) {
4354
self._cancel = cancel
4455
self._id = ObjectIdentifier(cancellable)
56+
self._retained = cancellable
4557
}
4658

59+
// MARK: - Public API
60+
4761
/// Cancels the underlying task.
4862
///
4963
/// Cancellation is cooperative. The underlying work must check
@@ -52,7 +66,7 @@ public final class AnyCancellable: Hashable, @unchecked Sendable {
5266
_cancel()
5367
}
5468

55-
// MARK: Hashable
69+
// MARK: - Hashable
5670

5771
public static func == (lhs: AnyCancellable, rhs: AnyCancellable) -> Bool {
5872
lhs._id == rhs._id

Sources/AsyncGuardKit/Core/AsyncGuard.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,6 @@ public enum AsyncGuard {
5656
/// - Parameter configuration: The configuration to apply.
5757
public static func configure(_ configuration: AsyncGuardConfiguration) {
5858
ConfigurationStore.shared.set(configuration)
59+
Diagnostics.log("AsyncGuard.configured", context: "debugLogging=\(configuration.debugLogging)")
5960
}
6061
}

Sources/AsyncGuardKit/Core/AsyncTask.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,9 @@ public final class AsyncTask: @unchecked Sendable {
143143
@discardableResult
144144
public func store(in set: inout Set<AnyCancellable>) -> Self {
145145
_ownershipTransferred = true
146-
let cancellable = AnyCancellable(self) { [weak self] in
147-
self?._task.cancel()
146+
let task = _task
147+
let cancellable = AnyCancellable(self) {
148+
task.cancel()
148149
}
149150
set.insert(cancellable)
150151
Diagnostics.log("AsyncTask.storedInSet")

Sources/AsyncGuardKit/Execution/SingleFlightRegistry.swift

Lines changed: 82 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,92 @@
1-
/// Actor-backed registry that deduplicates concurrent async operations by key.
1+
/// An actor-isolated registry that deduplicates concurrent async operations by key.
22
///
3-
/// `SingleFlightRegistry` maintains a map of in-flight operations keyed by
4-
/// a composite of the caller-supplied key and the expected result type.
5-
/// When a call arrives for a key already in flight, it awaits the existing
6-
/// task rather than starting a new one.
3+
/// `SingleFlightRegistry` is the engine behind ``withSingleFlight(key:operation:)``.
4+
/// It maintains a map of in-flight operations keyed by a composite of the
5+
/// caller-supplied key and the expected result type. When a call arrives for
6+
/// a key already in flight, it joins the existing operation rather than
7+
/// starting a redundant one — all callers suspend and receive the same result.
78
///
8-
/// This type is internal. Public access is via ``withSingleFlight(key:operation:)``.
9+
/// ## Type safety
10+
///
11+
/// Each entry in the registry is stored as a `Flight<T>` — a generic,
12+
/// type-preserving box that holds a `Task<T, Error>`. The composite
13+
/// `FlightKey` encodes both the caller's key and `ObjectIdentifier(T.self)`,
14+
/// preventing collisions between callers sharing the same string key but
15+
/// expecting different return types. Joining an existing flight downcasts
16+
/// `AnyFlight → Flight<T>`, which is guaranteed safe by the `typeID` match.
17+
/// No force-unwrapped casts appear at any call site.
18+
///
19+
/// ## Lifecycle
20+
///
21+
/// A flight entry is inserted when the first caller for a given key arrives
22+
/// and removed — via `defer` — when that operation completes, fails, or is
23+
/// cancelled. Subsequent callers for the same key therefore always start a
24+
/// fresh operation rather than joining a stale one.
25+
///
26+
/// ## Thread safety
27+
///
28+
/// All state mutations are actor-isolated. No external synchronization is
29+
/// required. Call sites may be on any actor or unstructured task context.
30+
///
31+
/// - Note: This type is internal. All public access is through
32+
/// ``withSingleFlight(key:operation:)``.
933
internal actor SingleFlightRegistry {
1034

11-
// MARK: - Shared
12-
1335
static let shared = SingleFlightRegistry()
1436

37+
// MARK: - Type-preserving flight box
38+
39+
/// A type-erased protocol that allows heterogeneous storage of `Flight<T>`
40+
/// values in a single dictionary without losing the ability to await them.
41+
private protocol AnyFlight: AnyObject {
42+
/// Awaits the underlying task and returns its value erased to `Any`.
43+
func awaitResult() async throws -> Any
44+
}
45+
46+
/// A concrete, type-preserving wrapper around a `Task<T, Error>`.
47+
///
48+
/// Storing `Task<T, Error>` directly — rather than erasing to `Task<Any, Error>`
49+
/// — eliminates all force casts at join sites. The `Flight<T>` is recovered
50+
/// from the registry via `as? Flight<T>`, which is guaranteed to succeed
51+
/// when `FlightKey.typeID` matches.
52+
private final class Flight<T: Sendable>: AnyFlight {
53+
let task: Task<T, Error>
54+
init(_ task: Task<T, Error>) { self.task = task }
55+
func awaitResult() async throws -> Any { try await task.value }
56+
}
57+
1558
// MARK: - Storage
1659

17-
/// Composite key preventing type collisions across callers sharing the same
18-
/// string key but expecting different return types.
60+
/// A composite key that uniquely identifies an in-flight operation by
61+
/// both its caller-supplied key and its expected return type.
62+
///
63+
/// Including `typeID` prevents a caller expecting `String` from joining
64+
/// a flight started by a caller expecting `Int` under the same string key.
1965
private struct FlightKey: Hashable {
66+
/// The caller-supplied key, type-erased to `AnyHashable`.
2067
let key: AnyHashable
68+
/// The `ObjectIdentifier` of the expected return type `T`.
2169
let typeID: ObjectIdentifier
2270
}
2371

24-
private var flights: [FlightKey: Task<Any, Error>] = [:]
72+
private var flights: [FlightKey: AnyFlight] = [:]
2573

2674
// MARK: - Execute
2775

28-
/// Joins an existing flight or starts a new one for the given key and type.
76+
/// Joins an existing in-flight operation or starts a new one.
2977
///
30-
/// If a flight for `(key, T)` is already in progress, the caller suspends
31-
/// and awaits its result. Otherwise a new task is started, recorded, and
32-
/// awaited. The entry is removed when the task finishes — on success,
33-
/// failure, or cancellation.
78+
/// If an operation for `(key, T)` is already in progress, the caller
79+
/// suspends and receives its result when it completes. Otherwise a new
80+
/// `Task<T, Error>` is created, stored under `flightKey`, and awaited.
81+
/// The entry is removed from the registry when the task finishes —
82+
/// whether by success, failure, or cancellation.
3483
///
3584
/// - Parameters:
36-
/// - key: The caller-supplied hashable key.
37-
/// - operation: The work to perform if no flight is currently running.
38-
/// - Returns: The result produced by the in-flight or newly started operation.
85+
/// - key: A `Hashable & Sendable` value identifying the operation.
86+
/// - operation: The async throwing closure to execute if no flight
87+
/// is currently in progress for this key and return type.
88+
/// - Returns: The value produced by the in-flight or newly started operation,
89+
/// shared across all concurrent callers for the same key.
3990
/// - Throws: Rethrows any error from the operation to all waiting callers.
4091
func execute<Key: Hashable & Sendable, T: Sendable>(
4192
key: Key,
@@ -46,33 +97,32 @@ internal actor SingleFlightRegistry {
4697
typeID: ObjectIdentifier(T.self)
4798
)
4899

49-
// Join existing flight
50-
if let existing = flights[flightKey] {
100+
// Join existing flight — the downcast to Flight<T> is guaranteed safe
101+
// because FlightKey.typeID encodes T, so only a Flight<T> can be stored
102+
// under this key. No force cast is required.
103+
if let existing = flights[flightKey] as? Flight<T> {
51104
Diagnostics.log("withSingleFlight.joined", key: String(describing: key))
52-
let value = try await existing.value
53-
return value as! T // safe: guaranteed by FlightKey.typeID
105+
return try await existing.task.value
54106
}
55107

56-
// Start new flight
108+
// No flight in progress — start one and register it before suspending
109+
// so that concurrent callers arriving during the first await join it.
57110
Diagnostics.log("withSingleFlight.started", key: String(describing: key))
58111

59-
let task = Task<Any, Error> {
60-
try await operation()
61-
}
112+
let task = Task<T, Error> { try await operation() }
113+
flights[flightKey] = Flight(task)
62114

63-
flights[flightKey] = task
64115
defer {
65116
flights[flightKey] = nil
66117
Diagnostics.log("withSingleFlight.completed", key: String(describing: key))
67118
}
68119

69-
let value = try await task.value
70-
return value as! T // safe: guaranteed by FlightKey.typeID
120+
return try await task.value
71121
}
72122

73-
/// Returns the number of distinct operations currently in flight.
123+
/// The number of distinct operations currently in flight.
74124
///
75-
/// Intended for diagnostics and tests only.
125+
/// Intended for diagnostics and unit tests only. Not part of the public API.
76126
func inFlightCount() -> Int {
77127
flights.count
78128
}

Tests/AsyncGuardKitTests/BenchmarkTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ final class BenchmarkTests: XCTestCase {
2626
func testAsyncTaskStoreInSetOverhead() async throws {
2727
let iterations = 500
2828
let clock = ContinuousClock()
29-
var cancellables = Set<AsyncCancellable>()
29+
var cancellables = Set<AnyCancellable>()
3030

3131
let start = clock.now
3232
for _ in 0..<iterations {

0 commit comments

Comments
 (0)