4

I'm curious if someone will easily spot my mistake here. I've got a problem where my App hangs during a call to sleep. My best guess is that this is a deadlock caused by using SwiftData and @Observable. I've reduced my code to a minimal SwiftUI app that consistently hangs (Xcode 15.4) a few seconds after the "Begin" button has been hit. I'm probably doing something silly wrong, but I can't spot it.

Here's the code:

import SwiftUI
import SwiftData
import os

private let logger = Logger(subsystem: "TestApp", category: "General")


@Observable
class AppState {
    var queue: [Item] = []
}

@Model
final class Item {
    var name: String
    
    init(name: String) {
        self.name = name
    }
}

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]
    @State private var state = AppState()
//    @State private var queue = [Item]()
    
    @State private var testsRunning = false
    @State private var remoteTask: Task<(), Never>?
    @State private var syncTask: Task<(), Never>?

    
    var body: some View {
        VStack {
            Button ("Begin") {
                Task { await runTests() }
                testsRunning = true
            }.disabled(testsRunning)
            Text("Remote Queue: \(state.queue.count)")
            List (items) {
                Text($0.name)
            }
        }
    }
}

extension ContentView {
    @MainActor func runTests() async {
        for item in items {
            modelContext.delete(item)
        }
        state.queue.removeAll()
        
        startRemoteWork()
        startSync()
    }
    
    @MainActor func startRemoteWork() {
        // Adds non-inserted SwiftData items in an array to simulate data in cloud
        remoteTask = Task.detached {
            while true {
                await sleep(duration: .random(in: 0.2...0.5))
                let newItem = Item(name: "Item \(items.count + state.queue.count + 1)")
                state.queue.append(newItem)
                logger.info("\(Date.now): \(newItem.name) added to remote queue")
            }
        }
    }
    
    @MainActor func syncQueuedItems() async {
        // removes items from remote queue and inserts them into local SwiftData context.
        while !state.queue.isEmpty
        {
            let item = state.queue.removeFirst()
            
            modelContext.insert(item)
            let delay = Double.random(in: 0.01...0.05)
            logger.info("    \(Date.now): syncing \(item.name) (will take \(delay) seconds)...")
            await sleep(duration: delay)    // simulating work
            logger.info("    \(Date.now): Done")
        }
    }
    
    @MainActor func startSync() {
        syncTask = Task.detached {
            logger.info("  \(Date.now): Sync Task Started")
            while true {
                await syncQueuedItems()
                logger.info("  \(Date.now): Sync Task sleeping for 3 seconds till next sync")
                await sleep(duration: 3)
            }
        }
    }
    
    func sleep(duration: Double) async {
        do {
            try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
        } catch { fatalError("Sleep failed") }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: Item.self, inMemory: true)
}

I know that if I change the code to not use SwiftData (replacing Item with a normal struct that would be held in local State storage) then there's no problem. Also, if I move the queue Array from the AppState @Observable to local State storage, then there's no problem. So I conclude, somewhat uncertainly, that the problem has something to do with the combination of the two. Could anyone point me to what I'm doing wrong, please?

8
  • 1
    I can't explain exactly what's wrong, but you should see a lot of concurrency warnings regarding capturing the non-sendable self in sendable closures. If you fix all of those by e.g. use Task { ... } instead of Task.detached { ... }. It does not hang. Commented Jul 27, 2024 at 2:21
  • 2
    ContentView is not Sendable, but you can make it safe to capture by making it @MainActor (which it will be in iOS 18 anyway). Item is not Sendable, so if you want to work with Items on different threads, you should use a @ModelActor (see example). Finally, AppState is also not Sendable, so you simply can't capture that. Commented Jul 27, 2024 at 2:40
  • Can you explain why you are using Task.detached? What operations do you want to perform on a non-main thread? Commented Jul 27, 2024 at 3:35
  • 1
    You should turn on complete concurrency checking in the project settings. I do not recommend using Task.detached in SwiftUI code. You should use the .task { ... } or .task(id: ...) { ... } modifiers. These cancel the tasks automatically when the view disappears. Your code is not handling task cancellation at all, and isn't even doing Task.checkCancellation() or checking Task.isCancelled. You don't need Task.detached to run things on a background thread. async functions that aren't isolated to the main actor are run on the cooperative thread pool. Commented Jul 27, 2024 at 5:16
  • 1
    "I thought non-detached child Tasks inherit the Actor of the parent" Yes, but only for the synchronous parts of the code. When you await, the code being awaited is not necessarily run on the same actor as the code that is awaiting. Commented Jul 27, 2024 at 5:28

