Skip to content

Commit a573fa2

Browse files
committed
feat(swift-concurrency): add swift concurrency target
1 parent 3b80ae8 commit a573fa2

31 files changed

Lines changed: 2378 additions & 25 deletions

Package.resolved

Lines changed: 28 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
// swift-tools-version:6.0
2-
// The swift-tools-version declares the minimum version of Swift required to build this package.
1+
// swift-tools-version:6.2
32

43
import PackageDescription
54

@@ -10,50 +9,71 @@ let package = Package(
109
],
1110
products: [
1211
.library(
13-
name: "FueledUtilsCore",
14-
targets: ["FueledUtilsCore"]
12+
name: "FueledCore",
13+
targets: ["FueledCore"]
1514
),
1615
.library(
17-
name: "FueledUtilsCombine",
18-
targets: ["FueledUtilsCombine"]
16+
name: "FueledCombine",
17+
targets: ["FueledCombine"]
1918
),
2019
.library(
21-
name: "FueledUtilsSwiftUI",
22-
targets: ["FueledUtilsSwiftUI"]
20+
name: "FueledSwiftUI",
21+
targets: ["FueledSwiftUI"]
22+
),
23+
.library(
24+
name: "FueledSwiftConcurrency",
25+
targets: ["FueledSwiftConcurrency"]
2326
),
2427
],
2528
dependencies: [
2629
.package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.4.5"),
30+
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.1"),
31+
.package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.3.2"),
2732
],
2833
targets: [
2934
.target(
30-
name: "FueledUtilsCore",
35+
name: "FueledCore",
3136
path: "Sources/FueledUtils/Core",
3237
linkerSettings: [
3338
.linkedFramework("Foundation")
3439
]
3540
),
3641
.target(
37-
name: "FueledUtilsCombine",
42+
name: "FueledCombine",
3843
dependencies: [
39-
"FueledUtilsCore"
44+
"FueledCore"
4045
],
4146
path: "Sources/FueledUtils/Combine"
4247
),
4348
.target(
44-
name: "FueledUtilsSwiftUI",
45-
dependencies: ["FueledUtilsCombine", "FueledUtilsCore"],
49+
name: "FueledSwiftUI",
50+
dependencies: ["FueledCombine", "FueledCore"],
4651
path: "Sources/FueledUtils/SwiftUI",
4752
linkerSettings: [
4853
.linkedFramework("SwiftUI", .when(platforms: [.iOS, .tvOS, .macOS])),
4954
]
5055
),
56+
.target(
57+
name: "FueledSwiftConcurrency",
58+
dependencies: [
59+
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
60+
.product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"),
61+
],
62+
path: "Sources/FueledUtils/SwiftConcurrency"
63+
),
5164
.testTarget(
52-
name: "FueledUtilsCombineTests",
65+
name: "FueledCombineTests",
5366
dependencies: [
54-
"FueledUtilsCombine",
67+
"FueledCombine",
5568
],
5669
path: "Tests/FueledUtils/CombineTests"
5770
),
71+
.testTarget(
72+
name: "FueledSwiftConcurrencyTests",
73+
dependencies: [
74+
"FueledSwiftConcurrency"
75+
],
76+
path: "Tests/FueledUtils/SwiftConcurrencyTests"
77+
),
5878
]
5979
)

Sources/FueledUtils/Combine/Extensions/ObservableObject+Extensions.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import Combine
1616
import Foundation
17-
import FueledUtilsCore
17+
import FueledCore
1818

