Skip to content

Commit cd22f6a

Browse files
kabiroberaimbrandonwstephencelis
authored
Warn if send is called after .run completes (#1900)
* Warn if send called after effect completes When an Effect.run is converted into a publisher, we already end up discarding `send`s sent after returning. Similarly, `*Task.finish()` will fail to track send()s after other in-flight effects complete. This is effectively UB so let's warn about it. * Fix tests * we don't need to print TestAction yet * Tweak messaging and tests. * fix tests * wip * fix tests part 2 * fix tests part 3 * Wrap testCancellation test in _withMainSerialExecutor * Update Publisher.swift --------- Co-authored-by: Brandon Williams <[email protected]> Co-authored-by: Stephen Celis <[email protected]> Co-authored-by: Stephen Celis <[email protected]>
1 parent 0087051 commit cd22f6a

File tree

4 files changed

+183
-40
lines changed

4 files changed

+183
-40
lines changed

Sources/ComposableArchitecture/Effects/Publisher.swift

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,32 @@ extension EffectPublisher: Publisher {
2323
return .create { subscriber in
2424
let task = Task(priority: priority) { @MainActor in
2525
defer { subscriber.send(completion: .finished) }
26-
let send = Send { subscriber.send($0) }
26+
#if DEBUG
27+
var isCompleted = false
28+
defer { isCompleted = true }
29+
#endif
30+
let send = Send {
31+
#if DEBUG
32+
if isCompleted {
33+
runtimeWarn(
34+
"""
35+
An action was sent from a completed effect:
36+
37+
Action:
38+
\(debugCaseOutput($0))
39+
40+
Avoid sending actions using the 'send' argument from 'EffectTask.run' after \
41+
the effect has completed. This can happen if you escape the 'send' argument in \
42+
an unstructured context.
43+
44+
To fix this, make sure that your 'run' closure does not return until you're \
45+
done calling 'send'.
46+
"""
47+
)
48+
}
49+
#endif
50+
subscriber.send($0)
51+
}
2752
await operation(send)
2853
}
2954
return AnyCancellable {

Sources/ComposableArchitecture/Store.swift

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,9 +417,35 @@ public final class Store<State, Action> {
417417
}
418418
case let .run(priority, operation):
419419
tasks.wrappedValue.append(
420-
Task(priority: priority) {
420+
Task(priority: priority) { @MainActor in
421+
#if DEBUG
422+
var isCompleted = false
423+
defer { isCompleted = true }
424+
#endif
421425
await operation(
422426
EffectTask<Action>.Send {
427+
#if DEBUG
428+
if isCompleted {
429+
runtimeWarn(
430+
"""
431+
An action was sent from a completed effect:
432+
433+
Action:
434+
\(debugCaseOutput($0))
435+
436+
Effect returned from:
437+
\(debugCaseOutput(action))
438+
439+
Avoid sending actions using the 'send' argument from 'EffectTask.run' after \
440+
the effect has completed. This can happen if you escape the 'send' argument in \
441+
an unstructured context.
442+
443+
To fix this, make sure that your 'run' closure does not return until you're \
444+
done calling 'send'.
445+
"""
446+
)
447+
}
448+
#endif
423449
if let task = self.send($0, originatingFrom: action) {
424450
tasks.wrappedValue.append(task)
425451
}

Tests/ComposableArchitectureTests/ComposableArchitectureTests.swift

Lines changed: 40 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -112,46 +112,48 @@ final class ComposableArchitectureTests: XCTestCase {
112112
}
113113

114114
func testCancellation() async {
115-
let mainQueue = DispatchQueue.test
116-
117-
enum Action: Equatable {
118-
case cancel
119-
case incr
120-
case response(Int)
121-
}
122-
123-
let reducer = Reduce<Int, Action> { state, action in
124-
enum CancelID {}
125-
126-
switch action {
127-
case .cancel:
128-
return .cancel(id: CancelID.self)
129-
130-
case .incr:
131-
state += 1
132-
return .task { [state] in
133-
try await mainQueue.sleep(for: .seconds(1))
134-
return .response(state * state)
115+
await _withMainSerialExecutor {
116+
let mainQueue = DispatchQueue.test
117+
118+
enum Action: Equatable {
119+
case cancel
120+
case incr
121+
case response(Int)
122+
}
123+
124+
let reducer = Reduce<Int, Action> { state, action in
125+
enum CancelID {}
126+
127+
switch action {
128+
case .cancel:
129+
return .cancel(id: CancelID.self)
130+
131+
case .incr:
132+
state += 1
133+
return .task { [state] in
134+
try await mainQueue.sleep(for: .seconds(1))
135+
return .response(state * state)
136+
}
137+
.cancellable(id: CancelID.self)
138+
139+
case let .response(value):
140+
state = value
141+
return .none
135142
}
136-
.cancellable(id: CancelID.self)
137-
138-
case let .response(value):
139-
state = value
140-
return .none
141143
}
144+
145+
let store = TestStore(
146+
initialState: 0,
147+
reducer: reducer
148+
)
149+
150+
await store.send(.incr) { $0 = 1 }
151+
await mainQueue.advance(by: .seconds(1))
152+
await store.receive(.response(1))
153+
154+
await store.send(.incr) { $0 = 2 }
155+
await store.send(.cancel)
156+
await store.finish()
142157
}
143-
144-
let store = TestStore(
145-
initialState: 0,
146-
reducer: reducer
147-
)
148-
149-
await store.send(.incr) { $0 = 1 }
150-
await mainQueue.advance(by: .seconds(1))
151-
await store.receive(.response(1))
152-
153-
await store.send(.incr) { $0 = 2 }
154-
await store.send(.cancel)
155-
await store.finish()
156158
}
157159
}

Tests/ComposableArchitectureTests/EffectRunTests.swift

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,94 @@ final class EffectRunTests: XCTestCase {
117117
let store = TestStore(initialState: State(), reducer: reducer)
118118
await store.send(.tapped).finish()
119119
}
120+
121+
#if DEBUG
122+
func testRunEscapeFailure() async throws {
123+
XCTExpectFailure {
124+
$0.compactDescription == """
125+
An action was sent from a completed effect:
126+
127+
Action:
128+
EffectRunTests.Action.response
129+
130+
Effect returned from:
131+
EffectRunTests.Action.tap
132+
133+
Avoid sending actions using the 'send' argument from 'EffectTask.run' after the effect has \
134+
completed. This can happen if you escape the 'send' argument in an unstructured context.
135+
136+
To fix this, make sure that your 'run' closure does not return until you're done calling \
137+
'send'.
138+
"""
139+
}
140+
141+
enum Action { case tap, response }
142+
143+
let queue = DispatchQueue.test
144+
145+
let store = Store(
146+
initialState: 0,
147+
reducer: Reduce<Int, Action> { _, action in
148+
switch action {
149+
case .tap:
150+
return .run { send in
151+
Task(priority: .userInitiated) {
152+
try await queue.sleep(for: .seconds(1))
153+
await send(.response)
154+
}
155+
}
156+
case .response:
157+
return .none
158+
}
159+
}
160+
)
161+
162+
let viewStore = ViewStore(store, observe: { $0 })
163+
await viewStore.send(.tap).finish()
164+
await queue.advance(by: .seconds(1))
165+
}
166+
167+
func testRunEscapeFailurePublisher() async throws {
168+
XCTExpectFailure {
169+
$0.compactDescription == """
170+
An action was sent from a completed effect:
171+
172+
Action:
173+
EffectRunTests.Action.response
174+
175+
Avoid sending actions using the 'send' argument from 'EffectTask.run' after the effect has \
176+
completed. This can happen if you escape the 'send' argument in an unstructured context.
177+
178+
To fix this, make sure that your 'run' closure does not return until you're done calling \
179+
'send'.
180+
"""
181+
}
182+
183+
enum Action { case tap, response }
184+
185+
let queue = DispatchQueue.test
186+
187+
let store = Store(
188+
initialState: 0,
189+
reducer: Reduce<Int, Action> { _, action in
190+
switch action {
191+
case .tap:
192+
return .run { send in
193+
Task(priority: .userInitiated) {
194+
try await queue.sleep(for: .seconds(1))
195+
await send(.response)
196+
}
197+
}
198+
.eraseToEffect()
199+
case .response:
200+
return .none
201+
}
202+
}
203+
)
204+
205+
let viewStore = ViewStore(store, observe: { $0 })
206+
await viewStore.send(.tap).finish()
207+
await queue.advance(by: .seconds(1))
208+
}
209+
#endif
120210
}

0 commit comments

Comments
 (0)