Replies: 11 comments 1 reply
-
I tracked this down to these lines in if let task = self.send($0, originatingFrom: action) {
tasks.wrappedValue.append(task)
} Stepping away as any solution gets into subtleties of effect cancellation, and the heart of the issue may be a conflict with with cascading cancellation ( Here's a more real-world/interactive sample where you can see Alive Tasks accumulate forever every time the button is tapped. FYI it has some print statements because tacking on import ComposableArchitecture
import SwiftUI
let relay = AsyncStream<Int>.streamWithContinuation()
struct SampleFeature: ReducerProtocol {
struct State: Equatable {
var value: Int = 0
}
enum Action: Equatable {
case increment
case receiveValue(Int)
case task
}
func reduce(into state: inout State, action: Action) -> Effect<Action, Never> {
switch action {
case .increment:
print(".increment")
return .fireAndForget { [value = state.value + 1] in
relay.continuation.yield(value)
}
case .receiveValue(let value):
print(".receiveValue")
state.value = value
return .run { _ in
print(" ** run ** ")
}
case .task:
print(".task")
return .run { send in
for await value in relay.stream {
await send(.receiveValue(value))
}
}
}
}
}
struct SampleContentView: View {
let store = Store(
initialState: SampleFeature.State(),
reducer: SampleFeature()
)
var body: some View {
WithViewStore(store) { viewStore in
VStack {
Button {
viewStore.send(.increment)
} label: {
Text("Increment")
}
Text(viewStore.value.formatted())
}
.padding()
.task {
await viewStore.send(.task).finish()
}
}
}
} |
Beta Was this translation helpful? Give feedback.
-
Hi @rcarver, we were just chatting about this issue. We know roughly what the solution should look like and so should have a fix soon. The crux of the problem is that we want an effect's lifetime to be extended to include the lifetimes of any effects created from sending actions inside the original effect. So, in your example above, if you navigate away from It should be possible (hopefully!) to simply remove the children tasks from the array as they finish rather than waiting for the original effect to finish. |
Beta Was this translation helpful? Give feedback.
-
Awesome thanks, that confirms my guesses and I look forward to seeing how to make the change 🙏🏻 |
Beta Was this translation helpful? Give feedback.
-
@rcarver We had a few ideas but this was the least invasive one that seems to work: https://github.com/pointfreeco/swift-composable-architecture/compare/weak-tasks Not quite sure it's the right "fix" though. First, while it seems perfectly safe to cast a value type to Second, should we even be bothering with this extra work? While the Concurrency instrument seems to consider tasks as "alive" even if they've completed and merely have a handle held somewhere, maybe this should be considered the "bug" and it should be reported to the Xcode team as feedback? Is there any harm in keeping these tasks handles around for the duration of the parent task? |
Beta Was this translation helpful? Give feedback.
-
I filed FB11690443 ("Concurrency instrument shows task as 'alive' even when finished") if anyone wants to dupe. |
Beta Was this translation helpful? Give feedback.
-
@rcarver Since it appears this isn't really a "leak" (the tasks are only retained as long as the parent is alive), and since my "fix" doesn't really do anything except work around a bug in the Concurrency instrument, do you think this can be closed? Or do you think there is a bug in our approach here that can be demonstrated/exploited? |
Beta Was this translation helpful? Give feedback.
-
Hey @stephencelis thanks for taking a look. It's a great question, I wish I could offer more insight. For context, my app has a bug where seemingly all async work stops happening. Like a queue is blocked, or priority inversion or something. I'm not sure, it's super random. I have yet to capture the bug while running in Instruments, so proactively I've been digging around trying to understand concurrency better. The Concurrency instrument shows me an ever-growing pile of Alive Tasks and that doesn't seem like a good thing. On the other hand, it could definitely be nothing. Any idea of the cost of holding onto a Task handle? The app basically has a render loop, so any change to state results in an accumulated task that lasts for the lifetime of the app. Pulling this branch now and I'll see if it helps. Any ideas to solve my actual problem? I'd happily drop this issue :) |
Beta Was this translation helpful? Give feedback.
-
@rcarver Let us know if it does! Task handles should be super lightweight, though, just 8 bytes per task in this array, so I imagine you'd have to be accumulating a ton of tasks for this to be noticeable. As for fixing...hard to do more than speculate. A non-library-level solution would be to restructure your render loop in some way so that not all effects come from a single starter effect. I think this would mean using a view store to coordinate the run loop instead of the reducer's effect layer, if that's possible. Library-level fixes may require inserting/removing tasks a bit more dynamically in
We'll think on it more, but any insight you can give us, or project that can reproduce a real problem, would be helpful. Short of that it's hard for us to really say this is a bug with our implementation or not, and so not sure we should track it as one in the long run. |
Beta Was this translation helpful? Give feedback.
-
Sounds good @stephencelis. Instruments shows zero accumulation of Alive Tasks with your branch. I'll keep an eye out for the actual bug on this build. Either way hopefully this will lead to some insights. |
Beta Was this translation helpful? Give feedback.
-
For what it's worth I think it'll still accumulate "weak tasks" 😉 |
Beta Was this translation helpful? Give feedback.
-
@rcarver I'm going to move this to a discussion for now. While there are potential improvements we could maybe make, it's hard to measure the problem right now. Please do follow up in the discussion with your findings, and if you find that are workaround somehow prevents the issue, we're definitely down to merge that branch or something like it in the future. And if you are able to more generally help us reproduce the problem that ties it to our current implementation of |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Description
Ok I think I tracked down another leaking task — When a long living
.run
effect sends an action, and that child action performs async work, then the child task is retained until the parent task completes.Checklist
main
branch of this package.Expected behavior
With this common pattern of observing a stream of values and relaying those to an action, I would expect any tasks spun up from those actions to be cleaned up while the parent task continues to run.
Actual behavior
A Task is retained each time the child performs async work.
Steps to reproduce
.child
is in the Alive state until.parent
completesThe Composable Architecture version information
0.42.0
Destination operating system
iOS 16.1
Xcode version information
Version 14.1 beta 3 (14B5033e)
Swift Compiler version information
Beta Was this translation helpful? Give feedback.
All reactions