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.