Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 5 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,24 +48,19 @@ jobs:
include:
# macOS
- { platform: macOS, name: "macOS 26, Xcode 26.5, Swift 6.2.0", xcode: "Xcode_26.5", runsOn: macOS-26, destination: "platform=macOS" }
- { platform: macOS, name: "macOS 14, Xcode 16.1, Swift 6.0.2", xcode: "Xcode_16.1", runsOn: macOS-14, destination: "platform=macOS" }
- { platform: macOS, name: "macOS 14, Xcode 15.4, Swift 5.10", xcode: "Xcode_15.4", runsOn: macOS-14, destination: "platform=macOS" }
- { platform: macOS, name: "macOS 15, Xcode 16.1, Swift 6.0.2", xcode: "Xcode_16.1", runsOn: macOS-15, destination: "platform=macOS" }

# iOS
- { platform: iOS, name: "iOS 26.5", xcode: "Xcode_26.5", runsOn: macOS-26, destination: "OS=26.5,name=iPhone 17 Pro" }
- { platform: iOS, name: "iOS 18.1", xcode: "Xcode_16.1", runsOn: macOS-14, destination: "OS=18.1,name=iPhone 16 Pro" }
- { platform: iOS, name: "iOS 17.4", xcode: "Xcode_15.3", runsOn: macOS-14, destination: "OS=17.4,name=iPhone 15 Pro" }
- { platform: iOS, name: "iOS 26.2", xcode: "Xcode_26.2", runsOn: macOS-26, destination: "OS=26.2,name=iPhone 17 Pro" }

# tvOS
- { platform: tvOS, name: "tvOS 26.5", xcode: "Xcode_26.5", runsOn: macOS-26, destination: "OS=26.5,name=Apple TV" }
- { platform: tvOS, name: "tvOS 18.1", xcode: "Xcode_16.1", runsOn: macOS-14, destination: "OS=18.1,name=Apple TV" }
- { platform: tvOS, name: "tvOS 17.4", xcode: "Xcode_15.3", runsOn: macOS-14, destination: "OS=17.4,name=Apple TV" }
- { platform: tvOS, name: "tvOS 26.2", xcode: "Xcode_26.2", runsOn: macOS-26, destination: "OS=26.2,name=Apple TV" }

# watchOS
- { platform: watchOS, name: "watchOS 26.5", xcode: "Xcode_26.5", runsOn: macOS-26, destination: "OS=26.5,name=Apple Watch Ultra 3 (49mm)" }
- { platform: watchOS, name: "watchOS 11.1", xcode: "Xcode_16.1", runsOn: macOS-14, destination: "OS=11.1,name=Apple Watch Series 10 (46mm)" }
- { platform: watchOS, name: "watchOS 10.5", xcode: "Xcode_15.3", runsOn: macOS-14, destination: "OS=10.5,name=Apple Watch Series 9 (45mm)" }
- { platform: watchOS, name: "watchOS 10.4", xcode: "Xcode_15.3", runsOn: macOS-14, destination: "OS=10.4,name=Apple Watch Series 9 (45mm)" }
- { platform: watchOS, name: "watchOS 26.2", xcode: "Xcode_26.2", runsOn: macOS-26, destination: "OS=26.2,name=Apple Watch Ultra 3 (49mm)" }
steps:
- name: Checkout code
uses: actions/checkout@v7
Expand All @@ -89,9 +84,7 @@ jobs:
matrix:
include:
- { name: "macOS 26, SPM 6.2.0", xcode: "Xcode_26.1.1", runsOn: macOS-26 }
- { name: "macOS 15, SPM 6.0.2", xcode: "Xcode_16.0", runsOn: macOS-15 }
- { name: "macOS 14, SPM 6.0.2", xcode: "Xcode_16.1", runsOn: macOS-14 }
- { name: "macOS 14, SPM 5.10.0", xcode: "Xcode_15.3", runsOn: macOS-14 }
- { name: "macOS 15, SPM 6.0.2", xcode: "Xcode_16.1", runsOn: macOS-15 }
steps:
- name: Checkout code
uses: actions/checkout@v7
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ let package = Package(
targets: [
.target(name: "Concurrency", dependencies: []),
.target(name: "TestConcurrency", dependencies: ["Concurrency"]),
.testTarget(name: "ConcurrencyTests", dependencies: ["Concurrency"]),
.testTarget(name: "ConcurrencyTests", dependencies: ["Concurrency", "TestConcurrency"]),
]
)
2 changes: 1 addition & 1 deletion Package@swift-5.10.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ let package = Package(
targets: [
.target(name: "Concurrency", dependencies: []),
.target(name: "TestConcurrency", dependencies: ["Concurrency"]),
.testTarget(name: "ConcurrencyTests", dependencies: ["Concurrency"]),
.testTarget(name: "ConcurrencyTests", dependencies: ["Concurrency", "TestConcurrency"]),
]
)
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

