AppDependencyManager and @Dependency usage crashes in Swift 6 mode

I'm working on an app that uses the AppIntents framework to write to a SwiftData persistence. I add the Sendable shared model container to app dependency manager that my intents and entities and queries can use as a dependency.

When I run the app in Swift 5 language mode, the app works without a problem. However, when I change to Swift 6 language mode, the app crashes at runtime at the point where the dependency should be resolved. In both language modes, strict concurrency checking is set to complete.

I've reduced the app to this MVP:

import AppIntents
import SwiftData
import SwiftUI

@main
struct AppDependencyBugApp: App {

	private let modelContainer: ModelContainer

	init() {
		let modelContainer = ModelContainer.shared
		self.modelContainer = modelContainer
		AppDependencyManager.shared.add(dependency: modelContainer)
	}

	var body: some Scene {
		WindowGroup {
			ItemsList()
				.modelContainer(modelContainer)
		}
	}

}

struct ItemsList: View {

	@Query private var items: [Item]

	var body: some View {
		NavigationStack {
			List {
				Section {
					Button("New Item", systemImage: "plus.circle", intent: NewItemIntent())
				}
				ForEach(items) { item in
					VStack(alignment: .leading) {
						Text(item.dateCreated, format: .iso8601)
						Text(item.id.uuidString)
							.monospaced()
							.font(.caption)
					}
				}
			}
			.navigationTitle("Items")
		}
	}

}

struct NewItemIntent: AppIntent {

	static var title: LocalizedStringResource {
		"New Item Intent"
	}

	@Dependency private var modelContainer: ModelContainer

	func perform() async throws -> some IntentResult {
		let act = DivvydeModelActor(modelContainer: modelContainer)
		try await act.newItem()
		return .result()
	}

}

extension ModelContainer {

	@MainActor
	static let shared: ModelContainer = {
		let container: ModelContainer
		do {
			let schema: Schema = .init([Item.self])
			let configuration: ModelConfiguration = .init(schema: schema)
			container = try ModelContainer(for: schema, configurations: configuration)
		} catch {
			preconditionFailure("Model container failed to initialize with error: \(error.localizedDescription)")
		}
		container.mainContext.autosaveEnabled = false
		return container
	} ()

}

@ModelActor
final actor DivvydeModelActor {

	@MainActor
	func newItem() async throws {
		let modelContext = modelContainer.mainContext
		let item = Item()
		modelContext.insert(item)
		try withAnimation {
			try modelContext.save()
		}
	}

}

@Model
final class Item: Identifiable {

	private(set) var id: UUID
	private(set) var dateCreated: Date

	convenience init(date: Date = .now) {
		self.init(id: .init(), dateCreated: date)
	}

	private init(id: ID, dateCreated: Date) {
		self.id = id
		self.dateCreated = dateCreated
	}

}

Am I missing something out of the Swift concurrency picture or if this is a bug in the framework?

4 Likes

As far as I can tell, Swift 6 mode will cause a runtime crash from queue assertion if any @Dependency is used in an AppIntent.

3 Likes

Define the @.Depdendency with a key and declare an async / @MainActor closure and pass that as the dependency, e.g.

     @Dependency(key: "Container")
     private var result: ModelContainer
@main
struct MyApp: App {
    
    init() {
        let asyncDependency: () async -> (ModelContainer) = { @MainActor in
            return ModelContainer.shared
        }
        AppDependencyManager.shared.add(key: "Container", dependency: asyncDependency)
        MyAppShortcuts.updateAppShortcutParameters()
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(ModelContainer.shared)
        }
    }
}

There are 3 .add(depdendency:) funcs that have the same param names (whose genius idea was that? ;-) and it seems to me the @autoclosure crash at runtime in Swift 6:

    final public func add<Dependency>(key: AnyHashable? = nil, dependency dependencyProvider: @autoclosure @escaping () -> Dependency) where Dependency : Sendable

    final public func add<Dependency>(key: AnyHashable? = nil, dependency dependencyProvider: @autoclosure @escaping () -> () throws -> Dependency) where Dependency : Sendable

    final public func add<Dependency>(key: AnyHashable? = nil, dependency dependencyProvider: @escaping () async throws -> Dependency) where Dependency : Sendable
1 Like

Using the async closure dependency adding should fix the issue, but I'm having to put this issue aside anyway because conformance to EntityPropertyQuery isn't Swift 6 compliant anyway.

I also ran into this issue. As far as I can see this is a bug in AppDependencyManager’s API definition, though fortunately there is an easy workaround.

The issue

Auto-closure variants of add are non-async. However they don’t require these to be @Sendable or otherwise specify their isolation rule. Therefore, any use of these closures apart from within the actor isolation they were created in is a violation of the API contract.

Now if we create this non-sendable closure with MainActor isolation (e.g. within App.init), the compiler will emit precondition checks in the closure to make sure it is always used on the MainActor. This causes the app to crash when AppIntents framework calls it from a background queue.

Note that we can see this issue even if we create a wrapper for add but give it the same signature (shim1 in sample code below).

Also note that the reason the async variant of the API does not have this problem (as mentioned previously) is because it has well-defined isolation rules.

I’ve raised a bug for this (FB15219224).

Workaround

As far as I understand, technically the violation is always there. But we can suppress the precondition check and therefore the crash in a couple of ways:

  1. Give enough information to the compiler that the closure will in fact needs to be @Sendable – See shim2.
  2. Only create the closure in a context that is not isolated to the main actor – See shim3.
  3. Somehow force the compiler to use the async version of the API as @prathameshk already mentioned.
@main
struct AppDependencyManagerBugApp: App {
    
    init() {
        let dependency = MyDependency()
        
        // Using this will cause a crash
        AppDependencyManager.shared.add(dependency: dependency)
        
        // Using this will cause a crash
//        AppDependencyManager.shared.shim1(dependency: dependency)
        
        // Using this will work fine
//        AppDependencyManager.shared.shim2(dependency: dependency)
        
        // Using this will work fine
//        AppDependencyManager.shared.shim3(dependency: dependency)
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

private extension AppDependencyManager {
    
    // Shim using exact declaration as ``AppDependencyManager.add``
    func shim1<Dependency>(key: AnyHashable? = nil, dependency dependencyProvider: @escaping () -> Dependency) where Dependency : Sendable {
        add(key: key, dependency: dependencyProvider())
    }
    
    // Shim using exact declaration as ``AppDependencyManager.add``, except that `dependencyProvider` is marked as `Sendable`
    func shim2<Dependency>(key: AnyHashable? = nil, dependency dependencyProvider: @Sendable @autoclosure @escaping () -> Dependency) where Dependency : Sendable {
        add(key: key, dependency: dependencyProvider())
    }
    
    // Shim without using an autoclosure
    func shim3<Dependency>(key: AnyHashable? = nil, dependency: Dependency) where Dependency : Sendable {
        add(key: key, dependency: dependency)
    }
    
}
3 Likes

Hey folks,

Just a quick updated: thanks to the team at Apple this is fixed now.

In the iOS 18.2 SDK (I believe even in 18.1), add(key:dependency:) has been updated to mark the closure parameter as @Sendable.

2 Likes