Issue #1037
Enabling Swift 6 strict concurrency checking (SWIFT_STRICT_CONCURRENCY = complete) catches data races at compile time, but it does not protect you fully at runtime. The compiler also injects dynamic isolation assertions at actor and GCD boundaries. These fire in production, not just in your test suite, often at callsites that produced no compiler warning at all.
The two crash symbols you will see in crash reports are
_dispatch_assert_queue_fail, which fires when code expected a specific dispatch queue but ran on a different one_swift_task_checkIsolatedSwift, which fires when code expected actor isolation (for example,@MainActor) but ran outside it.
Both originate from the same root cause: a piece of code inherited actor isolation from its definition context and was then called from a different thread.
How runtime assertions are injected
The compiler cannot always statically prove which thread a closure will run on. When isolation is ambiguous, it inserts a runtime check rather than a compile-time error. If the check fails, the process traps immediately.
This is distinct from a compile-time diagnostic. You can have a fully warning-free build and still hit these assertions in production if your execution paths do not match what the compiler assumed when it generated the assertions. The assertions are usually correct about the violation, but the compiler is sometimes overly aggressive about where it injects them.
Closures inheriting actor isolation
A closure defined inside an @MainActor-isolated context inherits that isolation. The compiler marks it as main-actor-isolated and inserts an assertion that it runs on the main actor. If something else calls that closure on a background thread, the assertion fires.
The clearest example is Core Data’s perform block. If you define a closure inside a @MainActor-isolated method and pass it to context.perform, Core Data calls it on its private background queue, not the main thread.
@MainActor
class ContactsViewModel {
func deleteAll(context: NSManagedObjectContext) {
context.perform {
// This closure inherits @MainActor isolation.
// Core Data calls it on its private background queue.
// Crashes with _dispatch_assert_queue_fail.
let request = NSFetchRequest<Contact>(entityName: "Contact")
let contacts = try? context.fetch(request)
contacts?.forEach { contact in
context.delete(contact)
}
}
}
}
Marking the closure @Sendable breaks the isolation inheritance. A @Sendable closure has no implied actor context, so the runtime assertion is not injected.
context.perform { @Sendable in
let request = NSFetchRequest<Contact>(entityName: "Contact")
let contacts = try? context.fetch(request)
contacts?.forEach { contact in
context.delete(contact)
}
}
The same pattern appears in Combine pipelines. A map closure defined inside a @MainActor-isolated method inherits main-actor isolation. If the publisher emits on a background thread, map runs there and the assertion fires before receive(on: DispatchQueue.main) has a chance to route execution back to the main thread.
@MainActor
class SearchViewModel {
func subscribe() {
searchPublisher
.map { value in // inherits @MainActor isolation from subscribe()
value.lowercased()
}
.receive(on: DispatchQueue.main)
.sink { value in
self.results = value
}
.store(in: &cancellables)
}
}
The fix is to move receive(on:) before any operator whose closure could inherit isolation, so the thread hop happens first.
searchPublisher
.receive(on: DispatchQueue.main) // hop to main before any isolated closures
.map { value in
value.lowercased()
}
.sink { value in
self.results = value
}
.store(in: &cancellables)
Alternatively, if you need the operator to run off the main thread, mark its closure @Sendable to explicitly remove the isolation.
searchPublisher
.map { @Sendable value in // no actor isolation inherited
value.lowercased()
}
.receive(on: DispatchQueue.main)
.sink { value in
self.results = value
}
.store(in: &cancellables)
Actor-isolated delegate methods called from the wrong thread
When an entire class is @MainActor-isolated, all its methods inherit that isolation, including delegate methods. If an SDK calls one of those methods from its own internal queue, the runtime checks for main-actor isolation and finds it missing.
A concrete example is NSDocument. AppKit calls autosavesInPlace from a background queue during the autosave cycle. In Swift 6 mode, because the class is @MainActor-isolated, that override inherits the isolation and the assertion fires.
@MainActor
class MyDocument: NSDocument {
// AppKit calls this from a background queue during autosave.
// Inherits @MainActor isolation and crashes with _swift_task_checkIsolatedSwift.
override class var autosavesInPlace: Bool {
true
}
}
The fix is to mark only that specific method nonisolated, leaving the rest of the class on the main actor.
override nonisolated class var autosavesInPlace: Bool {
true
}
The same issue appears with any SDK delegate whose callbacks arrive on a non-main thread. CLLocationManagerDelegate is a common example: the framework delivers location updates on its own internal queue.
@MainActor
class LocationManager: NSObject, CLLocationManagerDelegate {
func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
// CLLocationManager calls this on its internal queue.
// @MainActor isolation inherited, crash on arrival.
updateMap(with: locations)
}
}
Mark the delegate method nonisolated so it can receive the call on any thread, then dispatch the UI work back to the main actor explicitly.
nonisolated func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
Task { @MainActor in
self.updateMap(with: locations)
}
}
The same pattern applies to AVAudioPlayerDelegate, WKNavigationDelegate, and any other SDK delegate that does not document delivering callbacks on the main thread.
Other scenarios worth knowing
MainActor.assumeIsolated crashes immediately if the code is not actually running on the main actor. It is a bridge intended only for legacy code you know to be on the main thread. Using it as a convenient alternative to await MainActor.run in async contexts will cause crashes whenever the assumption is wrong.
Combine notification observers can crash in a similar way to the pipeline example above. If a notification is posted from a background thread inside an @MainActor-isolated class, the sink closure inherits main-actor isolation and the assertion fires.
NotificationCenter.default.publisher(for: .didRefresh)
.sink { [weak self] _ in
self?.reload() // inherits @MainActor isolation, crashes if posted off-main
}
.store(in: &cancellables)
The fix is the same: insert .receive(on: DispatchQueue.main) before sink.
Actor reentrancy is a softer failure but worth noting. After an await inside an actor method, the actor is unlocked and other tasks can mutate its state. State captured before the suspension point may no longer be valid after it. This rarely causes an immediate crash but can lead to precondition failures or corrupted state in code that depends on consistency across the suspension.
The common thread across all of these is that Swift 6 mode has changed the behavior: in Swift 5 mode, threading violations would silently execute on the wrong thread. In Swift 6 mode, the runtime preemptively crashes rather than continuing in a potentially unsafe state. Manual testing with real SDK callbacks and background-thread publishers is the only reliable way to find these before your users do.
Start the conversation