Skip to content

Commit bf24a8c

Browse files
authored
Avoid simultaneous access crash on double-cancel (#137)
* Avoid simultaneous access crash on double-cancel * fixes * fix
1 parent 85a8b83 commit bf24a8c

File tree

3 files changed

+40
-24
lines changed

3 files changed

+40
-24
lines changed

Sources/ComposableArchitecture/Effects/Cancellation.swift

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,40 +29,34 @@ extension Effect {
2929
public func cancellable(id: AnyHashable, cancelInFlight: Bool = false) -> Effect {
3030
return Deferred { () -> Publishers.HandleEvents<PassthroughSubject<Output, Failure>> in
3131
let subject = PassthroughSubject<Output, Failure>()
32-
let uuid = UUID()
33-
34-
var isCleaningUp = false
3532

3633
cancellablesLock.sync {
3734
if cancelInFlight {
38-
cancellationCancellables[id]?.forEach { _, cancellable in cancellable.cancel() }
35+
cancellationCancellables[id]?.forEach { cancellable in cancellable.cancel() }
3936
cancellationCancellables[id] = nil
4037
}
4138

4239
let cancellable = self.subscribe(subject)
4340

44-
cancellationCancellables[id] = cancellationCancellables[id] ?? [:]
45-
cancellationCancellables[id]?[uuid] = AnyCancellable {
41+
AnyCancellable {
4642
cancellable.cancel()
47-
if !isCleaningUp {
48-
subject.send(completion: .finished)
49-
}
43+
subject.send(completion: .finished)
5044
}
45+
.store(in: &cancellationCancellables[id, default: []])
5146
}
5247

53-
func cleanup() {
54-
isCleaningUp = true
48+
func cleanUp() {
5549
cancellablesLock.sync {
56-
cancellationCancellables[id]?[uuid] = nil
57-
if cancellationCancellables[id]?.isEmpty == true {
58-
cancellationCancellables[id] = nil
59-
}
50+
guard !isCancelling.contains(id) else { return }
51+
isCancelling.insert(id)
52+
defer { isCancelling.remove(id) }
53+
cancellationCancellables[id] = nil
6054
}
6155
}
6256

6357
return subject.handleEvents(
64-
receiveCompletion: { _ in cleanup() },
65-
receiveCancel: cleanup
58+
receiveCompletion: { _ in cleanUp() },
59+
receiveCancel: cleanUp
6660
)
6761
}
6862
.eraseToEffect()
@@ -76,12 +70,13 @@ extension Effect {
7670
public static func cancel(id: AnyHashable) -> Effect {
7771
.fireAndForget {
7872
cancellablesLock.sync {
79-
cancellationCancellables[id]?.forEach { _, cancellable in cancellable.cancel() }
73+
cancellationCancellables[id]?.forEach { cancellable in cancellable.cancel() }
8074
cancellationCancellables[id] = nil
8175
}
8276
}
8377
}
8478
}
8579

86-
var cancellationCancellables: [AnyHashable: [UUID: AnyCancellable]] = [:]
80+
var cancellationCancellables: [AnyHashable: Set<AnyCancellable>] = [:]
8781
let cancellablesLock = NSRecursiveLock()
82+
var isCancelling: Set<AnyHashable> = []

Sources/ComposableArchitecture/Internal/Locking.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ extension UnsafeMutablePointer where Pointee == os_unfair_lock_s {
1111

1212
extension NSRecursiveLock {
1313
@inlinable
14-
func sync(work: () -> Void) {
14+
func sync<R>(work: () -> R) -> R {
1515
self.lock()
1616
defer { self.unlock() }
17-
work()
17+
return work()
1818
}
1919
}

Tests/ComposableArchitectureTests/EffectCancellationTests.swift

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,32 @@ final class EffectCancellationTests: XCTestCase {
229229

230230
XCTAssertTrue(cancellationCancellables.isEmpty)
231231
}
232+
233+
func testNestedCancels() {
234+
var effect = Empty<Void, Never>(completeImmediately: false)
235+
.eraseToEffect()
236+
.cancellable(id: 1)
237+
238+
for _ in 1 ... .random(in: 1...1_000) {
239+
effect = effect.cancellable(id: 1)
240+
}
241+
242+
effect
243+
.sink(receiveValue: { _ in })
244+
.store(in: &cancellables)
245+
246+
cancellables.removeAll()
247+
248+
XCTAssertEqual([:], cancellationCancellables)
249+
XCTAssertEqual([], isCancelling)
250+
}
232251
}
233252

234253
func resetCancellables() {
235-
for (id, _) in cancellationCancellables {
236-
cancellationCancellables[id] = [:]
254+
cancellablesLock.sync {
255+
for (id, _) in cancellationCancellables {
256+
cancellationCancellables[id] = []
257+
}
258+
cancellationCancellables = [:]
237259
}
238-
cancellationCancellables = [:]
239260
}

0 commit comments

Comments
 (0)