Skip to content

Commit 5e072b8

Browse files
mluisbrownstephencelistgrapperoniampatbrownmbrandonw
committed
Perform thread check only when store is created on main queue. (#811)
* Perform thread check only when store is created on main thread. * clean up * Update Sources/ComposableArchitecture/Store.swift * clean up * Update Sources/ComposableArchitecture/Store.swift * clean up * execute setSpecific only once. * logic fix * added a test * typo * wip * docs * fix test * Update Sources/ComposableArchitecture/Store.swift Co-authored-by: Thomas Grapperon <[email protected]> * Run swift-format * Clean up speech recognition case study. (#812) * Clean up speech recognition case study. * fix tests * clean up; * Alternative to `CurrentValueSubject` in `ViewStore` (#755) * Replaced CurrentValueSubject with CurrentValueRelay * Added final to DemandBuffer Co-authored-by: Brandon Williams <[email protected]> * Run swift-format * Fix bindable deprecations (#815) * Fix Bindable Deprecations * More CI * wip * wip * wip * wip * Run swift-format * beef up test * expectation * fix Co-authored-by: Stephen Celis <[email protected]> Co-authored-by: Thomas Grapperon <[email protected]> Co-authored-by: stephencelis <[email protected]> Co-authored-by: iampatbrown <[email protected]> Co-authored-by: mbrandonw <[email protected]>
1 parent c4a299d commit 5e072b8

File tree

2 files changed

+134
-31
lines changed

2 files changed

+134
-31
lines changed

Sources/ComposableArchitecture/Store.swift

Lines changed: 105 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,9 @@ import ReactiveSwift
7676
/// ### Thread safety
7777
///
7878
/// The `Store` class is not thread-safe, and so all interactions with an instance of ``Store``
79-
/// (including all of its scopes and derived ``ViewStore``s) must be done on the same thread.
80-
/// Further, if the store is powering a SwiftUI or UIKit view, as is customary, then all
81-
/// interactions must be done on the _main_ thread.
79+
/// (including all of its scopes and derived ``ViewStore``s) must be done on the same thread the
80+
/// store was created on. Further, if the store is powering a SwiftUI or UIKit view, as is
81+
/// customary, then all interactions must be done on the _main_ thread.
8282
///
8383
/// The reason stores are not thread-safe is due to the fact that when an action is sent to a store,
8484
/// a reducer is run on the current state, and this process cannot be done from multiple threads.
@@ -111,6 +111,21 @@ import ReactiveSwift
111111
/// However, by leaving scheduling out of the ``Store`` we get to test these aspects of our effects
112112
/// if we so desire, or we can ignore if we prefer. We have that flexibility.
113113
///
114+
/// #### Thread safety checks
115+
///
116+
/// The store performs some basic thread safety checks in order to help catch mistakes. Stores
117+
/// constructed via the initializer ``Store/init(initialState:reducer:environment:)`` are assumed
118+
/// to run only on the main thread, and so a check is executed immediately to make sure that is the
119+
/// case. Further, all actions sent to the store and all scopes (see ``Store/scope(state:action:)``)
120+
/// of the store are also checked to make sure that work is performed on the main thread.
121+
///
122+
/// If you need a store that runs on a non-main thread, which should be very rare and you should
123+
/// have a very good reason to do so, then you can construct a store via the
124+
/// ``Store/unchecked(initialState:reducer:environment:)`` static method to opt out of all main
125+
/// thread checks.
126+
///
127+
/// ---
128+
///
114129
/// See also: ``ViewStore`` to understand how one observes changes to the state in a ``Store`` and
115130
/// sends user actions.
116131
public final class Store<State, Action> {
@@ -130,7 +145,7 @@ public final class Store<State, Action> {
130145
private let reducer: (inout State, Action) -> Effect<Action, Never>
131146
private var bufferedActions: [Action] = []
132147
#if DEBUG
133-
private let initialThread = Thread.current
148+
private let mainQueueChecksEnabled: Bool
134149
#endif
135150

136151
/// Initializes a store from an initial state, a reducer, and an environment.
@@ -139,13 +154,38 @@ public final class Store<State, Action> {
139154
/// - initialState: The state to start the application in.
140155
/// - reducer: The reducer that powers the business logic of the application.
141156
/// - environment: The environment of dependencies for the application.
142-
public init<Environment>(
157+
public convenience init<Environment>(
143158
initialState: State,
144159
reducer: Reducer<State, Action, Environment>,
145160
environment: Environment
146161
) {
147-
self.state = initialState
148-
self.reducer = { state, action in reducer.run(&state, action, environment) }
162+
self.init(
163+
initialState: initialState,
164+
reducer: reducer,
165+
environment: environment,
166+
mainQueueChecksEnabled: true
167+
)
168+
self.threadCheck(status: .`init`)
169+
}
170+
171+
/// Initializes a store from an initial state, a reducer, and an environment, and the main thread
172+
/// check is disabled for all interactions with this store.
173+
///
174+
/// - Parameters:
175+
/// - initialState: The state to start the application in.
176+
/// - reducer: The reducer that powers the business logic of the application.
177+
/// - environment: The environment of dependencies for the application.
178+
public static func unchecked<Environment>(
179+
initialState: State,
180+
reducer: Reducer<State, Action, Environment>,
181+
environment: Environment
182+
) -> Self {
183+
Self(
184+
initialState: initialState,
185+
reducer: reducer,
186+
environment: environment,
187+
mainQueueChecksEnabled: false
188+
)
149189
}
150190

151191
/// Scopes the store to one that exposes local state and actions.
@@ -325,8 +365,8 @@ public final class Store<State, Action> {
325365
self.scope(state: toLocalState, action: { $0 })
326366
}
327367

328-
func send(_ action: Action, isFromViewStore: Bool = true) {
329-
self.threadCheck(status: .send(action, isFromViewStore: isFromViewStore))
368+
func send(_ action: Action, originatingFrom originatingAction: Action? = nil) {
369+
self.threadCheck(status: .send(action, originatingAction: originatingAction))
330370

331371
self.bufferedActions.append(action)
332372
guard !self.isSending else { return }
@@ -345,8 +385,8 @@ public final class Store<State, Action> {
345385
var didComplete = false
346386
let uuid = UUID()
347387
let observer = Signal<Action, Never>.Observer(
348-
value: { [weak self] action in
349-
self?.send(action, isFromViewStore: false)
388+
value: { [weak self] effectAction in
389+
self?.send(effectAction, originatingFrom: action)
350390
},
351391
completed: { [weak self] in
352392
self?.threadCheck(status: .effectCompletion(action))
@@ -386,63 +426,97 @@ public final class Store<State, Action> {
386426

387427
private enum ThreadCheckStatus {
388428
case effectCompletion(Action)
429+
case `init`
389430
case scope
390-
case send(Action, isFromViewStore: Bool)
431+
case send(Action, originatingAction: Action?)
391432
}
392433

393434
@inline(__always)
394435
private func threadCheck(status: ThreadCheckStatus) {
395436
#if DEBUG
396-
guard self.initialThread != Thread.current
437+
guard self.mainQueueChecksEnabled && !isMainQueue
397438
else { return }
398439

399440
let message: String
400441
switch status {
401442
case let .effectCompletion(action):
402443
message = """
403-
An effect returned from the action "\(debugCaseOutput(action))" completed on the wrong \
444+
An effect returned from the action "\(debugCaseOutput(action))" completed on a non-main \
404445
thread. Make sure to use ".receive(on:)" on any effects that execute on background \
405-
threads to receive their output on the same thread the store was created on.
446+
threads to receive their output on the main thread, or create this store via \
447+
"Store.unchecked" to disable the main thread checker.
448+
"""
449+
450+
case .`init`:
451+
message = """
452+
"Store.init" was called on a non-main thread. Make sure that stores are initialized on \
453+
the main thread, or create this store via "Store.unchecked" to disable the main thread \
454+
checker.
406455
"""
407456

408457
case .scope:
409458
message = """
410-
"Store.scope" was called on the wrong thread. Make sure that "Store.scope" is always \
411-
called on the same thread the store was created on.
459+
"Store.scope" was called on a non-main thread. Make sure that "Store.scope" is always \
460+
called on the main thread, or create this store via "Store.unchecked" to disable the \
461+
main thread checker.
412462
"""
413463

414-
case let .send(action, isFromViewStore: true):
464+
case let .send(action, originatingAction: nil):
415465
message = """
416-
"ViewStore.send(\(debugCaseOutput(action)))" was called on the wrong thread. Make sure \
417-
that "ViewStore.send" is always called on the same thread the store was created on.
466+
"ViewStore.send(\(debugCaseOutput(action)))" was called on a non-main thread. Make sure \
467+
that "ViewStore.send" is always called on the main thread, or create this store via \
468+
"Store.unchecked" to disable the main thread checker.
418469
"""
419470

420-
case let .send(action, isFromViewStore: false):
471+
case let .send(action, originatingAction: .some(originatingAction)):
421472
message = """
422-
An effect emitted the action "\(debugCaseOutput(action))" from the wrong thread. Make \
423-
sure to use ".receive(on:)" on any effects that execute on background threads to receive \
424-
their output on the same thread the store was created on.
473+
An effect returned from "\(debugCaseOutput(originatingAction))" emitted the action \
474+
"\(debugCaseOutput(action))" on a non-main thread. Make sure to use ".receive(on:)" on \
475+
any effects that execute on background threads to receive their output on the main \
476+
thread, or create this store via "Store.unchecked" to disable the main thread checker.
425477
"""
426478
}
427-
479+
428480
breakpoint(
429481
"""
430482
---
431483
Warning:
432484
433-
The store was interacted with on a thread that is different from the thread the store was \
434-
created on:
485+
A store created on the main thread was interacted with on a non-main thread:
435486
436-
\(message)
487+
Thread: \(Thread.current)
437488
438-
Store created on: \(self.initialThread)
439-
Action sent on: \(Thread.current)
489+
\(message)
440490
441491
The "Store" class is not thread-safe, and so all interactions with an instance of "Store" \
442-
(including all of its scopes and derived view stores) must be done on the same thread.
492+
(including all of its scopes and derived view stores) must be done on the main thread.
443493
---
444494
"""
445495
)
446496
#endif
447497
}
498+
499+
private init<Environment>(
500+
initialState: State,
501+
reducer: Reducer<State, Action, Environment>,
502+
environment: Environment,
503+
mainQueueChecksEnabled: Bool
504+
) {
505+
self.state = initialState
506+
self.reducer = { state, action in reducer.run(&state, action, environment) }
507+
508+
#if DEBUG
509+
self.mainQueueChecksEnabled = mainQueueChecksEnabled
510+
#endif
511+
}
512+
}
513+
514+
private let mainQueueKey = DispatchSpecificKey<UInt8>()
515+
private let mainQueueValue: UInt8 = 0
516+
private var isMainQueue: Bool {
517+
_ = setSpecific
518+
return DispatchQueue.getSpecific(key: mainQueueKey) == mainQueueValue
448519
}
520+
private var setSpecific: () = {
521+
DispatchQueue.main.setSpecific(key: mainQueueKey, value: mainQueueValue)
522+
}()

Tests/ComposableArchitectureTests/StoreTests.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,4 +468,33 @@ final class StoreTests: XCTestCase {
468468
.child(2),
469469
])
470470
}
471+
472+
func testNonMainQueueStore() {
473+
var expectations: [XCTestExpectation] = []
474+
for i in 1 ... 100 {
475+
let expectation = XCTestExpectation(description: "\(i)th iteration is complete")
476+
expectations.append(expectation)
477+
DispatchQueue.global().async {
478+
let viewStore = ViewStore(
479+
Store.unchecked(
480+
initialState: 0,
481+
reducer: Reducer<Int, Void, XCTestExpectation> { state, _, expectation in
482+
state += 1
483+
if state == 2 {
484+
return .fireAndForget { expectation.fulfill() }
485+
}
486+
return .none
487+
},
488+
environment: expectation
489+
)
490+
)
491+
viewStore.send(())
492+
DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
493+
viewStore.send(())
494+
}
495+
}
496+
}
497+
498+
wait(for: expectations, timeout: 1)
499+
}
471500
}

0 commit comments

Comments
 (0)