Swift: Conform Older Protocols to MainActor Isolation?
Asked Answered
T

3

9

Context

I have a Mac app that uses the old QuickLook protocols: QLPreviewPanelDataSource and QLPreviewPanelDelegate. You select a row in an NSTableView, hit the spacebar, and you get the QuickLook preview. Standard stuff:

@MainActor
final class SomeController: NSTableViewDataSource, QLPreviewPanelDataSource
{
    private var tableView: NSTableView

    func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int {
       return tableView.numberOfSelectedRows
    }
}

Problem

I'm adopting Actors and Swift Concurrency in this app. SomeController is now assigned to @MainActor as it should be, since it controls UI. But this brings warnings for the -numberOfPreviewItems() implementation:

Main actor-isolated instance method 'numberOfPreviewItems(in:)' cannot be used to satisfy nonisolated protocol requirement

QLPreviewPanelDataSource is not decorated with @MainActor, which would obviously solve the problem. I cannot simply mark the function nonisolated, since it touches tableView, which IS isolated to the Main Actor. And I cannot await an access, since the protocol method does not support concurrency.

Everything works fine, of course, but having 30-some spurious build warnings is a giant distraction.

Question

What is the correct way to silence these warnings assuming:

  1. Apple will never update the protocols with the @MainActor decoration. (Radars to do so are unanswered for years.)

  2. I want to keep the Strict Concurrency Checking build setting set to complete to catch other, legitimate issues.

Transverse answered 11/9, 2023 at 22:39 Comment(6)
Are you looking only to silence the warnings, or are you willing to refactor the code to make it technically more compliant with the required behavior? For instance, you could pull out the QLPreviewPanelDataSource conformance into a dedicated data-source type that isn't actor isolated, and set properties on it from within SomeController (e.g., on selection, assign tableView.numberOfSelectedRows as a property on the data source so it doesn't need to touch tableView; etc.).Active
I wouldn’t be so sure about your first assumption, but let’s set that aside. Have you tried the @preconcurrency macro? That’s what it’s for, namely frameworks not yet updated for Sendable. See WWDC 2022 Eliminate data races using Swift Concurrency: “you can temporarily disable the Sendable warnings for types that come from that module using the @preconcurrency attribute.”Pula
@rob Yes. I tried @preconcurrency on the import quartz line. Xcode tells me it’s unused and the warnings continue.Transverse
@ItaiFerber - That approach is doable, but it’s a lot of work/overhead and code bloat. There are LOTS of places in this app that support QuickLook, so wrapping them all would be tedious.Transverse
Yeah, it looks like @preconcurrency silences Sendable warnings, but not this actor isolation issue. I also tried putting this QLPreviewPanelDataSource conformance in an extension in its own file and tried overriding the strict-concurrency build setting in the build phases, but even that didn’t seem to work. Interesting question.Pula
@Pula - I'm pretty sure about my first assumption. Xcode 16 just shipped and whatever concurrency audit Apple performed across their frameworks didn't include Quartz. (I've been doing this a looooong time; you can pretty much never go wrong predicting that Apple Engineering is going to ignore Radars.)Transverse
O
6

Xcode 16 Update:

For this answer to work in Xcode 16 with Swift 6 Language Mode, it is now necessary to decorate the import statement:

@preconcurrency import Quartz

In older versions of Xcode, the @preconcurrency decorator was non-functional, but that appears to have changed in Xcode 16. Without this decorator, MainActor.assumeIsolated will produce Sendable errors.

Original Answer:

As you’ve pointed out, Apple has not isolated some legacy Objective-C classes, and given the requests have been open a while, it doesn’t seem like they will. Given this, it doesn’t seem possible to isolate an entire object without access to the object or protocol definition.

To address this isolation issue, Apple has provided this. I found this after running across this question when I had a similar issue

MainActor.assumeIsolated {}

I was building an ARKit feature and needed to conform to ARSCNViewDelegate, which is locked to the main thread in Objective-C, but considered nonisolated when it came to Concurrency. Since the delegate method returns a value, I couldn't wrap the body in a Task.

This is what worked for me:

@MainActor
class MyViewController: UIViewController, ARSCNViewDelegate 
{
    // MARK: ARSCNViewDelegate methods

    nonisolated func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
        return MainActor.assumeIsolated {
            // Assumed to be isolated to MainActor
            let node = SCNNode() // This line refuses to compile if not isolated since `SCNNode` is isolated to the MainActor.
            // Configure node and return.
            return node
        }
    }
}