1919
extension ObservableObject where Self.ObjectWillChangePublisher == ObservableObjectPublisher {
2020
// Perform a one-way link, where the receiver will listen for changes on the object and automatically trigger its `objectWillChange` publisher

Sources/FueledUtils/Combine/Extensions/Publisher+Extensions.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
// limitations under the License.
1414

1515
import Combine
16-
import FueledUtilsCore
16+
import FueledCore
1717

1818
// MARK: - Helpers Functions
1919
public extension Publisher {

Sources/FueledUtils/Combine/Operators/Combine+Operators.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
// limitations under the License.
1414

1515
import Combine
16-
import FueledUtilsCore
16+
import FueledCore
1717

18-
public typealias OptionalProtocol = FueledUtilsCore.OptionalProtocol
18+
public typealias OptionalProtocol = FueledCore.OptionalProtocol
1919

2020
// swiftlint:disable generic_type_name
2121

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/// A concurrency-safe asynchronous cache for storing key-value pairs.
2+
///
3+
/// `AsyncCache` provides thread-safe caching with support for async value providers.
4+
/// Values are computed lazily on first access and cached for subsequent retrievals.
5+
///
6+
/// Example usage:
7+
/// ```swift
8+
/// let cache = AsyncCache<String, Data>()
9+
/// let data = try await cache.getOrAdd(key: "user-123") { key in
10+
/// try await fetchUserData(id: key)
11+
/// }
12+
/// ```
13+
public actor AsyncCache<Key: Sendable & Hashable, Value: Sendable> {
14+
private var cachedValues: [Key: Value] = [:]
15+
16+
/// Creates an empty cache.
17+
public init() {
18+
}
19+
20+
/// Retrieves a value from the cache if available, or computes and caches it using the provided async provider.
21+
///
22+
/// - Parameters:
23+
/// - key: The key to look up or associate with a new value.
24+
/// - provider: An asynchronous closure that computes the value if not already cached.
25+
/// - Returns: The cached or newly computed value.
26+
/// - Throws: Rethrows any error thrown by the `provider` closure.
27+
public func getOrAdd(
28+
key: Key,
29+
provider: @escaping @Sendable (Key) async throws -> Value
30+
) async throws -> Value {
31+
if let cachedValue = cachedValues[key] {
32+
return cachedValue
33+
}
34+
35+
let value = try await provider(key)
36+
cachedValues[key] = value
37+
return value
38+
}
39+
40+
/// Removes all cached key-value pairs.
41+
public func clear() {
42+
cachedValues.removeAll()
43+
}
44+
45+
/// Removes a specific key-value pair from the cache.
46+
///
47+
/// - Parameter key: The key to remove.
48+
/// - Returns: The removed value, or `nil` if the key was not present.
49+
@discardableResult
50+
public func remove(key: Key) -> Value? {
51+
cachedValues.removeValue(forKey: key)
52+
}
53+
54+
/// Returns the number of cached items.
55+
public var count: Int {
56+
cachedValues.count
57+
}
58+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import Foundation
2+
3+
/// An async-compatible counting semaphore that controls access to a resource across multiple execution contexts.
4+
///
5+
/// Use `AsyncSemaphore` when you need to limit concurrent access to a shared resource in async/await code.
6+
/// Unlike `DispatchSemaphore`, this semaphore suspends tasks without blocking the underlying thread.
7+
///
8+
/// Example usage:
9+
/// ```swift
10+
/// let semaphore = AsyncSemaphore(value: 3) // Allow 3 concurrent accesses
11+
///
12+
/// await semaphore.wait()
13+
/// defer { semaphore.signal() }
14+
/// // Access shared resource
15+
/// ```
16+
///
17+
/// ## Topics
18+
///
19+
/// ### Creating a Semaphore
20+
///
21+
/// - ``init(value:)``
22+
///
23+
/// ### Signaling the Semaphore
24+
///
25+
/// - ``signal()``
26+
///
27+
/// ### Waiting for the Semaphore
28+
///
29+
/// - ``wait()``
30+
/// - ``waitUnlessCancelled()``
31+
public final class AsyncSemaphore: @unchecked Sendable {
32+
private class Suspension: @unchecked Sendable {
33+
enum State {
34+
case pending
35+
case suspendedUnlessCancelled(UnsafeContinuation<Void, Error>)
36+
case suspended(UnsafeContinuation<Void, Never>)
37+
case cancelled
38+
}
39+
40+
var state: State
41+
42+
init(state: State) {
43+
self.state = state
44+
}
45+
}
46+
47+
private var value: Int
48+
private var suspensions: [Suspension] = []
49+
private let _lock = NSRecursiveLock()
50+
51+
/// Creates a semaphore with the specified initial value.
52+
///
53+
/// - Parameter value: The starting value for the semaphore. Must be greater than or equal to zero.
54+
/// - Precondition: `value` must be >= 0.
55+
public init(value: Int) {
56+
precondition(value >= 0, "AsyncSemaphore requires a value equal or greater than zero")
57+
self.value = value
58+
}
59+
60+
deinit {
61+
precondition(suspensions.isEmpty, "AsyncSemaphore is deallocated while some task(s) are suspended waiting for a signal.")
62+
}
63+
64+
private func lock() { _lock.lock() }
65+
private func unlock() { _lock.unlock() }
66+
67+
/// Waits for, or decrements, the semaphore.
68+
///
69+
/// Decrements the semaphore count. If the resulting value is less than zero,
70+
/// this method suspends the current task until ``signal()`` is called.
71+
/// This suspension does not block the underlying thread.
72+
public func wait() async {
73+
lock()
74+
75+
value -= 1
76+
if value >= 0 {
77+
unlock()
78+
return
79+
}
80+
81+
await withUnsafeContinuation { continuation in
82+
let suspension = Suspension(state: .suspended(continuation))
83+
suspensions.insert(suspension, at: 0)
84+
unlock()
85+
}
86+
}
87+
88+
/// Waits for, or decrements, the semaphore with cancellation support.
89+
///
90+
/// Decrements the semaphore count. If the resulting value is less than zero,
91+
/// this method suspends the current task until ``signal()`` is called.
92+
///
93+
/// - Throws: `CancellationError` if the task is cancelled before a signal is received.
94+
public func waitUnlessCancelled() async throws {
95+
lock()
96+
97+
value -= 1
98+
if value >= 0 {
99+
defer { unlock() }
100+
101+
do {
102+
try Task.checkCancellation()
103+
} catch {
104+
value += 1
105+
throw error
106+
}
107+
108+
return
109+
}
110+
111+
let suspension = Suspension(state: .pending)
112+
113+
try await withTaskCancellationHandler {
114+
try await withUnsafeThrowingContinuation { (continuation: UnsafeContinuation<Void, Error>) in
115+
if case .cancelled = suspension.state {
116+
unlock()
117+
continuation.resume(throwing: CancellationError())
118+
} else {
119+
suspension.state = .suspendedUnlessCancelled(continuation)
120+
suspensions.insert(suspension, at: 0)
121+
unlock()
122+
}
123+
}
124+
} onCancel: {
125+
lock()
126+
127+
value += 1
128+
if let index = suspensions.firstIndex(where: { $0 === suspension }) {
129+
suspensions.remove(at: index)
130+
}
131+
132+
if case let .suspendedUnlessCancelled(continuation) = suspension.state {
133+
unlock()
134+
continuation.resume(throwing: CancellationError())
135+
} else {
136+
suspension.state = .cancelled
137+
unlock()
138+
}
139+
}
140+
}
141+
142+
/// Signals (increments) the semaphore.
143+
///
144+
/// Increments the semaphore count. If there are tasks suspended in ``wait()``
145+
/// or ``waitUnlessCancelled()``, one of them will be resumed.
146+
///
147+
/// - Returns: `true` if a suspended task was resumed, `false` otherwise.
148+
@discardableResult
149+
public func signal() -> Bool {
150+
lock()
151+
152+
value += 1
153+
154+
switch suspensions.popLast()?.state {
155+
case let .suspendedUnlessCancelled(continuation):
156+
unlock()
157+
continuation.resume()
158+
return true
159+
case let .suspended(continuation):
160+
unlock()
161+
continuation.resume()
162+
return true
163+
default:
164+
unlock()
165+
return false
166+
}
167+
}
168+
}

0 commit comments

Comments
 (0)