Recently I've run into a frustrating runtime crash that I believe may be related to a bug in the current Swift language version, but before I file a report I wanted to see if folks here could help check my understanding.
In our app we rely heavily on NSOperationQueue to help us manage more expensive tasks. Consider the following code:
public class DownloadAppsOperation: MyOperation, @unchecked Sendable {
let adapter = AppStoreAdapter()
var error: Error?
override public func start() {
super.start()
Task.detached {
do {
try await self.adapter.persistApps()
} catch {
self.error = error
}
self.signalCompletionToOperationQueue()
}
}
public nonisolated static func perform() async throws {
let operation = DownloadAppsOperation()
Persistence.shared.operationQueue.standardOperationQueue.addOperation(operation)
return try await withCheckedThrowingContinuation(isolation: nil) { continuation in
operation.completionBlock = {
if let error = operation.error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
}
Call site in the controller:
Task.detached {
try? await DownloadAppsOperation.perform()
}
I have found that when the the thread pool is heavily saturated (first install), completionBlock
will occasionally gain @MainActor
attribution, despite the call site being detached, the checked continuation not specifying isolation, the perform method being nonisolated
, and the completionBlock
also not being @MainActor
. When this occurs during runtime, we get a crash after the operation is signaled complete and the completionBlock
tries to run, because a @MainActor
context is incorrectly expected when there isn't one.
I have verified this with the following relevant part of the call stack
Controllers`partial apply for closure #1 in closure #1 in static DownloadAppsOperation.perform():
0x10c2929d4 <+0>: sub sp, sp, #0x20
0x10c2929d8 <+4>: stp x29, x30, [sp, #0x10]
0x10c2929dc <+8>: add x29, sp, #0x10
0x10c2929e0 <+12>: mov x0, x30
0x10c2929e4 <+16>: bl 0x10d18ef0c ; symbol stub for: __tsan_func_entry
0x10c2929e8 <+20>: adrp x0, 4740
0x10c2929ec <+24>: add x0, x0, #0x400 ; demangling cache variable for type metadata for Swift.CheckedContinuation<(), Swift.Error>
0x10c2929f0 <+28>: bl 0x10c216b00 ; __swift_instantiateConcreteTypeFromMangledName at <compiler-generated>
0x10c2929f4 <+32>: ldur x8, [x0, #-0x8]
0x10c2929f8 <+36>: ldrb w8, [x8, #0x50]
0x10c2929fc <+40>: mov x9, x8
0x10c292a00 <+44>: add x8, x9, #0x18
0x10c292a04 <+48>: bic x8, x8, x9
0x10c292a08 <+52>: ldr x0, [x20, #0x10]
0x10c292a0c <+56>: add x1, x20, x8
0x10c292a10 <+60>: bl 0x10c290208 ; closure #1 @Swift.MainActor @Sendable () -> () in closure #1 (Swift.CheckedContinuation<(), Swift.Error>) -> () in static Controllers.DownloadAppsOperation.perform() async throws -> () at AppStoreDetailListCollectionViewController.swift:153
-> 0x10c292a14 <+64>: b 0x10c292a18 ; <+68> at <compiler-generated>
0x10c292a18 <+68>: bl 0x10d18ef18 ; symbol stub for: __tsan_func_exit
0x10c292a1c <+72>: ldp x29, x30, [sp, #0x10]
0x10c292a20 <+76>: add sp, sp, #0x20
0x10c292a24 <+80>: ret
0x10c292a28 <+84>: str x0, [sp, #0x8]
0x10c292a2c <+88>: bl 0x10d18ef18 ; symbol stub for: __tsan_func_exit
0x10c292a30 <+92>: ldr x0, [sp, #0x8]
0x10c292a34 <+96>: bl 0x10d18eea0 ; symbol stub for: _Unwind_Resume
Once I discovered what was happening, I modified the operation this way, and I get no such crash.
...
nonisolated private func completeOperation(continuation: CheckedContinuation<(), any Error>) -> (@Sendable () -> Void)? {
let error = self.error
return {
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
public nonisolated static func perform() async throws {
let operation = DownloadAppsOperation()
Persistence.shared.operationQueue.standardOperationQueue.addOperation(operation)
return try await withCheckedThrowingContinuation(isolation: nil) { continuation in
operation.completionBlock = operation.completeOperation(continuation: continuation)
}
}
Here is part of the call stack from the workaround, which as you can see, never gets assigned @MainActor
Controllers`partial apply for closure #1 in DownloadAppsOperation.completeOperation(continuation:):
0x10abfcdbc <+0>: sub sp, sp, #0x20
0x10abfcdc0 <+4>: stp x29, x30, [sp, #0x10]
0x10abfcdc4 <+8>: add x29, sp, #0x10
0x10abfcdc8 <+12>: mov x0, x30
0x10abfcdcc <+16>: bl 0x10bafafbc ; symbol stub for: __tsan_func_entry
0x10abfcdd0 <+20>: adrp x0, 4742
0x10abfcdd4 <+24>: add x0, x0, #0x400 ; demangling cache variable for type metadata for Swift.CheckedContinuation<(), Swift.Error>
0x10abfcdd8 <+28>: bl 0x10ab82f48 ; __swift_instantiateConcreteTypeFromMangledName at <compiler-generated>
0x10abfcddc <+32>: ldur x8, [x0, #-0x8]
0x10abfcde0 <+36>: ldrb w8, [x8, #0x50]
0x10abfcde4 <+40>: mov x9, x8
0x10abfcde8 <+44>: add x8, x9, #0x18
0x10abfcdec <+48>: bic x8, x8, x9
0x10abfcdf0 <+52>: ldr x0, [x20, #0x10]
0x10abfcdf4 <+56>: add x1, x20, x8
0x10abfcdf8 <+60>: bl 0x10abfc18c ; closure #1 @Sendable () -> () in Controllers.DownloadAppsOperation.completeOperation(continuation: Swift.CheckedContinuation<(), Swift.Error>) -> Swift.Optional<@Sendable () -> ()> at AppStoreDetailListCollectionViewController.swift:139
-> 0x10abfcdfc <+64>: b 0x10abfce00 ; <+68> at <compiler-generated>
0x10abfce00 <+68>: bl 0x10bafafc8 ; symbol stub for: __tsan_func_exit
0x10abfce04 <+72>: ldp x29, x30, [sp, #0x10]
0x10abfce08 <+76>: add sp, sp, #0x20
0x10abfce0c <+80>: ret
0x10abfce10 <+84>: str x0, [sp, #0x8]
0x10abfce14 <+88>: bl 0x10bafafc8 ; symbol stub for: __tsan_func_exit
0x10abfce18 <+92>: ldr x0, [sp, #0x8]
0x10abfce1c <+96>: bl 0x10bafaf50 ; symbol stub for: _Unwind_Resume
My assumption here is that ether
A) something deep in Foundation is causing the completionBlock to rarely get called on the main thread, and at runtime, an incorrect executor assumption is observed, resulting in a crash
or
B) this is related to static methods being less / not able to correctly assign isolation context when that context is copied into withCheckedThrowingContinuation
, and this could be a Swift compiler / runtime bug.
It seems to me that marking the static method as nonisolated
and passing nil
to the isolation in withCheckedThrowingContinuation
should absolutely guarantee at runtime that completionBlock
should never expect @MainActor
but perhaps my understanding is incorrect here.
If anyone can help shed some light on whether this is a me problem, a language issue, or a bug in Foundation, it would be much appreciated! Thanks so much!
Environment
- Build Setting: Swift 6 mode | Strict Concurrency Checking > Complete
- Xcode: Version 16.1 (16B40)
- Swift 6.0.2 (6.0.2.1.2)
- macOS: 15.0 (24A335)