Dispatch Queue based Custom Executor of Actor does not work on iOS 17?

I have tried running Dispatch Queue based custom executor on Swift Actors but they seem to fail on iOS 17 devices at runtime. For instance I have this method assumeIsolated defined and it passes all checks on iOS 18 devices but fails on iOS 17 (crashes on line shared.preconditionIsolated() when called from a function that is invoked on globalQueue).

@globalActor
actor MyGlobalActor: GlobalActor {
    static let shared = MyGlobalActor()
    
    static let globalQueue = DispatchSerialQueue(label: "Global Queue")
    
    nonisolated
    var unownedExecutor: UnownedSerialExecutor {
        MyGlobalActor.cameraQueue.asUnownedSerialExecutor()
    }
    
    static func assumeIsolated<T : Sendable>(
        _ operation: @ MyGlobalActor () throws -> T
    ) rethrows -> T {
        typealias YesActor = @ MyGlobalActor () throws -> T
        typealias NoActor = () throws -> T

        shared.preconditionIsolated()
        
        // To do the unsafe cast, we have to pretend it's @escaping.
        return try withoutActuallyEscaping(operation) {
            (_ fn: @escaping YesActor) throws -> T in
            let rawFn = unsafeBitCast(fn, to: NoActor.self)
            return try rawFn()
        }
    }
}

Yes, because that’s a runtime feature implemented in swift-evolution/proposals/0424-custom-isolation-checking-for-serialexecutor.md at main · swiftlang/swift-evolution · GitHub and only available in recent runtimes.

Unless you mean something else, then please provide exact code snippet reproducing the issue you mean.

Hope this helps,

3 Likes

So there is no way to implement MyGlobalActor.assumeIsolated in iOS 17, correct?

Not quite, it’s possible but you’ll have to do some manual work that is done automatically on latest runtimes.

You have not shared the exact snippet of execution you’re asking about so I’ll guess you’re doing “async onto the queue and there try to assume isolated on the actor”. This specifically is a new feature.

  1. You can however put a Task on a specific queue using either actor isolation or task executor preferences and this way the assume checks work correctly. The new feature is specifically about allowing non swift concurrency executors, such as “random thread” and “just a queue but not on an executor that uses this queue” to work.

  2. You also can replace the precondition check with a direct use of the underlying dispatch queue like this: dispatchPrecondition(.onQueue(q)) and if that didn’t crash you can continue with the cast. This is what the new runtime does automatically, you’ll just have to make sure the actor you’re doing this on definitely is using a dispatch queue and not something else and check like that.

So the latter solution would work on all platforms.

Hope this clarifies and helps get it working on all platforms!

2 Likes

Ok here is the code snippet that fails on iOS 17. If you have any questions about the snippet, feel free to ask.

@preconcurrency import AVFoundation

@globalActor
actor CameraActor: GlobalActor {
    static let shared = CameraActor()
    
    static let cameraQueue = DispatchSerialQueue(label: "Camera Queue")
    
    nonisolated
    var unownedExecutor: UnownedSerialExecutor {
        CameraActor.cameraQueue.asUnownedSerialExecutor()
    }
    
    static func assumeIsolated<T : Sendable>(
        _ operation: @CameraActor () throws -> T
    ) rethrows -> T {
        typealias YesActor = @CameraActor () throws -> T
        typealias NoActor = () throws -> T

        shared.preconditionIsolated()
        
        // To do the unsafe cast, we have to pretend it's @escaping.
        return try withoutActuallyEscaping(operation) {
            (_ fn: @escaping YesActor) throws -> T in
            let rawFn = unsafeBitCast(fn, to: NoActor.self)
            return try rawFn()
        }
    }
}

@CameraActor final class TestCameraActor: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
    
    let output = AVCaptureVideoDataOutput()
    
    override init() {
        super.init()
        output.setSampleBufferDelegate(self, queue: CameraActor.cameraQueue)
    }
    
    func actorFunc(_ sampleBuffer:CMSampleBuffer) {
       
    }
    
    //This delegate is assured to be called on CameraActor.cameraQueue by the system
    nonisolated func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        CameraActor.assumeIsolated { //Crashes in the check
            self.actorFunc(sampleBuffer)
        }
    }
}

I tried this instead of shared.preconditionIsolated(), but I get runtime warnings and no "output" at all (i.e. video frames received on the delegate queue do not get processed).

`warning: data race detected: actor-isolated function at CaptureManager.swift:112 was not called on the same actor`
1 Like

This is not an answer, but rather just a minimal reproducible example of the problem manifested in iOS 17 simulator (to save others from having to introduce the AVCaptureVideoDataOutputSampleBufferDelegate dependency). I’m using Xcode 16.2, 16C5032a; Swift 6.

Consider the following GCD-based mock service:

protocol QueueBasedServiceDelegate: AnyObject {
    func didSend(_ value: Int) // will be called on `queue`
}

class QueueBasedService: @unchecked Sendable {
    private weak var delegate: QueueBasedServiceDelegate?
    private let queue: DispatchQueue
    private var timer: DispatchSourceTimer!
    private let lock = NSLock()

    init(queue: DispatchQueue, delegate: QueueBasedServiceDelegate) {
        self.queue = queue
        self.delegate = delegate
    }

    // Just send values 1, 2, etc. every second on `queue`

    func start() {
        let shouldProceed = lock.withLock {
            if timer != nil { return false }
            timer = DispatchSource.makeTimerSource(queue: queue)
            return true
        }
        guard shouldProceed else { return }

        timer.schedule(deadline: .now(), repeating: 1)

        var value = 0
        timer.setEventHandler { [weak self] in
            value += 1
            self?.delegate?.didSend(value)
        }

        timer.resume()
    }
}

