Is this runtime crash related to incorrect executor assumption / inheritence?

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)