Leaving a view leaves Tasks in a suspended state? [SwiftUI]

My setup looks something like this

struct MyView: View { 
   var body: some View {
        SomeView()
            .task { 
                 try? await taskA() {}
                 try? await taskB() {}
            }
    }
}

func taskA() async throws { 
   Task(name: "Task A") { }
}

func taskB() async throws { 
   Task(name: "Task B") { }
}

When I leave the view (popping the view from the Nav Stack) the task never ends? I thought .task automatically canceled child tasks so why do TaskA and TaskB appear to be in a Suspended state? My expectation here is that the two Tasks would just end.


(How Task A and Task B appear in Instruments after leaving the view)

I think what's happening here is the task modifier is tied to the lifetime of the view component. Which is the "unmount" operation that might not be exactly tied to whether or not the view component is on screen.

You might want to try using onDisappear(perform:) to see if that gives you what you want.

Another option is to try and figure out how exactly the "parent" in this flow is potentially keeping the lifetime of its children active after disappearing.

(I assume your functions are actually await Task {}.value?)

If you're producing unstructured tasks like this, they will not be automatically cancelled by task. You'd need to wrap them in withTaskCancellationHandler if you want that behavior, or use something else that automatically responds to cancellation.

5 Likes

Ahh… this makes more sense now.

import AsyncAlgorithms
import SwiftUI

@main
struct TaskDemoApp: App {
  var body: some Scene {
    WindowGroup {
      MyView()
    }
  }
}

struct MyView: View {
  var body: some View {
    ContentView()
      .task {
        for await x in AsyncTimerSequence(
          interval: .seconds(1),
          clock: .continuous
        ) {
          print("Task A: \(x)")
        }
      }
      .task {
        Task {
          for await x in AsyncTimerSequence(
            interval: .seconds(1),
            clock: .continuous
          ) {
            print("Task B: \(x)")
          }
        }
      }
  }
}

The Task A correctly cancels when the window closes but Task B keeps going.

1 Like

tl;dr

You are using unstructured concurrency. That will not propagate the cancellation (without adding a special withTaskCancellationHandler handler). As the Task {…} documentation says:

Also, without an await of the resulting Task, you are returning immediately: So, the .task {…} view modifier also finishes immediately, leaving the system with no reference to anything that needs to be cancelled.

So either manually add withTaskCancellationHandler or adopt structured concurrency.


You have a few alternatives:

  1. Pass back the Task references and await their completion inside the withTaskCancellationHandler:

    struct MyView: View {
        var body: some View {
            SomeView()
                .task {
                    let a = taskA()
                    let b = taskB()
    
                    await withTaskCancellationHandler {
                        try? await a.value
                        try? await b.value
                    } onCancel: {
                        a.cancel()
                        b.cancel()
                    }
                }
        }
    }
    
    // Note, if you are just launching unstructured concurrency and returning immediately:
    //  - you do not need to mark this function as either `async` or `throws`
    //  - you want to return the `Task` so that the caller has something to cancel
    
    func taskA() -> Task<Void, Error> {
        Task(name: "Task A") {
            try await sleepForTenSeconds()
        }
    }
    
    func taskB() -> Task<Void, Error> {
        Task(name: "Task B") {
            try await sleepForTenSeconds()
        }
    }
    

    So, although my two tasks were defined to run for 10 seconds, I dismissed the view after a few seconds and both tasks were properly cancelled:

    Frankly, I only include the above example for symmetry with your code snippet. I would generally recommend one of the patterns below.

  2. The more idiomatic pattern would be to keep all the cancellation logic in these two functions, and remove this burden from the .task view modifier. Making these async functions that handle cancellation simplifies the call point.

    But the challenge is that if the functions don’t return before their respective asynchronous work is done, they will not run concurrently unless you use a task group (or async let). So it might look like:

    struct MyView: View {
        var body: some View {
            SomeView()
                .task {
                    await withTaskGroup { group in
                        group.addTask { try? await taskC() }
                        group.addTask { try? await taskD() }
                    }
                }
        }
    }
    
    func taskC() async throws {
        let task = Task(name: "Task C") {
            try await sleepForTenSeconds()
        }
    
        try await withTaskCancellationHandler {
            try await task.value
        } onCancel: {
            task.cancel()
        }
    }
    
    func taskD() async throws {
        let task = Task(name: "Task D") {
            try await sleepForTenSeconds()
        }
    
        try await withTaskCancellationHandler {
            try await task.value
        } onCancel: {
            task.cancel()
        }
    }
    

    This results in the same behavior, namely supporting cancellation when the view is dismissed, but this time the cancellation logic is in the async functions (where it arguably belongs).

  3. Even simpler, I might suggest remaining within structured concurrency:

    struct MyView: View {
        var body: some View {
            SomeView()
                .task {
                    await withTaskGroup { group in
                        group.addTask(name: "Task E") { try? await taskE() }
                        group.addTask(name: "Task F") { try? await taskF() }
                    }
                }
        }
    }
    
    func taskE() async throws {
        try await sleepForTenSeconds()
    }
    
    func taskF() async throws {
        try await sleepForTenSeconds()
    }
    

    This way, we enjoy automatic cancellation propagation.

Obviously, if you absolutely need the control of unstructured concurrency, feel free to do so, but make sure you manually propagate the cancellation (presumably like option 2, above). But your original example is a good example of precisely why we often prefer to remain within structured concurrency and keep things simple.

2 Likes

A small addition: View.task is effectively implemented in a similar way to this code:

In practice, you can replace View.task(action) with the corresponding onAppear/onDisappear logic to observe why task.cancel() doesn’t lead to taskA and taskB being cancelled as you expected.

The main point has already been clarified by others in the thread, but I hope this provides some additional context.

1 Like