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?
selfin sendable closures. If you fix all of those by e.g. useTask { ... }instead ofTask.detached { ... }. It does not hang.ContentViewis notSendable, but you can make it safe to capture by making it@MainActor(which it will be in iOS 18 anyway).Itemis notSendable, so if you want to work withItems on different threads, you should use a@ModelActor(see example). Finally,AppStateis also notSendable, so you simply can't capture that.Task.detached? What operations do you want to perform on a non-main thread?Task.detachedin 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 doingTask.checkCancellation()or checkingTask.isCancelled. You don't needTask.detachedto run things on a background thread.asyncfunctions that aren't isolated to the main actor are run on the cooperative thread pool.await, the code being awaited is not necessarily run on the same actor as the code that is awaiting.