Skip to content

Commit b86012c

Browse files
authored
Fix Store leak when async effect is in flight (#2643)
* Fix Store leak when async effect is in flight A [Slack conversation](https://pointfreecommunity.slack.com/archives/C04KQQ7NXHV/p1702049135565039) pointed to a potential leak of the `Store` when it was executing an effect. This PR allows cancellation to properly propagate during deinitialization, and ensures that async effects don't strongly capture the store. * wip * feedback * wip
1 parent c6b0a6c commit b86012c

File tree

6 files changed

+385
-297
lines changed

6 files changed

+385
-297
lines changed

Sources/ComposableArchitecture/Store.swift

Lines changed: 45 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,10 @@ public final class Store<State, Action> {
485485
for id in self.children.keys {
486486
self.invalidateChild(id: id)
487487
}
488+
self.effectCancellables.values.forEach { cancellable in
489+
cancellable.cancel()
490+
}
491+
self.effectCancellables.removeAll()
488492
}
489493

490494
fileprivate func invalidateChild(id: ScopeID<State, Action>) {
@@ -526,14 +530,14 @@ public final class Store<State, Action> {
526530
defer { index += 1 }
527531
let action = self.bufferedActions[index]
528532
let effect = self.reducer.reduce(into: &currentState, action: action)
533+
let uuid = UUID()
529534

530535
switch effect.operation {
531536
case .none:
532537
break
533538
case let .publisher(publisher):
534539
var didComplete = false
535540
let boxedTask = Box<Task<Void, Never>?>(wrappedValue: nil)
536-
let uuid = UUID()
537541
let effectCancellable = withEscapedDependencies { continuation in
538542
publisher
539543
.handleEvents(
@@ -571,45 +575,48 @@ public final class Store<State, Action> {
571575
}
572576
case let .run(priority, operation):
573577
withEscapedDependencies { continuation in
574-
tasks.wrappedValue.append(
575-
Task(priority: priority) { @MainActor in
576-
#if DEBUG
577-
let isCompleted = LockIsolated(false)
578-
defer { isCompleted.setValue(true) }
579-
#endif
580-
await operation(
581-
Send { effectAction in
582-
#if DEBUG
583-
if isCompleted.value {
584-
runtimeWarn(
585-
"""
586-
An action was sent from a completed effect:
587-
588-
Action:
589-
\(debugCaseOutput(effectAction))
590-
591-
Effect returned from:
592-
\(debugCaseOutput(action))
593-
594-
Avoid sending actions using the 'send' argument from 'Effect.run' after \
595-
the effect has completed. This can happen if you escape the 'send' \
596-
argument in an unstructured context.
597-
598-
To fix this, make sure that your 'run' closure does not return until \
599-
you're done calling 'send'.
600-
"""
601-
)
602-
}
603-
#endif
604-
if let task = continuation.yield({
605-
self.send(effectAction, originatingFrom: action)
606-
}) {
607-
tasks.wrappedValue.append(task)
578+
let task = Task(priority: priority) { @MainActor [weak self] in
579+
#if DEBUG
580+
let isCompleted = LockIsolated(false)
581+
defer { isCompleted.setValue(true) }
582+
#endif
583+
await operation(
584+
Send { effectAction in
585+
#if DEBUG
586+
if isCompleted.value {
587+
runtimeWarn(
588+
"""
589+
An action was sent from a completed effect:
590+
591+
Action:
592+
\(debugCaseOutput(effectAction))
593+
594+
Effect returned from:
595+
\(debugCaseOutput(action))
596+
597+
Avoid sending actions using the 'send' argument from 'Effect.run' after \
598+
the effect has completed. This can happen if you escape the 'send' \
599+
argument in an unstructured context.
600+
601+
To fix this, make sure that your 'run' closure does not return until \
602+
you're done calling 'send'.
603+
"""
604+
)
608605
}
606+
#endif
607+
if let task = continuation.yield({
608+
self?.send(effectAction, originatingFrom: action)
609+
}) {
610+
tasks.wrappedValue.append(task)
609611
}
610-
)
611-
}
612-
)
612+
}
613+
)
614+
self?.effectCancellables[uuid] = nil
615+
}
616+
tasks.wrappedValue.append(task)
617+
self.effectCancellables[uuid] = AnyCancellable {
618+
task.cancel()
619+
}
613620
}
614621
}
615622
}

0 commit comments

Comments
 (0)