Skip to content

Commit bb5c227

Browse files
IosifSuzukiBogdan PetkanychMaks-Jago
authored
Fix crash when executing/canceling effect inside BaseMiddleware (#108)
* Add synchronization mechanism for cancellation management in _BaseMiddleware Add test case for concurrent cancellations for _BaseMiddleware * Remove print code * Add clear explanation to the state property * Rename method from deleteCancellation to removeCancellation Remove usage GCD from swift concurrency code * Update _BaseMiddleware.swift --------- Co-authored-by: Bogdan Petkanych <bogdanpetkanych@gmail.com> Co-authored-by: Max Kuznetsov <mx.kuznetsov.dev@gmail.com>
1 parent c549270 commit bb5c227

3 files changed

Lines changed: 111 additions & 14 deletions

File tree

Tests/SwiftUI-UDF-ConcurrencyTests/Middlewares/ConcurrencyMiddlewareCancellationTests.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,30 @@ import Foundation
6262
success = await waitForCondition { await store.state.middlewareFlow == .didCancel }
6363
#expect(success)
6464
}
65+
66+
@Test func testMiddlewareCancellationDataRace() async {
67+
let store = await TestStore(initial: AppState())
68+
await store.subscribe(ObservableMiddlewareToCancel.self, environment: ObservableMiddlewareToCancel.Environment(loadItems: { [] }))
69+
70+
await store.dispatch(Actions.Loading())
71+
var success = await waitForCondition { await store.state.middlewareFlow == .loading }
72+
#expect(success)
73+
74+
await withTaskGroup(of: Void.self) { group in
75+
for _ in 0..<10 {
76+
group.addTask {
77+
await store.dispatch(Actions.CancelLoading())
78+
}
79+
}
80+
}
81+
82+
let acceptableFlowState: [MiddlewareFlow] = [.cancel, .didCancel]
83+
84+
success = await waitForCondition { acceptableFlowState.contains(await store.state.middlewareFlow) }
85+
#expect(success)
86+
87+
await store.wait()
88+
}
6589
}
6690

6791
private extension Actions {

Tests/SwiftUI-UDF-Tests/Middlewares/MiddlewareCancellationTests.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,27 @@ import Foundation
100100
success = await waitForCondition { await store.state.middlewareFlow == .none }
101101
#expect(success)
102102
}
103+
104+
@Test func testMiddlewareCancellationDataRace() async {
105+
let store = await TestStore(initial: AppState())
106+
await store.subscribe(ObservableMiddlewareToCancel.self, environment: ())
107+
108+
await store.dispatch(Actions.Loading())
109+
var success = await waitForCondition { await store.state.middlewareFlow == .loading }
110+
#expect(success)
111+
await withTaskGroup(of: Void.self) { group in
112+
for _ in 0..<10 {
113+
group.addTask {
114+
await store.dispatch(Actions.CancelLoading())
115+
}
116+
}
117+
}
118+
let acceptableFlowState: [MiddlewareFlow] = [.none, .cancel]
119+
success = await waitForCondition { acceptableFlowState.contains(await store.state.middlewareFlow) }
120+
#expect(success)
121+
122+
await store.wait()
123+
}
103124
}
104125

105126
private extension Actions {

UDF/_Middleware/_BaseMiddleware.swift

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import Combine
1313
import Foundation
14+
import os
1415

1516
/// `_BaseMiddleware` is an open class that serves as the base for creating middleware components
1617
/// in the UDF architecture. Middleware is responsible for handling side effects and can process actions
@@ -63,7 +64,17 @@ open class _BaseMiddleware<State: AppReducer>: _Middleware, @unchecked Sendable
6364
public typealias ErrorMapper<Id> = @Sendable (_ id: Id, _ error: Error) -> any Action
6465

6566
/// A dictionary to track ongoing tasks by their unique identifiers, allowing for cancellation.
66-
public var cancellations: [AnyHashable: CancellableTask] = [:]
67+
public var cancellations: [AnyHashable: CancellableTask] {
68+
cancellationsBox.withLockUnchecked { box in
69+
box.cancellations
70+
}
71+
}
72+
73+
/// Synchronizes access to the middleware's mutable state.
74+
///
75+
/// This property ensures that operations on the internal data such as reading/writing—are
76+
/// atomic across different physical threads, preventing data races and memory corruption.
77+
private let cancellationsBox = OSAllocatedUnfairLock(initialState: CancellationsBox())
6778

6879
// MARK: - Cancellation
6980

@@ -80,7 +91,9 @@ open class _BaseMiddleware<State: AppReducer>: _Middleware, @unchecked Sendable
8091
}
8192

