Search code examples
swiftuiswiftdataswift-concurrency

How to avoid deadlock / hang when using SwiftData and @Observable?


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?


Solution

  • 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.