ANSCoder/AsyncGuardKit
Structured task lifetime management for Swift concurrency
Why this exists
Swift concurrency is excellent. Managing task lifetimes manually is not.
Every Swift developer has written a version of this:
class FeedViewModel: ObservableObject {
func load() {
Task {
let items = try await api.fetchFeed()
self.items = items // π₯ crashes if ViewModel was released
}
// This task has no owner.
// It runs after the ViewModel is gone.
// It mutates deallocated state.
// Everyone writes this. Everyone gets burned by it.
}
}And every team with a networking layer has shipped a version of this:
// User opens a screen. 10 views load simultaneously.
// Each detects an expired token. Each calls refreshToken().
// Your auth server gets 10 simultaneous refresh requests.
// Race conditions ensue. Nobody can reproduce it in development.These are not edge cases. They are the default outcome of Swift concurrency without a lifetime strategy. AsyncGuardKit gives you that strategy β three primitives, zero dependencies, an API that reads like Swift.
The problem
Problem 1 β Tasks outlive their owner
// β Unowned task β crashes or corrupts state
class ProfileViewModel: ObservableObject {
func load() {
Task {
let profile = try await api.fetchProfile()
self.profile = profile // self may already be gone
}
// No way to cancel. No owner. No cleanup.
}
}Problem 2 β Token refresh stampede
// β 10 callers β 10 network requests β race conditions
func fetchData() async throws -> Data {
let token = try await auth.refreshToken() // called 10Γ simultaneously
return try await api.fetch(token: token)
}Problem 3 β Hand-rolled retry
// β Inconsistent, error-prone, not cancellation-aware
var attempts = 0
while attempts < 3 {
do {
return try await api.call()
} catch {
attempts += 1
try await Task.sleep(nanoseconds: 1_000_000_000 * UInt64(attempts))
// Does this propagate CancellationError? Depends who wrote it.
}
}The solution
// β
Lifetime-bound task β zero cleanup code
class ProfileViewModel: ObservableObject {
private let lifetime = AsyncLifetime()
func load() {
AsyncTask {
let profile = try await api.fetchProfile()
await MainActor.run { self.profile = profile }
}
.bind(to: lifetime)
// When ProfileViewModel deallocates β task cancelled. Automatically.
}
}// β
Single-flight deduplication β one request, all callers get the result
let token = try await withSingleFlight(key: "token-refresh") {
try await auth.refreshToken()
}// β
Structured retry β cancellable, composable, testable
let data = try await retry(attempts: 3, backoff: .exponential(base: .seconds(1))) {
try await api.call()
}Installation
Swift Package Manager
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/ANSCoder/AsyncGuardKit", from: "1.0.0")
]Add to your target:
.target(
name: "MyApp",
dependencies: [
.product(name: "AsyncGuardKit", package: "AsyncGuardKit")
]
)Then import:
import AsyncGuardKitXcode
File β Add Package Dependencies β paste the repository URL β select version β Add Package.
Requirements
| Platform | Minimum | |---|---| | iOS | 16.0 | | macOS | 13.0 | | tvOS | 16.0 | | watchOS | 9.0 | | Swift | 5.9 | | Xcode | 15.0 |
API
AsyncTask
A unit of async work with an explicit, declared lifetime strategy. Every AsyncTask requires you to choose one of three strategies β there is no silent default. Lifetime intent is visible in the code.
// Strategy 1 β bind to object lifetime (recommended)
// Cancelled automatically when `lifetime` deallocates.
AsyncTask { await doWork() }
.bind(to: lifetime)
// Strategy 2 β store in a cancellable set (manual control)
// Cancelled when you call cancellables.cancelAll().
AsyncTask { await doWork() }
.store(in: &cancellables)
// Strategy 3 β detached (explicit fire-and-forget)
// Runs to completion. Use for logging, analytics.
AsyncTask { await Analytics.log(.screenViewed) }
.detached()With priority:
AsyncTask(priority: .userInitiated) {
try await api.fetchCriticalData()
}
.bind(to: lifetime)AsyncLifetime
Cancels all bound tasks when it deallocates. Declare it as a let property β it lives and dies with its owner.
class FeedViewModel: ObservableObject {
@Published var items: [Item] = []
private let lifetime = AsyncLifetime() // one line
func load() {
AsyncTask { self.items = try await api.fetchFeed() }
.bind(to: lifetime)
// β Cancelled when FeedViewModel is released.
// No deinit. No cancelAll(). Nothing to forget.
}
func refresh() {
lifetime.cancelAll() // cancel in-flight, restart fresh
AsyncTask { self.items = try await api.fetchFeed() }
.bind(to: lifetime)
}
}Set<AnyCancellable>
Combine-familiar manual cancellation. Same muscle memory, no Combine dependency.
var cancellables = Set<AnyCancellable>()
AsyncTask { await loadFeed() }.store(in: &cancellables)
AsyncTask { await loadAds() }.store(in: &cancellables)
cancellables.cancelAll() // cancel and clearwithSingleFlight(key:operation:)
Execute an operation exactly once for a given key. All concurrent callers for the same key join the in-flight operation and receive the same result β or the same error.
Named following Apple's with* convention (withTaskGroup, withCheckedContinuation) to make scoping semantics immediately recognizable.
// One network request. All concurrent callers share the result.
let token = try await withSingleFlight(key: "token-refresh") {
try await authClient.refreshAccessToken()
}
// Typed keys prevent collisions in large systems
enum APIKey: Hashable {
case tokenRefresh
case userProfile(id: String)
case feedPage(cursor: String)
}
let profile = try await withSingleFlight(key: APIKey.userProfile(id: userID)) {
try await api.fetchProfile(userID)
}retry(attempts:backoff:shouldRetry:operation:)
Retry with configurable backoff. Stops immediately on CancellationError, including during a backoff delay.
// Exponential: 1s β 2s β 4s
let data = try await retry(attempts: 3, backoff: .exponential(base: .seconds(1))) {
try await api.fetchData()
}
// Fixed: 500ms between each attempt
let result = try await retry(attempts: 5, backoff: .fixed(.milliseconds(500))) {
try await database.query()
}
// Conditional: only retry on specific errors
let response = try await retry(attempts: 3, backoff: .exponential(base: .seconds(1))) {
try await api.post(request)
} shouldRetry: { error in
(error as? URLError)?.code == .networkConnectionLost
}RetryBackoff options:
| Case | Behaviour | |---|---| | .none | Retry immediately, no delay | | .fixed(.milliseconds(500)) | Same delay before every attempt | | .exponential(base: .seconds(1)) | base Γ 2βΏ β doubles after each failure |
Real-world patterns
SwiftUI search ViewModel
@MainActor
class SearchViewModel: ObservableObject {
@Published var results: [SearchResult] = []
@Published var isLoading = false
private let lifetime = AsyncLifetime()
func search(query: String) {
lifetime.cancelAll() // cancel previous search
AsyncTask {
self.isLoading = true
defer { self.isLoading = false }
do {
self.results = try await withSingleFlight(key: "search:\(query)") {
try await SearchAPI.search(query: query)
}
} catch is CancellationError {
// User typed again β expected, not an error
}
}
.bind(to: lifetime)
}
}UIKit ViewController with retry
class FeedViewController: UIViewController {
private let lifetime = AsyncLifetime()
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
AsyncTask {
let feed = try await retry(
attempts: 3,
backoff: .exponential(base: .seconds(1))
) {
try await FeedAPI.fetch()
}
await MainActor.run { self.render(feed) }
}
.bind(to: lifetime)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
lifetime.cancelAll()
}
}Authenticated networking layer
class AuthenticatedAPIClient {
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
// Token refreshed exactly once regardless of concurrent callers
let token = try await withSingleFlight(key: "auth-token") {
try await tokenStore.refreshIfNeeded()
}
return try await retry(
attempts: 3,
backoff: .exponential(base: .milliseconds(500)),
shouldRetry: { ($0 as? URLError)?.isTransient == true }
) {
try await self.perform(endpoint, authorization: token)
}
}
}
private extension URLError {
var isTransient: Bool {
[.networkConnectionLost, .timedOut, .notConnectedToInternet].contains(code)
}
}Architecture
ββββββββββββββββββββββββ ββββββββββββββββββββββββ
β Raw Async Call β β Parallel Requests β
ββββββββββββ¬ββββββββββββ ββββββββββββ¬ββββββββββββ
β β
βΌ βΌ
ββββββββββββββββββββββββ ββββββββββββββββββββββββ
β Execution Context β β SingleFlight β
β (caller's actor, β β Deduplication β
β no silent hops) β β (actor-backed) β
ββββββββββββ¬ββββββββββββ ββββββββββββ¬ββββββββββββ
β β
βββββββββββββββββ¬ββββββββββββββββ
βΌ
βββββββββββββββββββββββββ
β Scoped Task β
β Management β
β β
β AsyncLifetime β
β AnyCancellable β
βββββββββββββ¬ββββββββββββ
β
βΌ
βββββββββββββββββββββββββ
β Concurrency β
β Policies β
β β
β retry / backoff β
βββββββββββββ¬ββββββββββββ
β
βΌ
βββββββββββββββββββββββββ
β AsyncTask β
β β
β Owns Task<Void,Never>β
β Enforces strategy β
β declared at site β
βββββββββββββ¬ββββββββββββ
β
βΌ
βββββββββββββββββββββββββ
β Swift Concurrency β
β Runtime β
ββββββ¬βββββ¬βββββ¬βββββ¬ββββ
β β β β
ββββββββββββ β β ββββββββββββββββ
βΌ βΌ βΌ βΌ
ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ
β Task β β Cancel β β Error β β Debug β
βCompletionβ βPropagate β βPropagate β βDiagnosticsβ
ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββLayer responsibilities
| Layer | Responsibility | |---|---| | Execution Context | Preserves caller's actor. Never silently hops. | | SingleFlight Registry | Actor-backed map of (key, type) β Task. New callers join rather than duplicate. Cleaned up on completion or failure. | | Scoped Task Management | AsyncLifetime β deinit cancels. AnyCancellable β you control when. | | Concurrency Policies | retry β cancellation-aware loop with pluggable RetryBackoff. Free functions, not wrapper types. | | AsyncTask | Owns the underlying Task. Enforces that every task declares its lifetime strategy. Cancels defensively in deinit if strategy was never declared. | | Debug Diagnostics | os.Logger events. Gated by #if DEBUG + debugLogging flag. Zero cost in release. |
Why this approach
Why deinit-based cancellation?
The alternative β manual cancelAll() in deinit β requires tracking every task as a stored property. It's verbose, error-prone, and routinely forgotten. By attaching cancellation to an AsyncLifetime object that shares its owner's lifecycle, cancellation becomes a structural guarantee rather than a coding discipline.
This pattern is directly inspired by Combine's AnyCancellable, which proved that lifecycle-bound cancellation is the right default for asynchronous streams. AsyncGuardKit applies the same model to Swift concurrency tasks.
Why free functions for withSingleFlight and retry?
Apple uses free functions for coordination primitives: withTaskGroup, withCheckedContinuation, withUnsafeContinuation. This convention signals that the function establishes a scope with well-defined entry and exit semantics. A static method on a facade (AsyncGuard.singleFlight) obscures that signal and adds indirection without benefit.
Why AnyCancellable instead of a protocol existential?
Swift cannot make any Protocol conform to Hashable, which Set requires. A concrete wrapper type β identical to Combine's approach β is the correct solution. It also gives stable ObjectIdentifier-based identity without requiring conforming types to implement Hashable themselves.
Why not an actor for AsyncTask?
Actors serialize access via suspension points. A task wrapper implemented as an actor would add unnecessary awaits to operations that are already thread-safe by construction. AsyncTask uses NSLock only for the minimal ownership-transfer flag β no suspension, no reentrancy risk.
Debug diagnostics
Enable structured logging during development:
// AppDelegate or @main
#if DEBUG
AsyncGuard.configure(.init(debugLogging: true))
#endifEvents appear in Console.app and the Xcode debug console:
[main] AsyncTask.created
[main] AsyncTask.boundToLifetime
[background] withSingleFlight.started key=token-refresh
[background] withSingleFlight.joined key=token-refresh
[background] withSingleFlight.completed key=token-refresh
[background] retry.failed attempt=1 error=URLError(networkConnectionLost)
[background] retry.succeeded attempt=2
[main] AsyncLifetime.cancelledAll count=3
[main] AsyncLifetime.deallocatedAll logging is compiled out entirely in release builds β #if DEBUG eliminates every call site. Zero runtime cost in production.
Design principles
Explicit over implicit. AsyncGuardKit never silently switches actor context, auto-cancels tasks without declaration, or makes scheduling decisions on your behalf. Every behavior is visible at the call site.
Lifetime binding over manual cleanup. The deinit-based model means correctly managed tasks require zero cleanup code. The absence of a deinit override is not a bug β it is the correct outcome.
Free functions for scoped coordination. withSingleFlight and retry follow Apple's with* convention, making their scoping semantics immediately recognizable to any Swift developer.
No dependencies. Only the Swift standard library, Foundation, and os.Logger. Nothing pulled in transitively.
Testable by design. Every component exposes what tests need. SingleFlightRegistry.inFlightCount() for deduplication assertions. AsyncLifetime.count for binding assertions. retry's shouldRetry predicate injectable in tests.
License
AsyncGuardKit is available under the MIT license. See LICENSE for details.
Contributing
Contributions are welcome. Please read the following before opening a pull request.
Before contributing:
- Open an issue to discuss significant changes before implementing
- Check existing issues and pull requests to avoid duplicate work
Standards:
- All public API must include full doc comments following the existing style
- All changes must include tests β new behaviour requires new tests, bug fixes require regression tests
- Run
swift testand ensure all tests pass - No force unwraps, no global mutable state, no blocking APIs
Pull request checklist:
- [ ] Tests added or updated
- [ ] Documentation updated
- [ ]
swift testpasses locally - [ ] No new compiler warnings
<div align="center"> <sub>Built with care for the Swift open-source community.</sub> </div>
Package Metadata
Repository: ANSCoder/AsyncGuardKit
Stars: 0
Forks: 0
Open issues: 0
Default branch: master
Primary language: swift
License: MIT
Topics: async, async-await, ios, spm, swift, swift-concurrency, task, task-management
README: README.md