diff --git a/Package.swift b/Package.swift index 639a999..ab21adb 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.0 import PackageDescription let package = Package( @@ -18,18 +18,13 @@ let package = Package( targets: [ .target( name: "AsyncGuardKit", - path: "Sources/AsyncGuardKit", - swiftSettings: [ - .enableExperimentalFeature("StrictConcurrency") - ] + path: "Sources/AsyncGuardKit" ), .testTarget( name: "AsyncGuardKitTests", dependencies: ["AsyncGuardKit"], - path: "Tests/AsyncGuardKitTests", - swiftSettings: [ - .enableExperimentalFeature("StrictConcurrency") - ] + path: "Tests/AsyncGuardKitTests" ) - ] + ], + swiftLanguageModes: [.v6] ) diff --git a/README.md b/README.md index 1bc9082..dcd264d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Structured task lifetime management for Swift.** -[![Swift 5.9+](https://img.shields.io/badge/Swift-5.9+-F05138?style=flat&logo=swift&logoColor=white)](https://swift.org) +[![Swift 6](https://img.shields.io/badge/Swift-6.0+-F05138?style=flat&logo=swift&logoColor=white)](https://swift.org) [![iOS 16+](https://img.shields.io/badge/iOS-16+-000000?style=flat&logo=apple&logoColor=white)](https://developer.apple.com/ios/) [![macOS 13+](https://img.shields.io/badge/macOS-13+-000000?style=flat&logo=apple&logoColor=white)](https://developer.apple.com/macos/) [![MIT License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE) @@ -18,121 +18,41 @@ --- -AsyncGuardKit eliminates the manual cancellation boilerplate that developers write — and forget — when using Swift concurrency. It gives you three focused, composable tools: **automatic lifetime binding**, **single-flight deduplication**, and **retry with backoff** — each designed to feel native to Swift. +AsyncGuardKit removes the cancellation boilerplate that Swift concurrency leaves to you. Three small, composable primitives — **lifetime-bound tasks**, **single-flight deduplication**, and **cancellation-aware retry** — built for Swift 6 strict concurrency, with zero dependencies. ---- - -## Why this exists - -Swift concurrency is excellent. Managing task lifetimes manually is not. - -Every Swift developer has written a version of this: - -```swift -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: - -```swift -// 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. +| | | +|---|---| +| 🔗 **`AsyncTask` + `AsyncLifetime`** | Tasks that are cancelled when their owner goes away | +| 🛫 **`withSingleFlight`** | N concurrent callers → one operation → one shared result | +| 🔁 **`retry`** | Backoff, predicates, overflow-safe, stops the instant you are cancelled | --- -## The problem - -### Problem 1 — Tasks outlive their owner - -```swift -// ❌ 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 - -```swift -// ❌ 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 - -```swift -// ❌ 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. - } -} -``` - ---- +## Why this exists -## The solution +An unowned `Task {}` keeps running after the screen that started it is gone. A token refresh fired from ten views hits your server ten times. Hand-rolled retry loops forget `CancellationError`. These are the *default* outcome of Swift concurrency without a lifetime strategy. ```swift -// ✅ 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. - } +// ✅ Owned, cancelled with its owner +AsyncTask { [weak self] in + let profile = try await api.fetchProfile() + await self?.apply(profile) } -``` +.bind(to: lifetime) -```swift -// ✅ Single-flight deduplication — one request, all callers get the result +// ✅ Ten callers, one refresh let token = try await withSingleFlight(key: "token-refresh") { try await auth.refreshToken() } -``` -```swift -// ✅ Structured retry — cancellable, composable, testable +// ✅ Cancellable, composable retry let data = try await retry(attempts: 3, backoff: .exponential(base: .seconds(1))) { try await api.call() } ``` +> **Capture `self` weakly.** `AsyncTask` cancels when its `AsyncLifetime` deallocates, but a running task that captures its owner strongly keeps that owner alive. Use `[weak self]` (as above) so deallocation can actually happen. + --- ## Installation @@ -143,7 +63,7 @@ Add to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/ANSCoder/AsyncGuardKit", from: "1.0.0") + .package(url: "https://github.com/ANSCoder/AsyncGuardKit", from: "1.0.0") // check Releases for the latest ] ``` @@ -176,8 +96,8 @@ import AsyncGuardKit | macOS | 13.0 | | tvOS | 16.0 | | watchOS | 9.0 | -| Swift | 5.9 | -| Xcode | 15.0 | +| Swift | 6.0 (builds in Swift 6 language mode) | +| Xcode | 16.0 | --- @@ -217,25 +137,29 @@ AsyncTask(priority: .userInitiated) { ### `AsyncLifetime` -Cancels all bound tasks when it deallocates. Declare it as a `let` property — it lives and dies with its owner. +Cancels all bound tasks when it deallocates. Declare it as a `let` property — it lives and dies with its owner. Tasks that finish on their own are released immediately, so `count` reflects only work still in flight. ```swift -class FeedViewModel: ObservableObject { - @Published var items: [Item] = [] +@MainActor @Observable +final class FeedViewModel { + 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. + AsyncTask { [weak self] in + let items = try await api.fetchFeed() + await self?.apply(items) + } + .bind(to: lifetime) + // ↑ Cancelled when FeedViewModel is released. No deinit needed. } func refresh() { lifetime.cancelAll() // cancel in-flight, restart fresh - AsyncTask { self.items = try await api.fetchFeed() } - .bind(to: lifetime) + load() } + + private func apply(_ items: [Item]) { self.items = items } } ``` @@ -243,7 +167,7 @@ class FeedViewModel: ObservableObject { ### `Set` -Combine-familiar manual cancellation. Same muscle memory, no Combine dependency. +Combine-familiar manual cancellation. Same muscle memory, no Combine dependency. (If you also `import Combine`, qualify the type as `AsyncGuardKit.AnyCancellable`.) ```swift var cancellables = Set() @@ -260,6 +184,8 @@ cancellables.cancelAll() // cancel and clear 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. +**Cancellation:** cancelling one caller never cancels work others are still waiting on. When the *last* waiter is cancelled, the shared operation is cancelled and the key is freed, so the next call starts fresh. + Named following Apple's `with*` convention (`withTaskGroup`, `withCheckedContinuation`) to make scoping semantics immediately recognizable. ```swift @@ -282,9 +208,9 @@ let profile = try await withSingleFlight(key: APIKey.userProfile(id: userID)) { --- -### `retry(attempts:backoff:shouldRetry:operation:)` +### `retry(attempts:backoff:shouldRetry:isolation:operation:)` -Retry with configurable backoff. Stops immediately on `CancellationError`, including during a backoff delay. +Retry with configurable backoff. Stops immediately on `CancellationError`, including during a backoff delay. Exponential delays saturate instead of overflowing, so very high attempt counts are safe. The operation runs on the **caller's actor**, so it can use that actor's non-`Sendable` state without `@Sendable` gymnastics. ```swift // Exponential: 1s → 2s → 4s @@ -298,10 +224,12 @@ let result = try await retry(attempts: 5, backoff: .fixed(.milliseconds(500))) { } // Conditional: only retry on specific errors -let response = try await retry(attempts: 3, backoff: .exponential(base: .seconds(1))) { +let response = try await retry( + attempts: 3, + backoff: .exponential(base: .seconds(1)), + shouldRetry: { ($0 as? URLError)?.code == .networkConnectionLost } +) { try await api.post(request) -} shouldRetry: { error in - (error as? URLError)?.code == .networkConnectionLost } ``` @@ -319,51 +247,56 @@ let response = try await retry(attempts: 3, backoff: .exponential(base: .seconds ### SwiftUI search ViewModel +> `@Observable` needs iOS 17+; on iOS 16 use `ObservableObject` + `@Published` — the `AsyncTask` code is identical. + ```swift -@MainActor -class SearchViewModel: ObservableObject { - @Published var results: [SearchResult] = [] - @Published var isLoading = false +@MainActor @Observable +final class SearchViewModel { + var results: [SearchResult] = [] + var isLoading = false private let lifetime = AsyncLifetime() func search(query: String) { lifetime.cancelAll() // cancel previous search - AsyncTask { - self.isLoading = true - defer { self.isLoading = false } - + AsyncTask { [weak self] in + await self?.setLoading(true) do { - self.results = try await withSingleFlight(key: "search:\(query)") { + let results = try await withSingleFlight(key: "search:\(query)") { try await SearchAPI.search(query: query) } - } catch is CancellationError { - // User typed again — expected, not an error + await self?.finish(results) + } catch { + await self?.setLoading(false) // includes CancellationError } } .bind(to: lifetime) } + + private func setLoading(_ value: Bool) { isLoading = value } + private func finish(_ results: [SearchResult]) { + self.results = results + isLoading = false + } } ``` ### UIKit ViewController with retry ```swift -class FeedViewController: UIViewController { +@MainActor +final 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)) - ) { + AsyncTask { [weak self] in + let feed = try await retry(attempts: 3, backoff: .exponential(base: .seconds(1))) { try await FeedAPI.fetch() } - await MainActor.run { self.render(feed) } + await self?.render(feed) } .bind(to: lifetime) } @@ -378,7 +311,7 @@ class FeedViewController: UIViewController { ### Authenticated networking layer ```swift -class AuthenticatedAPIClient { +actor AuthenticatedAPIClient { func request(_ endpoint: Endpoint) async throws -> T { // Token refreshed exactly once regardless of concurrent callers @@ -414,9 +347,9 @@ private extension URLError { │ │ ▼ ▼ ┌──────────────────────┐ ┌──────────────────────┐ -│ Execution Context │ │ SingleFlight │ -│ (caller's actor, │ │ Deduplication │ -│ no silent hops) │ │ (actor-backed) │ +│ Caller │ │ SingleFlight │ +│ (any actor or │ │ Deduplication │ +│ task context) │ │ (actor-backed) │ └──────────┬───────────┘ └──────────┬───────────┘ │ │ └───────────────┬───────────────┘ @@ -464,8 +397,7 @@ private extension URLError { | 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. | +| **SingleFlight Registry** | Actor-backed map of `(key, type) → Task`. New callers join rather than duplicate. Waiter-counted: cancelled with its last waiter, 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. | @@ -491,7 +423,7 @@ Swift cannot make `any Protocol` conform to `Hashable`, which `Set` requires. A ### 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. +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 its small lifecycle state (ownership, completion hook) — no suspension, no reentrancy risk. --- @@ -526,7 +458,7 @@ All logging is compiled out entirely in release builds — `#if DEBUG` eliminate ## 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. +**Explicit over implicit.** AsyncGuardKit never auto-cancels tasks without declaration or makes scheduling decisions on your behalf. `AsyncTask` operations are `@Sendable` and non-isolated — hop to an actor explicitly (`await self?.apply(...)`). 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. diff --git a/Sources/AsyncGuardKit/Core/AsyncTask.swift b/Sources/AsyncGuardKit/Core/AsyncTask.swift index 4ff7921..c2c365a 100644 --- a/Sources/AsyncGuardKit/Core/AsyncTask.swift +++ b/Sources/AsyncGuardKit/Core/AsyncTask.swift @@ -51,7 +51,36 @@ public final class AsyncTask: @unchecked Sendable { // MARK: - Storage private let _task: Task - private var _ownershipTransferred = false + private let _state = State() + + /// Lock-protected lifecycle flags shared with the running task. + private final class State: @unchecked Sendable { + private let lock = NSLock() + private var ownershipTransferred = false + private var finished = false + private var onFinish: (@Sendable () -> Void)? + + func transferOwnership() { lock.withLock { ownershipTransferred = true } } + var isOwned: Bool { lock.withLock { ownershipTransferred } } + + /// Registers a completion hook; returns `false` if already finished. + func setOnFinish(_ hook: @escaping @Sendable () -> Void) -> Bool { + lock.withLock { + guard !finished else { return false } + onFinish = hook + return true + } + } + + func markFinished() { + let hook = lock.withLock { () -> (@Sendable () -> Void)? in + finished = true + defer { onFinish = nil } + return onFinish + } + hook?() + } + } // MARK: - Init @@ -68,8 +97,10 @@ public final class AsyncTask: @unchecked Sendable { priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> Void ) { + let state = _state _task = Task(priority: priority) { await operation() + state.markFinished() } Diagnostics.log("AsyncTask.created") } @@ -86,6 +117,7 @@ public final class AsyncTask: @unchecked Sendable { priority: TaskPriority? = nil, operation: @escaping @Sendable () async throws -> Void ) { + let state = _state _task = Task(priority: priority) { do { try await operation() @@ -94,12 +126,13 @@ public final class AsyncTask: @unchecked Sendable { } catch { Diagnostics.log("AsyncTask.failed", context: String(describing: error)) } + state.markFinished() } Diagnostics.log("AsyncTask.created") } deinit { - if !_ownershipTransferred { + if !_state.isOwned { _task.cancel() Diagnostics.log("AsyncTask.cancelledOnDeinit") } @@ -120,7 +153,7 @@ public final class AsyncTask: @unchecked Sendable { /// - Parameter lifetime: The lifetime to bind to. @discardableResult public func bind(to lifetime: AsyncLifetime) -> Self { - _ownershipTransferred = true + _state.transferOwnership() lifetime.add(self) Diagnostics.log("AsyncTask.boundToLifetime") return self @@ -142,7 +175,7 @@ public final class AsyncTask: @unchecked Sendable { /// - Parameter set: The ``AnyCancellable`` set to store into. @discardableResult public func store(in set: inout Set) -> Self { - _ownershipTransferred = true + _state.transferOwnership() let task = _task let cancellable = AnyCancellable(self) { task.cancel() @@ -163,12 +196,18 @@ public final class AsyncTask: @unchecked Sendable { /// ``` @discardableResult public func detached() -> Self { - _ownershipTransferred = true + _state.transferOwnership() Diagnostics.log("AsyncTask.detached") return self } /// Cancels the underlying task cooperatively. + /// Calls `hook` once the task has finished (immediately skipped if it already has). + /// Returns `false` when the task is already complete. + internal func onFinish(_ hook: @escaping @Sendable () -> Void) -> Bool { + _state.setOnFinish(hook) + } + public func cancel() { _task.cancel() Diagnostics.log("AsyncTask.cancelled") diff --git a/Sources/AsyncGuardKit/Execution/SingleFlightRegistry.swift b/Sources/AsyncGuardKit/Execution/SingleFlightRegistry.swift index 521a937..e7e55bc 100644 --- a/Sources/AsyncGuardKit/Execution/SingleFlightRegistry.swift +++ b/Sources/AsyncGuardKit/Execution/SingleFlightRegistry.swift @@ -1,3 +1,5 @@ +import Foundation + /// An actor-isolated registry that deduplicates concurrent async operations by key. /// /// `SingleFlightRegistry` is the engine behind ``withSingleFlight(key:operation:)``. @@ -13,15 +15,16 @@ /// `FlightKey` encodes both the caller's key and `ObjectIdentifier(T.self)`, /// preventing collisions between callers sharing the same string key but /// expecting different return types. Joining an existing flight downcasts -/// `AnyFlight → Flight`, which is guaranteed safe by the `typeID` match. +/// to `Flight`, which is guaranteed safe by the `typeID` match. /// No force-unwrapped casts appear at any call site. /// /// ## Lifecycle /// /// A flight entry is inserted when the first caller for a given key arrives -/// and removed — via `defer` — when that operation completes, fails, or is -/// cancelled. Subsequent callers for the same key therefore always start a -/// fresh operation rather than joining a stale one. +/// and removed once every waiter has left — because the operation completed, +/// failed, or all waiters were cancelled. Cancelling one waiter never cancels +/// the shared operation while others still wait; cancelling the last one does, +/// and the entry is dropped immediately so the next caller starts fresh. /// /// ## Thread safety /// @@ -36,58 +39,33 @@ internal actor SingleFlightRegistry { // MARK: - Type-preserving flight box - /// A type-erased protocol that allows heterogeneous storage of `Flight` - /// values in a single dictionary without losing the ability to await them. - private protocol AnyFlight: AnyObject { - /// Awaits the underlying task and returns its value erased to `Any`. - func awaitResult() async throws -> Any - } - - /// A concrete, type-preserving wrapper around a `Task`. - /// - /// Storing `Task` directly — rather than erasing to `Task` - /// — eliminates all force casts at join sites. The `Flight` is recovered - /// from the registry via `as? Flight`, which is guaranteed to succeed - /// when `FlightKey.typeID` matches. - private final class Flight: AnyFlight { + private final class Flight { + let id = UUID() let task: Task + var waiters: Set = [] init(_ task: Task) { self.task = task } - func awaitResult() async throws -> Any { try await task.value } } // MARK: - Storage - /// A composite key that uniquely identifies an in-flight operation by - /// both its caller-supplied key and its expected return type. - /// - /// Including `typeID` prevents a caller expecting `String` from joining - /// a flight started by a caller expecting `Int` under the same string key. - private struct FlightKey: Hashable { - /// The caller-supplied key, type-erased to `AnyHashable`. + // AnyHashable only ever wraps a `Key: Sendable` (see `execute`). + private struct FlightKey: Hashable, @unchecked Sendable { let key: AnyHashable - /// The `ObjectIdentifier` of the expected return type `T`. let typeID: ObjectIdentifier } - private var flights: [FlightKey: AnyFlight] = [:] + /// Type-erased view of a flight's waiter bookkeeping. + private struct Entry { + let id: UUID + let box: AnyObject + let cancel: () -> Void + let removeWaiter: (UUID) -> Bool // true when no waiters remain + } + + private var flights: [FlightKey: Entry] = [:] // MARK: - Execute - /// Joins an existing in-flight operation or starts a new one. - /// - /// If an operation for `(key, T)` is already in progress, the caller - /// suspends and receives its result when it completes. Otherwise a new - /// `Task` is created, stored under `flightKey`, and awaited. - /// The entry is removed from the registry when the task finishes — - /// whether by success, failure, or cancellation. - /// - /// - Parameters: - /// - key: A `Hashable & Sendable` value identifying the operation. - /// - operation: The async throwing closure to execute if no flight - /// is currently in progress for this key and return type. - /// - Returns: The value produced by the in-flight or newly started operation, - /// shared across all concurrent callers for the same key. - /// - Throws: Rethrows any error from the operation to all waiting callers. func execute( key: Key, operation: @escaping @Sendable () async throws -> T @@ -97,32 +75,50 @@ internal actor SingleFlightRegistry { typeID: ObjectIdentifier(T.self) ) - // Join existing flight — the downcast to Flight is guaranteed safe - // because FlightKey.typeID encodes T, so only a Flight can be stored - // under this key. No force cast is required. - if let existing = flights[flightKey] as? Flight { + // The downcast is guaranteed safe because FlightKey.typeID encodes T. + let flight: Flight + if let existing = flights[flightKey]?.box as? Flight { Diagnostics.log("withSingleFlight.joined", key: String(describing: key)) - return try await existing.task.value + flight = existing + } else { + // Register before suspending so concurrent callers join this flight. + Diagnostics.log("withSingleFlight.started", key: String(describing: key)) + let new = Flight(Task { try await operation() }) + flights[flightKey] = Entry( + id: new.id, + box: new, + cancel: { new.task.cancel() }, + removeWaiter: { new.waiters.remove($0); return new.waiters.isEmpty } + ) + flight = new } - // No flight in progress — start one and register it before suspending - // so that concurrent callers arriving during the first await join it. - Diagnostics.log("withSingleFlight.started", key: String(describing: key)) - - let task = Task { try await operation() } - flights[flightKey] = Flight(task) - + let waiterID = UUID() + flight.waiters.insert(waiterID) defer { - flights[flightKey] = nil + release(flightKey, flightID: flight.id, waiter: waiterID) Diagnostics.log("withSingleFlight.completed", key: String(describing: key)) } - return try await task.value + let task = flight.task + let flightID = flight.id + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + // Caller cancelled: leave the flight, cancelling the work if nobody is left. + Task { await self.release(flightKey, flightID: flightID, waiter: waiterID) } + } + } + + /// Removes `waiter`; when the last one leaves, cancels the work and drops the entry. + /// Idempotent, and a no-op if the key now belongs to a newer flight. + private func release(_ key: FlightKey, flightID: UUID, waiter: UUID) { + guard let entry = flights[key], entry.id == flightID else { return } + guard entry.removeWaiter(waiter) else { return } + entry.cancel() + flights[key] = nil } - /// The number of distinct operations currently in flight. - /// - /// Intended for diagnostics and unit tests only. Not part of the public API. func inFlightCount() -> Int { flights.count } diff --git a/Sources/AsyncGuardKit/Policies/Retry.swift b/Sources/AsyncGuardKit/Policies/Retry.swift index 7ca60eb..7c287b4 100644 --- a/Sources/AsyncGuardKit/Policies/Retry.swift +++ b/Sources/AsyncGuardKit/Policies/Retry.swift @@ -57,17 +57,20 @@ import Foundation /// Defaults to retrying on all errors. /// - operation: The async, throwing operation to execute. /// - Returns: The value returned by the first successful attempt. +/// - Note: `operation` runs on the caller's actor (`isolation`), so it may +/// freely capture non-`Sendable` state of that actor. /// - Throws: The error from the final attempt, or `CancellationError` if /// the enclosing task is cancelled. -public func retry( +public func retry( attempts: Int, backoff: RetryBackoff = .none, - shouldRetry: @Sendable (Error) -> Bool = { _ in true }, - operation: @escaping @Sendable () async throws -> T + shouldRetry: (Error) -> Bool = { _ in true }, + isolation: isolated (any Actor)? = #isolation, + operation: () async throws -> T ) async throws -> T { precondition(attempts >= 1, "retry(attempts:) requires at least 1 attempt") - var lastError: Error? + var lastError: any Error = CancellationError() for attempt in 0..( let isLastAttempt = attempt == attempts - 1 guard !isLastAttempt, shouldRetry(error) else { break } - let delayNanoseconds = backoff.delay(for: attempt) - if delayNanoseconds > 0 { - try await Task.sleep(nanoseconds: delayNanoseconds) + let delay = backoff.delay(for: attempt) + if delay > .zero { + try await Task.sleep(for: delay) } } } - throw lastError! + throw lastError } // MARK: - RetryBackoff @@ -131,26 +134,20 @@ public enum RetryBackoff: Sendable { case exponential(base: Duration) /// Returns the nanosecond delay for a given 0-based attempt index. - internal func delay(for attempt: Int) -> UInt64 { + internal func delay(for attempt: Int) -> Duration { switch self { case .none: - return 0 + return .zero case .fixed(let duration): - return UInt64(max(0, duration.nanoseconds)) + return max(.zero, duration) case .exponential(let base): - let multiplier = UInt64(pow(2.0, Double(attempt))) - let baseNanos = UInt64(max(0, base.nanoseconds)) - let (result, overflow) = baseNanos.multipliedReportingOverflow(by: multiplier) - return overflow ? UInt64.max : result + // 2^attempt overflows Int64 at 63; clamp the shift so large + // attempt counts saturate instead of trapping. + let multiplier = 1 << min(attempt, 62) + // Saturate at ~68 years rather than overflowing Duration's Int128. + let cap = Duration.seconds(Int32.max) + let base = max(.zero, base) + return base > cap / multiplier ? cap : base * multiplier } } } - -// MARK: - Duration nanoseconds - -private extension Duration { - var nanoseconds: Int64 { - let (seconds, attoseconds) = components - return seconds * 1_000_000_000 + attoseconds / 1_000_000_000 - } -} diff --git a/Sources/AsyncGuardKit/Scope/AsyncLifetime.swift b/Sources/AsyncGuardKit/Scope/AsyncLifetime.swift index a752778..4de7572 100644 --- a/Sources/AsyncGuardKit/Scope/AsyncLifetime.swift +++ b/Sources/AsyncGuardKit/Scope/AsyncLifetime.swift @@ -79,12 +79,22 @@ public final class AsyncLifetime: @unchecked Sendable { /// /// Called by ``AsyncTask/bind(to:)``. Not intended for direct use. internal func add(_ cancellable: AsyncTask) { + let id = ObjectIdentifier(cancellable) + // Finished tasks drop out so a long-lived owner doesn't accumulate them. + // Insert before registering the hook so a fast finish can't remove first. _lock.withLock { - _tasks[ObjectIdentifier(cancellable)] = cancellable + _tasks[id] = cancellable + } + if !cancellable.onFinish({ [weak self] in self?.remove(id) }) { + remove(id) } Diagnostics.log("AsyncLifetime.taskAdded", context: "count=\(count)") } + private func remove(_ id: ObjectIdentifier) { + _lock.withLock { _tasks[id] = nil } + } + // MARK: - Public API /// Cancels all bound tasks and clears the lifetime. diff --git a/Tests/AsyncGuardKitTests/RegressionTests.swift b/Tests/AsyncGuardKitTests/RegressionTests.swift new file mode 100644 index 0000000..f2fde66 --- /dev/null +++ b/Tests/AsyncGuardKitTests/RegressionTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import AsyncGuardKit + +final class RegressionTests: XCTestCase { + + func testExponentialBackoffSaturatesInsteadOfTrapping() { + let backoff = RetryBackoff.exponential(base: .seconds(1)) + XCTAssertEqual(backoff.delay(for: 3), .seconds(8)) + XCTAssertGreaterThan(backoff.delay(for: 200), .seconds(1_000_000)) + } + + func testFinishedTasksLeaveLifetime() async throws { + let lifetime = AsyncLifetime() + AsyncTask { }.bind(to: lifetime) + try await Task.sleep(for: .milliseconds(100)) + XCTAssertEqual(lifetime.count, 0) + } + + func testCancellingLastWaiterCancelsSharedOperation() async throws { + let cancelled = CancelCounter() + let caller = Task { + try await withSingleFlight(key: "cancel-propagation") { + do { try await Task.sleep(for: .seconds(5)) } + catch { await cancelled.increment(); throw error } + } + } + try await Task.sleep(for: .milliseconds(50)) + caller.cancel() + _ = try? await caller.value + try await Task.sleep(for: .milliseconds(50)) + let count = await cancelled.value + let inFlight = await SingleFlightRegistry.shared.inFlightCount() + XCTAssertEqual(count, 1) + XCTAssertEqual(inFlight, 0) + } +}