Skip to content

Commit da6e07c

Browse files
authored
Cancellation cleanup (pointfreeco#1977)
* Clean up effect cancellation. * wip * wip * wip * wip * wip * wip * wip * cancel id test * nb * benchmark * wip * wip * wip * wip * wip * clean up * wip
1 parent d73a349 commit da6e07c

38 files changed

+311
-127
lines changed

ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved

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

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ let package = Package(
2525
.package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "0.9.1"),
2626
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "0.2.0"),
2727
.package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "0.7.0"),
28-
.package(url: "https://github.com/pointfreeco/swiftui-navigation", from: "0.7.0"),
28+
.package(url: "https://github.com/pointfreeco/swiftui-navigation", from: "0.7.1"),
2929
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.8.4"),
3030
],
3131
targets: [

Sources/ComposableArchitecture/Effects/Cancellation.swift

Lines changed: 59 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -45,36 +45,30 @@ extension EffectPublisher {
4545
_cancellablesLock.lock()
4646
defer { _cancellablesLock.unlock() }
4747

48-
let id = _CancelToken(id: id)
4948
if cancelInFlight {
50-
_cancellationCancellables[id]?.forEach { $0.cancel() }
49+
_cancellationCancellables.cancel(id: id)
5150
}
5251

5352
let cancellationSubject = PassthroughSubject<Void, Never>()
5453

55-
var cancellationCancellable: AnyCancellable!
56-
cancellationCancellable = AnyCancellable {
54+
var cancellable: AnyCancellable!
55+
cancellable = AnyCancellable {
5756
_cancellablesLock.sync {
5857
cancellationSubject.send(())
5958
cancellationSubject.send(completion: .finished)
60-
_cancellationCancellables[id]?.remove(cancellationCancellable)
61-
if _cancellationCancellables[id]?.isEmpty == .some(true) {
62-
_cancellationCancellables[id] = nil
63-
}
59+
_cancellationCancellables.remove(cancellable, at: id)
6460
}
6561
}
6662

6763
return publisher.prefix(untilOutputFrom: cancellationSubject)
6864
.handleEvents(
6965
receiveSubscription: { _ in
70-
_ = _cancellablesLock.sync {
71-
_cancellationCancellables[id, default: []].insert(
72-
cancellationCancellable
73-
)
66+
_cancellablesLock.sync {
67+
_cancellationCancellables.insert(cancellable, at: id)
7468
}
7569
},
76-
receiveCompletion: { _ in cancellationCancellable.cancel() },
77-
receiveCancel: cancellationCancellable.cancel
70+
receiveCompletion: { _ in cancellable.cancel() },
71+
receiveCancel: cancellable.cancel
7872
)
7973
}
8074
.eraseToAnyPublisher()
@@ -113,7 +107,7 @@ extension EffectPublisher {
113107
public static func cancel(id: AnyHashable) -> Self {
114108
.fireAndForget {
115109
_cancellablesLock.sync {
116-
_cancellationCancellables[.init(id: id)]?.forEach { $0.cancel() }
110+
_cancellationCancellables.cancel(id: id)
117111
}
118112
}
119113
}
@@ -201,22 +195,18 @@ extension EffectPublisher {
201195
cancelInFlight: Bool = false,
202196
operation: @Sendable @escaping () async throws -> T
203197
) async rethrows -> T {
204-
let id = _CancelToken(id: id)
205198
let (cancellable, task) = _cancellablesLock.sync { () -> (AnyCancellable, Task<T, Error>) in
206199
if cancelInFlight {
207-
_cancellationCancellables[id]?.forEach { $0.cancel() }
200+
_cancellationCancellables.cancel(id: id)
208201
}
209202
let task = Task { try await operation() }
210203
let cancellable = AnyCancellable { task.cancel() }
211-
_cancellationCancellables[id, default: []].insert(cancellable)
204+
_cancellationCancellables.insert(cancellable, at: id)
212205
return (cancellable, task)
213206
}
214207
defer {
215208
_cancellablesLock.sync {
216-
_cancellationCancellables[id]?.remove(cancellable)
217-
if _cancellationCancellables[id]?.isEmpty == .some(true) {
218-
_cancellationCancellables[id] = nil
219-
}
209+
_cancellationCancellables.remove(cancellable, at: id)
220210
}
221211
}
222212
do {
@@ -231,22 +221,18 @@ extension EffectPublisher {
231221
cancelInFlight: Bool = false,
232222
operation: @Sendable @escaping () async throws -> T
233223
) async rethrows -> T {
234-
let id = _CancelToken(id: id)
235224
let (cancellable, task) = _cancellablesLock.sync { () -> (AnyCancellable, Task<T, Error>) in
236225
if cancelInFlight {
237-
_cancellationCancellables[id]?.forEach { $0.cancel() }
226+
_cancellationCancellables.cancel(id: id)
238227
}
239228
let task = Task { try await operation() }
240229
let cancellable = AnyCancellable { task.cancel() }
241-
_cancellationCancellables[id, default: []].insert(cancellable)
230+
_cancellationCancellables.insert(cancellable, at: id)
242231
return (cancellable, task)
243232
}
244233
defer {
245234
_cancellablesLock.sync {
246-
_cancellationCancellables[id]?.remove(cancellable)
247-
if _cancellationCancellables[id]?.isEmpty == .some(true) {
248-
_cancellationCancellables[id] = nil
249-
}
235+
_cancellationCancellables.remove(cancellable, at: id)
250236
}
251237
}
252238
do {
@@ -301,7 +287,9 @@ extension Task where Success == Never, Failure == Never {
301287
///
302288
/// - Parameter id: An identifier.
303289
public static func cancel<ID: Hashable & Sendable>(id: ID) {
304-
_cancellablesLock.sync { _cancellationCancellables[.init(id: id)]?.forEach { $0.cancel() } }
290+
_cancellablesLock.sync {
291+
_cancellationCancellables.cancel(id: id)
292+
}
305293
}
306294

307295
/// Cancel any currently in-flight operation with the given identifier.
@@ -315,7 +303,7 @@ extension Task where Success == Never, Failure == Never {
315303
}
316304
}
317305

318-
@_spi(Internals) public struct _CancelToken: Hashable {
306+
@_spi(Internals) public struct _CancelID: Hashable {
319307
let id: AnyHashable
320308
let discriminator: ObjectIdentifier
321309

@@ -325,8 +313,8 @@ extension Task where Success == Never, Failure == Never {
325313
}
326314
}
327315

328-
@_spi(Internals) public var _cancellationCancellables: [_CancelToken: Set<AnyCancellable>] = [:]
329-
@_spi(Internals) public let _cancellablesLock = NSRecursiveLock()
316+
@_spi(Internals) public var _cancellationCancellables = CancellablesCollection()
317+
private let _cancellablesLock = NSRecursiveLock()
330318

331319
@rethrows
332320
private protocol _ErrorMechanism {
@@ -346,3 +334,41 @@ extension _ErrorMechanism {
346334
}
347335

348336
extension Result: _ErrorMechanism {}
337+
338+
@_spi(Internals)
339+
public class CancellablesCollection {
340+
var storage: [_CancelID: Set<AnyCancellable>] = [:]
341+
342+
func insert(
343+
_ cancellable: AnyCancellable,
344+
at id: AnyHashable
345+
) {
346+
let cancelID = _CancelID(id: id)
347+
self.storage[cancelID, default: []].insert(cancellable)
348+
}
349+
350+
func remove(
351+
_ cancellable: AnyCancellable,
352+
at id: AnyHashable
353+
) {
354+
let cancelID = _CancelID(id: id)
355+
self.storage[cancelID]?.remove(cancellable)
356+
if self.storage[cancelID]?.isEmpty == true {
357+
self.storage[cancelID] = nil
358+
}
359+
}
360+
361+
func cancel(id: AnyHashable) {
362+
let cancelID = _CancelID(id: id)
363+
self.storage[cancelID]?.forEach { $0.cancel() }
364+
self.storage[cancelID] = nil
365+
}
366+
367+
public func exists(at id: AnyHashable) -> Bool {
368+
return self.storage[_CancelID(id: id)] != nil
369+
}
370+
371+
public var count: Int {
372+
return self.storage.count
373+
}
374+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import Benchmark
2+
import Combine
3+
@_spi(Internals) import ComposableArchitecture
4+
import Foundation
5+
6+
let storeSuite = BenchmarkSuite(name: "Store") {
7+
var store: StoreOf<Feature>!
8+
let levels = 5
9+
10+
for level in 1...levels {
11+
$0.benchmark("Nested send tap: \(level)") {
12+
_ = store.send(tap(level: level))
13+
} setUp: {
14+
store = Store(
15+
initialState: state(level: level),
16+
reducer: Feature()
17+
)
18+
} tearDown: {
19+
precondition(count(of: store.state.value, level: level) == 1)
20+
precondition(_cancellationCancellables.count == 0)
21+
}
22+
}
23+
for level in 1...levels {
24+
$0.benchmark("Nested send none: \(level)") {
25+
_ = store.send(none(level: level))
26+
} setUp: {
27+
store = Store(
28+
initialState: state(level: level),
29+
reducer: Feature()
30+
)
31+
} tearDown: {
32+
precondition(count(of: store.state.value, level: level) == 0)
33+
precondition(_cancellationCancellables.count == 0)
34+
}
35+
}
36+
}
37+
38+
private struct Feature: ReducerProtocol {
39+
struct State {
40+
@Box var child: State?
41+
var count = 0
42+
}
43+
enum Action {
44+
indirect case child(Action)
45+
case tap
46+
case none
47+
}
48+
var body: some ReducerProtocolOf<Self> {
49+
Reduce<State, Action> { state, action in
50+
switch action {
51+
case .child:
52+
return .none
53+
case .tap:
54+
state.count = 1
55+
return Empty(completeImmediately: true)
56+
.eraseToEffect()
57+
.cancellable(id: UUID())
58+
case .none:
59+
return .none
60+
}
61+
}
62+
.ifLet(\.child, action: /Action.child) {
63+
Feature()
64+
}
65+
}
66+
}
67+
68+
@propertyWrapper
69+
private struct Box<Value> {
70+
private var value: [Value]
71+
var wrappedValue: Value? {
72+
get { self.value.first }
73+
set { self.value = newValue.map { [$0] } ?? [] }
74+
}
75+
init(wrappedValue: Value?) {
76+
self.value = wrappedValue.map { [$0] } ?? []
77+
}
78+
}
79+
80+
private func state(level: Int) -> Feature.State {
81+
Feature.State(
82+
child: level == 0
83+
? nil
84+
: state(level: level - 1)
85+
)
86+
}
87+
private func tap(level: Int) -> Feature.Action {
88+
level == 0
89+
? .tap
90+
: Feature.Action.child(tap(level: level - 1))
91+
}
92+
private func none(level: Int) -> Feature.Action {
93+
level == 0
94+
? .none
95+
: Feature.Action.child(none(level: level - 1))
96+
}
97+
private func count(of state: Feature.State?, level: Int) -> Int? {
98+
level == 0
99+
? state?.count
100+
: count(of: state?.child, level: level - 1)
101+
}

Sources/swift-composable-architecture-benchmark/main.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ Benchmark.main([
66
dependenciesSuite,
77
effectSuite,
88
storeScopeSuite,
9+
storeSuite,
910
viewStoreSuite,
1011
])

Tests/ComposableArchitectureTests/BindingLocalTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
@testable import ComposableArchitecture
55

66
@MainActor
7-
final class BindingLocalTests: XCTestCase {
7+
final class BindingLocalTests: BaseTCATestCase {
88
public func testBindingLocalIsActive() {
99
XCTAssertFalse(BindingLocal.isActive)
1010

Tests/ComposableArchitectureTests/BindingTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import ComposableArchitecture
22
import XCTest
33

44
@MainActor
5-
final class BindingTests: XCTestCase {
5+
final class BindingTests: BaseTCATestCase {
66
#if swift(>=5.7)
77
func testNestedBindingState() {
88
struct BindingTest: ReducerProtocol {

Tests/ComposableArchitectureTests/CompatibilityTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import ComposableArchitecture
33
import XCTest
44

55
@MainActor
6-
final class CompatibilityTests: XCTestCase {
6+
final class CompatibilityTests: BaseTCATestCase {
77
var cancellables: Set<AnyCancellable> = []
88

99
// Actions can be re-entrantly sent into the store if an action is sent that holds an object

Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import ComposableArchitecture
44
import XCTest
55

66
@MainActor
7-
final class ComposableArchitectureTests: XCTestCase {
7+
final class ComposableArchitectureTests: BaseTCATestCase {
88
var cancellables: Set<AnyCancellable> = []
99

1010
func testScheduling() async {

0 commit comments

Comments
 (0)