8293
cancellableTask.cancel()
83-
cancellations[anyId] = nil
94+
cancellationsBox.withLockUnchecked { box in
95+
box.removeCancellation(forKey: anyId)
96+
}
8497
return true
8598
}
8699

@@ -140,17 +153,21 @@ open class _BaseMiddleware<State: AppReducer>: _Middleware, @unchecked Sendable
140153
let testGroupKey = TestGroup.enter(for: store)
141154

142155
// Subscribe to the effect and store the cancellation token
143-
cancellations[anyId] = effect
156+
let cancellable = effect
144157
.subscribe(on: queue)
145158
.receive(on: queue)
146159
.handleEvents(receiveCancel: { [weak self] in
147160
// Handle cancellation: Remove the task from cancellations and dispatch cancellation action
148-
self?.cancellations[anyId] = nil
161+
self?.cancellationsBox.withLockUnchecked { box in
162+
box.removeCancellation(forKey: anyId)
163+
}
149164
self?.dispatch(action: mapAction(Actions.DidCancelEffect(by: cancellation)), filePosition: filePosition)
150165
})
151166
.sink(receiveCompletion: { [weak self] _ in
152167
// Handle completion: Remove the task from cancellations and signal Testing
153-
self?.cancellations[anyId] = nil
168+
self?.cancellationsBox.withLockUnchecked { box in
169+
box.removeCancellation(forKey: anyId)
170+
}
154171
}, receiveValue: { [weak self] action in
155172
// Handle receiving a value: Dispatch the action to the store
156173
if self?.cancellations[anyId] != nil {
@@ -159,6 +176,9 @@ open class _BaseMiddleware<State: AppReducer>: _Middleware, @unchecked Sendable
159176
TestGroup.instanceFor(key: testGroupKey).leave()
160177
}
161178
})
179+
cancellationsBox.withLockUnchecked { state in
180+
state.set(cancellable: cancellable, forKey: anyId)
181+
}
162182
}
163183

164184
/// Executes an effect that conforms to both `PureEffect` and `ErasableToEffect` and dispatches actions to the store.
@@ -240,12 +260,14 @@ open class _BaseMiddleware<State: AppReducer>: _Middleware, @unchecked Sendable
240260
let testGroupKey = TestGroup.instanceKey(store)
241261

242262
// Subscribe to the effect and store the cancellation token
243-
cancellations[anyId] = effect
263+
let cancellable = effect
244264
.subscribe(on: queue)
245265
.receive(on: queue)
246266
.handleEvents(receiveCancel: { [weak self] in
247267
// Handle cancellation: Remove the task from cancellations and dispatch cancellation action
248-
self?.cancellations[anyId] = nil
268+
self?.cancellationsBox.withLockUnchecked { box in
269+
box.removeCancellation(forKey: anyId)
270+
}
249271
self?.dispatch(action: mapAction(Actions.DidCancelEffect(by: cancellation)), filePosition: filePosition)
250272
})
251273
.flatMap { [weak self] action in
@@ -263,7 +285,9 @@ open class _BaseMiddleware<State: AppReducer>: _Middleware, @unchecked Sendable
263285
}
264286
.sink(receiveCompletion: { [weak self] _ in
265287
// Handle completion: Remove the task from cancellations
266-
self?.cancellations[anyId] = nil
288+
self?.cancellationsBox.withLockUnchecked { box in
289+
box.removeCancellation(forKey: anyId)
290+
}
267291
TestGroup.instanceFor(key: testGroupKey).leave()
268292

269293
}, receiveValue: { [weak self] result in
@@ -272,6 +296,9 @@ open class _BaseMiddleware<State: AppReducer>: _Middleware, @unchecked Sendable
272296
self?.dispatch(action: mapAction(result.action), filePosition: filePosition)
273297
}
274298
})
299+
cancellationsBox.withLockUnchecked { box in
300+
box.set(cancellable: cancellable, forKey: anyId)
301+
}
275302
}
276303