No warnings, no errors.

Orchardman answered 6/1, 2024 at 6:46 Comment(7)
I think, this is a valid workaround for the described issue. Not sure, why it has been downvoted. If this workaround is not possible in other scenarios, a technical correct solution would be to attempt to transform the values returned from the nonisolated delegate function to sendable values – still on the caller provided thread – and then using MainActor or a Task to switch to the main thread, passing the sendable values to the main actor isolated target. Note, that the latter cannot be used with delegates returning values.Corsetti
This is what I tried at first, but (as you said) since this delegate method returns a value the scope couldn't stay structured/isolated. Thus the need to "assume" that it isolated (cause it's locked in objective c to "main"!) Thanks CouchDeveloper <3Orchardman
Looks interesting! I'm not keen to pay a runtime penalty just to snuff out some annoying IDE warnings, though. In your case, you had to use this API to compile. In mine, I'm just flooded with spurious warnings.Transverse
@Transverse , you can also silence warnings in your code with the suggest solution from Brianna Doubt. I added an answer, but credit goes to Brianna.Corsetti
@Corsetti - thanks, but that still doesn't avoid the runtime overhead.Transverse
@Transverse - You didn’t mention any runtime overhead until these comments on an answer that works. This answer gives a first-party solution that yields no warnings with strict concurrency. What else do you want? HahahaOrchardman
@BriannaDoubt - Yea, I don't have a better solution and I'm tired of seeing 47 stupid build warnings, so the hacky workaround will have to suffice. Thanks!Transverse
C
1

As Brianna Doubt already figured out in her answer (do not credit me!), one can use MainActor\assumeIsolated(_:file:line:) to silence the the compiler warnings:

In your main actor isolated class, define the delegate function as follows:

    nonisolated
    func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int {
        assert(Thread.isMainThread)
        MainActor.assumeIsolated {
            return tableView.numberOfSelectedRows
        }
    }

The code above makes the assumption, that the delegate is called on the main thread. Since we cannot control the caller of the delegate, we cannot guarantee that this is actually the case. But at least, we can test it.

The assert(Thread.isMainThread) will catch any violation of our assumption in Debug configuration. However, assumeIsolated(...) will crash anyway if the code will not run on the main thread. So, the explicit assert is more for documentation making it double clear what we assume.

Then, MainActor.assumeIsolated(..) will silence the warnings.

Consider this as a workaround. Actually, Apple should address this issue and make it conform the concurrency rules.

Side notes:

You possibly do not need to make the whole class MyViewController isolated to the main actor. Possibly, there are other workarounds in your code.

Regarding your statement: "Apple will never update the protocols with the @MainActor decoration":

Possibly, they can't due to breaking APIs.

The other reason could be, that Apple don't want to isolate the delegate to the main actor, which makes sense for "data oriented" APIs. Either way, we don't know.

Corsetti answered 8/1, 2024 at 9:31 Comment(0)
S
0

So using MainActor.assumeIsolated caused crashes for me because the callback wasn't coming on the main queue. Tested on IOS 18 Iphone and Xcode 16.

This worked however:

@MainActor
final class Coordinator: NSObject, ARSCNViewDelegate, Sendable {
    nonisolated func renderer(_: any SCNSceneRenderer, nodeFor _: ARAnchor) -> SCNNode? {
        DispatchQueue.main.sync {
            SCNNode()
        }
    }

}
Stagy answered 13/9, 2024 at 12:17 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.