I then defined a global actor with an assumeIsolated akin to the MainActor rendition:

/// A global actor isolated to a particular GCD dispatch queue.

@globalActor
actor DispatchQueueGlobalActor {
    static let shared = DispatchQueueGlobalActor()

    static let queue = DispatchSerialQueue(label: Bundle.main.bundleIdentifier! + ".DispatchQueueActor")

    nonisolated var unownedExecutor: UnownedSerialExecutor {
        Self.queue.asUnownedSerialExecutor()
    }

    /// Assume that the current task is executing on this global actor's
    /// serial executor, or stop program execution.
    ///
    /// This method allows to *assume and verify* that the currently
    /// executing synchronous function is actually executing on the serial
    /// executor of the `DispatchQueueActor`.
    ///
    /// If that is the case, the operation is invoked with an `isolated` version
    /// of the actor, / allowing synchronous access to actor local state without
    /// hopping through asynchronous boundaries.
    ///
    /// If the current context is not running on the actor's serial executor, or
    /// if the actor is a reference to a remote actor, this method will crash
    /// with a fatal error (similar to ``preconditionIsolated()``).
    ///
    /// This method can only be used from synchronous functions, as asynchronous
    /// functions should instead perform a normal method call to the actor, which
    /// will hop task execution to the target actor if necessary.
    ///
    /// - Note: This check is performed against the DispatchQueueActor’s serial executor,
    ///   meaning that / if another actor uses the same serial executor--by using
    ///   ``DispatchQueueActor/sharedUnownedExecutor`` as its own
    ///   ``Actor/unownedExecutor``--this check will succeed , as from a concurrency
    ///   safety perspective, the serial executor guarantees mutual exclusion of
    ///   those two actors.
    ///
    /// - Note: This is modeled after `MainActor.assumeIsolated`
    ///    found in the [swiftlang GitHub rep](https://github.com/swiftlang/swift/blob/380f319371dc9d50a98b2da5fba5affce3dcaf28/stdlib/public/Concurrency/MainActor.swift#L144).
    ///
    /// - Parameters:
    ///   - operation: the operation that will be executed if the current context
    ///                is executing on the DispatchQueueActor’s serial executor.
    /// - Returns: The return value of the `operation`.
    /// - Throws: Rethrows the `Error` thrown by the operation if it threw.

    static func assumeIsolated<T: Sendable>(
        _ operation: @DispatchQueueGlobalActor () throws -> T
    ) rethrows -> T {
        typealias YesActor = @DispatchQueueGlobalActor () throws -> T
        typealias NoActor = () throws -> T

        dispatchPrecondition(condition: .onQueue(Self.queue))

        return try withoutActuallyEscaping(operation) { (_ operation: @escaping YesActor) throws -> T in
            let rawOperation = unsafeBitCast(operation, to: NoActor.self)
            return try rawOperation()
        }
    }
}

(Note the use of dispatchPrecondition(condition: .onQueue(…)) rather than shared.preconditionIsolated() for iOS 17 compatibility.)

I then wrote a type, isolated to this DispatchQueueGlobalActor that updates an actor-isolated count of how many times the delegate method was called (this is just a trivial example of updating an actor-isolated property):

@DispatchQueueGlobalActor
class Foo {
    var count = 0
    var service: QueueBasedService!

    func experiment() {
        service = QueueBasedService(queue: DispatchQueueGlobalActor.queue, delegate: self)
        service.start()
    }
}

extension Foo: QueueBasedServiceDelegate {
    nonisolated func didSend(_ value: Int) {
        dispatchPrecondition(condition: .onQueue(DispatchQueueGlobalActor.queue)) // OK

        DispatchQueueGlobalActor.assumeIsolated {
            print(value)
            count += 1             // TSAN reports: “warning: data race detected: actor-isolated function at Demo/Foo.swift:15 was not called on the same actor”
        }
    }
}

That appears to work in iOS 18, but in iOS 17 it turns with the thread sanitizer worryingly throwing lots of warnings:

warning: data race detected: actor-isolated function at Demo/Foo.swift:15 was not called on the same actor
warning: data race detected: actor-isolated function at Demo/Foo.swift:27 was not called on the same actor

Using Swift 5 on iOS 17 there are no such warnings. I am not entirely sure whether this is a Swift 6 false-positive or a Swift 5 failure to detect the alleged race.


It gets even worse if I bypass my custom global actor and instead try to use the Actor built-in assumeIsolated with my own actor with a custom serial executor:

actor Bar {
    let queue = DispatchSerialQueue(label: Bundle.main.bundleIdentifier! + ".Bar")
    nonisolated var unownedExecutor: UnownedSerialExecutor {
        return queue.asUnownedSerialExecutor()
    }

    var count = 0
    var service: QueueBasedService!

    func experiment() {
        service = QueueBasedService(queue: queue, delegate: self)
        service.start()
    }
}

extension Bar: QueueBasedServiceDelegate {
    nonisolated func didSend(_ value: Int) {
        dispatchPrecondition(condition: .onQueue(queue)) // OK

        assumeIsolated { isolatedSelf in                 // Thread 3: Fatal error: Incorrect actor executor assumption; Expected same executor as Demo.Bar
            print(value)
            isolatedSelf.count += 1
        }
    }
}

This throws the fatal error, “Incorrect actor executor assumption” despite being on the same dispatch queue as the actor’s custom executor.

This is not an answer to the question, but merely a confirmation of the iOS 17 behaviors.

2 Likes