1 Answer 1

5

Could anyone point me to what I'm doing wrong, please?

Task.detached takes a @Sendable closure, which cannot capture non-Sendable things. However, the closures you are using are capturing [Item], ContentView, and AppState, which are all non-Sendable. If you turn on complete concurrency checking, there will be many warnings in your code.

You should isolate the whole ContentView to MainActor instead of marking its individual methods as @MainActor.

Then, use the .task(id:) modifier to launch tasks instead of Task.detached. If you want to run something on a non-main thread, put it in an asynchronous nonisolated func or a function isolated to another actor.

SwiftData models are not Sendable - so the idea of "one Task.detached creates Items and adds them to a queue, and another Task.detached takes them out of the queue" doesn't work. You should instead work with something Sendable, like a simple struct with all let properties, that contains all you need to create an Item. You should create the Item just before you insert it into the context.

Here is your code after those transformations:

@MainActor
struct ContentView: View {
    private let logger = Logger(subsystem: "TestApp", category: "General")
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]
    @State private var state = AppState()
    
    @State private var testsRunning = false
    
    // when you want to cancel these tasks, just set these to false
    @State private var remoteTask = false
    @State private var syncTask = false

    
    var body: some View {
        VStack {
            Button ("Begin") {
                testsRunning = true
                runTests()
            }.disabled(testsRunning)
            Text("Remote Queue: \(state.queue.count)")
            List (items) {
                Text($0.name)
            }
        }
        .task(id: remoteTask) {
            if remoteTask {
                await startRemoteWork()
            }
        }
        .task(id: syncTask) {
            if syncTask {
                await startSync()
            }
        }
    }
}

extension ContentView {
    func runTests() {
        for item in items {
            modelContext.delete(item)
        }
        state.queue.removeAll()
        
        remoteTask = true
        syncTask = true
    }
    
    func startRemoteWork() async {
        while !Task.isCancelled {
            let newItemName = await fetchItemName()
            state.queue.append(newItemName)
            logger.info("\(Date.now): \(newItemName) added to remote queue")
        }
    }
    
    nonisolated func fetchItemName() async -> String {
        await sleep(duration: .random(in: 0.2...0.5))
        return UUID().uuidString
    }
    
    func syncQueuedItems(with itemsActor: ItemsActor) async {
        let queue = state.queue
        state.queue = []
        await itemsActor.insertItems(withNames: queue)
    }
    
    func startSync() async {
        logger.info("  \(Date.now): Sync Task Started")
        let itemsActor = ItemsActor(modelContainer: modelContext.container)
        while !Task.isCancelled {
            await syncQueuedItems(with: itemsActor)
            logger.info("  \(Date.now): Sync Task sleeping for 3 seconds till next sync")
            await sleep(duration: 3)
        }
    }
}

@ModelActor
actor ItemsActor {
    private let logger = Logger(subsystem: "ItemsActor", category: "General")
    func insertItems(withNames names: [String]) async {
        for name in names {
            let delay = Double.random(in: 0.01...0.05)
            logger.info("    \(Date.now): syncing \(name)")
            modelContext.insert(Item(name: name))
            await sleep(duration: delay)
            logger.info("    \(Date.now): Done")
            if Task.isCancelled {
                break
            }
        }
    }
}

func sleep(duration: Double) async {
    do {
        try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
    } catch { fatalError("Sleep failed") }
}
  • I've made queue in AppState store an array of strings representing item names. This is Sendable, so it can be sent to the model actor for insertion
  • fetchItemName is non-isolated, so it will not be running on the main thread when you await it.
  • insertItems(withNames:) is isolated to ItemsActor, so it will also not be running on the main thread.
  • The logic in syncQueuedItems is slightly different from your code. I simply took everything in the queue and inserted everything. It is also possible to do it your way, but this would involve a lot of hopping between the main actor and ItemsActor.
Sign up to request clarification or add additional context in comments.

1 Comment

Awesome! So much wisdom and good practice packed into one answer! Transformative! 🤯

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.