-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Rethrow CancellationError
in TaskResult.init(catching:)
#1934
Conversation
This is technically a breaking change (that the compiler can usually fix by inserting `try`), but worth considering if this should make the pre-1.0 cut.
f6b3c1c
to
3b2aa2d
Compare
After discussing with @stephencelis we decided to re-target this to We'd love to hear people's opinions on this. I personally think it's a good idea, but also am a little put off seeing the |
I don't know if you prefer to discuss here, in a discussion, or on Slack. I was initially reserved about the idea because:
public func withCancellationError<Result>(
perform operation: () async -> Result
) async throws -> Result {
let result = await operation()
try Task.checkCancellation()
return result
} I think this would cover the same cases as a throwing
I'm playing a little the Devil's advocate though. It seems possible to catch the context's Footnotes
|
This does seem correct but also really awkward.
I was advocating for this in Slack based on an experience where receiving a I was thinking this would fix it, but it no luck. switch action {
case .response(.failure(CancellationError)): // ❌ Type 'CancellationError.Type' cannot conform to '_ErrorCodeProtocol'
return .none
case .response(.failure(let error)):
// handle error
} But generally, I think anyone doing error handling in So, just brainstorming another approach (and a very breaking change), what if Seeing the complexity that this raises I'm really not advocating one way or the other. There does seem to be something awkward about how cancellation is treated though. For another example, I sketched an idea for |
@rcarver Wouldn't this kind of pattern matching work? case .response(.failure(let error)) where error is CancellationError: … |
@tgrapperon aha yes thank you! |
Brandon and I agree that the outer |
About the outer extension EffectTask {
static func taskResult<Value: Sendable>(
_ action: @escaping (TaskResult<Value>) -> Action,
operation: @escaping @Sendable () async throws -> Value,
onCancellation: (() async -> Action)? = nil
) -> EffectTask<Action> {
if let onCancellation {
return EffectTask<Action>.task {
try await action(
TaskResult(catching: operation)
)
} catch: { cancellationError in
assert(cancellationError is CancellationError)
return await onCancellation()
}
} else {
return EffectTask<Action>.task {
try await action(
TaskResult(catching: operation)
)
}
}
}
} You would use it as: .taskResult(Action.entries) {
try await dependency.fetchEntries()
}
// or
.taskResult(Action.entries) {
try await dependency.fetchEntries()
} onCancellation: {
.fetchEntriesCancelled
} in place of: .task {
try await .entries(
TaskResult {
try await dependency.fetchEntries()
}
)
}
// or
.task {
try await .entries(
TaskResult {
try await dependency.fetchEntries()
}
)
} catch { _ in
.fetchEntriesCancelled
} It also seems that in this case, the throwing init of |
@stephencelis 👍 on all that. If I follow correctly, where in TCA would be responsible for ignoring a
Nope. I think if you care about |
Working through this in my codebase now and I think this change is important to make. A common use case for cancellation is for when state is going away. TCA considers it to be an error to send actions back into a domain whose state is nil. Therefore when some state is going away, we might typically want to cancel some effects to prevent them from feeding back into the store. With async tasks, throwing a cancellation error is what is expected but if you're using I know you want to keep the surface area of the |
I've decided to take this for a spin in our app - I've had to implement it as a static func helper because the compiler cannot disambiguate between the throwing and non-throwing initialisers, but I can easily find/replace these later. I am going to hold off on the |
This is probably naive but, couldn't we just add a public enum TaskResult<Success: Sendable>: Sendable {
/// A success, storing a `Success` value.
case success(Success)
/// A failure, storing an error.
case failure(Error)
/// A cancellation.
case cancelled(CancellationError)
} |
Adding a cancelled case would not solve the problem of task results being fed back into the store when they shouldn't be. |
It also means that you will need to explicitly account for cancellation for each case .onValues(.success(let values)):
state.values = values
return .none
case .onValues(.failure(let error)):
return .fireAndForget {
self.log.error(error)
}
case .onValues(.cancelled):
return .none AFAIK, |
I totally vote for this PR, my console is spammed with cancellation errors, that are being printed/logged in the Regarding the additional Other than that, whoever wants to do some logic for cancellation could just wrap the |
@lukeredpath Revisiting this now and with distance the change maybe seems the wrong direction. case .startRequest:
state.isLoading = true
return .run { await send(.response(TaskResult { … })) }
case .response:
state.isLoading = false
return .none If
With the new navigation tools, is this point now moot, since we automatically cancel these effects? |
@lukeredpath I'm going to close this for now since it does seem problematic in hindsight, but definitely feel free to ping the PR again if we're missing something, and we can consider reopening and next steps. |
This came up on the Slack: it seems surprising that
TaskResult
can swallow cancellation errors. Instead it'd make sense forTaskResult
to propagate them into theEffect
.This is technically a breaking change, where all
TaskResult.init
s must now be qualified with atry
, and this can even look a lil confusing. At the very least the compiler can usually fix things automatically by insertingtry
.Thoughts? Is there a better approach? Is there more surface area to think about?
TaskResult.init(result:)
, for example?