## Features
✨ **Type-Safe Queue Abstractions** - Clean interfaces for main, global, and custom queues
🧵 **Typed Task Failures** - `TypedTask` keeps the error type of a `throws(Failure)` operation
🧪 **Built for Testing** - Comes with dedicated test doubles for unit testing
⚡ **Lightweight** - Minimal wrapper around GCD with zero overhead
🎯 **Protocol-Based** - Easy to mock and inject dependencies
Expand All @@ -31,6 +32,8 @@
- [Main Queue](#main-queue)
- [Global Queue](#global-queue)
- [Private Queue](#private-queue)
- [Task Factory](#task-factory)
- [Typed Task](#typed-task)
- [Testing](#testing)
- [Communication](#communication)
- [Contributing](#contributing)
Expand Down Expand Up @@ -203,6 +206,41 @@ class NetworkManager {
- `autoreleaseFrequency` - Memory management strategy
- `target` - Queue to execute blocks on (advanced usage)

### Task Factory

`ITaskFactory` creates Swift concurrency tasks, so code that starts a task can be given a test double:

```swift
import Concurrency

let factory: ITaskFactory = TaskFactory()

factory.task { await refresh() } // tied to the current actor
factory.detached { try await export() } // detached from it
```

### Typed Task

A `Task` created by the standard library only fails with `Never` or `any Error`, so the error type of an operation that
`throws(NetworkError)` is lost. `typedTask` and `typedDetached` return a `TypedTask`, which keeps it:

```swift
let task = factory.typedTask { () async throws(NetworkError) -> User in
try await client.loadUser()
}

do throws(NetworkError) {
let user = try await task.value
} catch {
// `error` is a `NetworkError`, no cast is needed.
}

let result: Result<User, NetworkError> = await task.result
```

`TypedTask` is `Hashable` like `Task`, can be cancelled with `cancel()`, and `TestTaskFactory` waits for it in
`waitUntilIdle()` like it does for the other tasks.

## Testing

Concurrency includes a dedicated test target with synchronous test doubles:
Expand Down
45 changes: 45 additions & 0 deletions Sources/Concurrency/TaskFactory/ITaskFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,30 @@ import Foundation
priority: TaskPriority?,
@_inheritActorContext operation: sending @escaping @isolated(any) () async -> Success
) -> Task<Success, Never>

/// Creates a task tied to the current actor context whose operation fails with a specific type of error.
/// - Parameters:
/// - priority: The priority of the task (optional).
/// - operation: An asynchronous operation to execute within the task. The operation
/// inherits the current actor context and is isolated to that actor.
/// - Returns: A ``TypedTask`` that keeps the type of the error of the operation.
@discardableResult
func typedTask<Success: Sendable, Failure: Error>(
priority: TaskPriority?,
@_inheritActorContext operation: sending @escaping @isolated(any) () async throws(Failure) -> Success
) -> TypedTask<Success, Failure>

/// Creates a detached task whose operation fails with a specific type of error.
/// - Parameters:
/// - priority: The priority of the task (optional).
/// - operation: An asynchronous operation to execute within the task. The operation
/// is isolated and does not inherit the current actor context.
/// - Returns: A ``TypedTask`` that keeps the type of the error of the operation.
@discardableResult
func typedDetached<Success: Sendable, Failure: Error>(
priority: TaskPriority?,
@_inheritActorContext operation: sending @escaping @isolated(any) () async throws(Failure) -> Success
) -> TypedTask<Success, Failure>
}

/// Default implementations for the `ITaskFactory` protocol.
Expand Down Expand Up @@ -109,5 +133,26 @@ import Foundation
) -> Task<Success, Never> {
detached(priority: nil, operation: operation)
}

/// Creates a task tied to the current actor context with a default priority whose operation
/// fails with a specific type of error.
/// - Parameter operation: An asynchronous operation to execute within the task.
/// - Returns: A ``TypedTask`` that keeps the type of the error of the operation.
@discardableResult
func typedTask<Success: Sendable, Failure: Error>(
@_inheritActorContext operation: sending @escaping @isolated(any) () async throws(Failure) -> Success
) -> TypedTask<Success, Failure> {
typedTask(priority: nil, operation: operation)
}

/// Creates a detached task with a default priority whose operation fails with a specific type of error.
/// - Parameter operation: An asynchronous operation to execute within the task.
/// - Returns: A ``TypedTask`` that keeps the type of the error of the operation.
@discardableResult
func typedDetached<Success: Sendable, Failure: Error>(
@_inheritActorContext operation: sending @escaping @isolated(any) () async throws(Failure) -> Success
) -> TypedTask<Success, Failure> {
typedDetached(priority: nil, operation: operation)
}
}
#endif
16 changes: 16 additions & 0 deletions Sources/Concurrency/TaskFactory/TaskFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,21 @@
) -> Task<Success, Never> {
Task.detached(priority: priority, operation: operation)
}

@discardableResult
public func typedTask<Success: Sendable, Failure: Error>(
priority: TaskPriority?,
@_inheritActorContext operation: sending @escaping @isolated(any) () async throws(Failure) -> Success
) -> TypedTask<Success, Failure> {
TypedTask(priority: priority, operation: operation)
}

@discardableResult
public func typedDetached<Success: Sendable, Failure: Error>(
priority: TaskPriority?,
@_inheritActorContext operation: sending @escaping @isolated(any) () async throws(Failure) -> Success
) -> TypedTask<Success, Failure> {
TypedTask.detached(priority: priority, operation: operation)
}
}
#endif
110 changes: 110 additions & 0 deletions Sources/Concurrency/TaskFactory/TypedTask.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//
// Concurrency
// Copyright © 2026 Space Code. All rights reserved.
//

#if swift(>=6.0)

// MARK: - TypedTask

/// A unit of asynchronous work that fails with a specific type of error.
///
/// The standard library only creates a `Task` that fails with `Never` or `any Error`, so a task whose operation
/// `throws(MyError)` loses its error type. `TypedTask` keeps it: awaiting ``value`` throws `Failure`, and
/// ``result`` is a `Result<Success, Failure>`.
///
/// ```swift
/// let task = TypedTask { () async throws(NetworkError) -> User in
/// try await client.loadUser()
/// }
///
/// do throws(NetworkError) {
/// let user = try await task.value
/// } catch {
/// // `error` is a `NetworkError`.
/// }
/// ```
///
/// Cancelling a `TypedTask` cancels the work like cancelling a `Task` does: it only sets a flag that the operation
/// reads, and the operation decides whether to fail.
public struct TypedTask<Success: Sendable, Failure: Error>: Sendable, Hashable {
// MARK: Properties

private let task: Task<Result<Success, Failure>, Never>

// MARK: Initialization

/// Runs the operation in a new task that inherits the actor context of the caller.
///
/// - Parameters:
/// - priority: The priority of the task. Pass `nil` to inherit the priority of the caller.
/// - operation: The operation to run. It can only fail with `Failure`.
public init(
priority: TaskPriority? = nil,
@_inheritActorContext operation: sending @escaping @isolated(any) () async throws(Failure) -> Success
) {
task = Task(priority: priority) {
await Self.run(operation)
}
}

private init(task: Task<Result<Success, Failure>, Never>) {
self.task = task
}

// MARK: Interface

/// The success value of the task, or the error it failed with.
///
/// Waiting for the value does not cancel the task when the waiting code is cancelled.
public var value: Success {
get async throws(Failure) {
try await task.value.get()
}
}

/// The outcome of the task, once it has finished.
public var result: Result<Success, Failure> {
get async {
await task.value
}
}

/// Whether the task was cancelled.
public var isCancelled: Bool {
task.isCancelled
}

/// Runs the operation in a new task that does not inherit the actor context of the caller.
///
/// - Parameters:
/// - priority: The priority of the task. Pass `nil` to use the default priority.
/// - operation: The operation to run. It can only fail with `Failure`.
/// - Returns: The task that runs the operation.
public static func detached(
priority: TaskPriority? = nil,
operation: sending @escaping @isolated(any) () async throws(Failure) -> Success
) -> TypedTask {
TypedTask(task: Task.detached(priority: priority) {
await run(operation)
})
}

/// Cancels the task.
public func cancel() {
task.cancel()
}

// MARK: Private

private static func run(
_ operation: @isolated(any) () async throws(Failure) -> Success
) async -> Result<Success, Failure> {
do throws(Failure) {
return try await .success(operation())
} catch {
return .failure(error)
}
}
}
#endif
11 changes: 11 additions & 0 deletions Sources/TestConcurrency/Task.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
// MARK: - ITask

#if swift(>=6.0)
import Concurrency

/// A protocol representing a task that can be awaited until its execution completes.
protocol ITask {
/// Waits for the `Task` to complete by retrieving its result.
Expand All @@ -20,4 +22,13 @@
_ = await result
}
}

// MARK: - TypedTask + ITask

/// Extends the `TypedTask` type to conform to the `ITask` protocol.
extension TypedTask: ITask {
func wait() async {
_ = await result
}
}
#endif
18 changes: 18 additions & 0 deletions Sources/TestConcurrency/TestTaskFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,23 @@ import Foundation
addTask(task)
return task
}

public func typedTask<Success: Sendable, Failure: Error>(
priority: TaskPriority?,
@_inheritActorContext operation: sending @escaping @isolated(any) () async throws(Failure) -> Success
) -> TypedTask<Success, Failure> {
let task = TypedTask(priority: priority, operation: operation)
addTask(task)
return task
}

public func typedDetached<Success: Sendable, Failure: Error>(
priority: TaskPriority?,
@_inheritActorContext operation: sending @escaping @isolated(any) () async throws(Failure) -> Success
) -> TypedTask<Success, Failure> {
let task = TypedTask.detached(priority: priority, operation: operation)
addTask(task)
return task
}
}
#endif
Loading
Loading