277304
/// Runs a `PureEffect` and dispatches its actions to the store.
@@ -314,17 +341,21 @@ open class _BaseMiddleware<State: AppReducer>: _Middleware, @unchecked Sendable
314341
let testGroupKey = TestGroup.instanceKey(store)
315342

316343
// Subscribe to the effect and store the cancellation token
317-
cancellations[anyId] = effect
344+
let cancellable = effect
318345
.subscribe(on: queue) // Subscribe to the effect on the specified queue
319346
.receive(on: queue) // Specify the queue on which to receive events
320347
.handleEvents(receiveCancel: { [weak self] in
321348
// Handle cancellation: Remove the task from cancellations and dispatch cancellation action
322-
self?.cancellations[anyId] = nil
349+
self?.cancellationsBox.withLockUnchecked { box in
350+
box.removeCancellation(forKey: anyId)
351+
}
323352
self?.dispatch(action: mapAction(Actions.DidCancelEffect(by: cancellation)), filePosition: filePosition )
324353
})
325354
.sink(receiveCompletion: { [weak self] _ in
326355
// Handle completion: Remove the task from cancellations
327-
self?.cancellations[anyId] = nil
356+
self?.cancellationsBox.withLockUnchecked { box in
357+
box.removeCancellation(forKey: anyId)
358+
}
328359
TestGroup.instanceFor(key: testGroupKey).leave()
329360

330361
}, receiveValue: { [weak self] action in
@@ -334,6 +365,9 @@ open class _BaseMiddleware<State: AppReducer>: _Middleware, @unchecked Sendable
334365
self?.dispatch(action: mapAction(action), filePosition: filePosition)
335366
}
336367
})
368+
cancellationsBox.withLockUnchecked { box in
369+
box.set(cancellable: cancellable, forKey: anyId)
370+
}
337371
}
338372

339373
// MARK: - Concurrency
@@ -464,13 +498,31 @@ open class _BaseMiddleware<State: AppReducer>: _Middleware, @unchecked Sendable
464498
}
465499

466500
// Remove the task from the cancellations dictionary
467-
_ = self?.queue.sync { [weak self] in
468-
self?.cancellations.removeValue(forKey: anyCancellationId)
501+
self?.cancellationsBox.withLockUnchecked { box in
502+
box.removeCancellation(forKey: anyCancellationId)
469503
}
470504
}
471505

472506
// Store the task in the cancellations dictionary for future cancellation
473-
cancellations[anyCancellationId] = task
507+
cancellationsBox.withLockUnchecked { box in
508+
box.set(cancellable: task, forKey: anyCancellationId)
509+
}
510+
}
511+
512+
/// A container for the middleware's mutable state, designed to be managed by a synchronization mechanism
513+
///
514+
/// This class enables the safe retrieval and cancellation of tasks across different threads,
515+
/// ensuring that internal storage is modified only through the established lock.
516+
private class CancellationsBox: @unchecked Sendable {
517+
var cancellations: [AnyHashable: CancellableTask] = [:]
518+
519+
func set(cancellable: CancellableTask, forKey key: AnyHashable) {
520+
cancellations[key] = cancellable
521+
}
522+
523+
func removeCancellation(forKey key: AnyHashable) {
524+
cancellations.removeValue(forKey: key)
525+
}
474526
}
475527
}
476528

0 commit comments

Comments
 (0)