ARKit delegate code broken by Swift 6

I'm porting over some code that uses ARKit to Swift 6 (with Complete Strict Concurrency Checking enabled).

Some methods on ARSCNViewDelegate, namely Coordinator.renderer(_:didAdd:for:) among at least one other is causing a consistent crash. On Swift 5 this code works absolutely fine.

The above method consistently crashes with _dispatch_assert_queue_fail. My assumption is that in Swift 6 a trap has been inserted by the compiler to validate that my downstream code is running on the main thread.

In Implementing a Main Actor Protocol That’s Not @MainActor, Quinn “The Eskimo!” seems to address scenarios of this nature with 3 proposed workarounds yet none of them seem feasible here.

  • For #1, marking ContentView.addPlane(renderer:node:anchor:) nonisolated and using @preconcurrency import ARKit compiles but still crashes :(
  • For #2, applying @preconcurrency to the ARSCNViewDelegate conformance declaration site just yields this warning: @preconcurrency attribute on conformance to 'ARSCNViewDelegate' has no effect
  • For #3, as Quinn recognizes, this is a non-starter as ARSCNViewDelegate is out of our control.

The minimal reproducible set of code is below. Simply run the app, scan your camera back and forth across a well lit environment and the app should crash within a few seconds. Switch over to Swift Language Version 5 in build settings, retry and you'll see the current code works fine.

import ARKit
import SwiftUI

struct ContentView: View {
    @State private var arViewProxy = ARSceneProxy()
        
    private let configuration: ARWorldTrackingConfiguration
    
    @State private var planeFound = false
    
    init() {
        configuration = ARWorldTrackingConfiguration()
        configuration.worldAlignment = .gravityAndHeading
        configuration.planeDetection = [.horizontal]
    }
    
    var body: some View {
        ARScene(proxy: arViewProxy)
            .onAddNode { renderer, node, anchor in
                addPlane(renderer: renderer, node: node, anchor: anchor)
            }
            .onAppear {
                arViewProxy.session.run(configuration)
            }
            .onDisappear {
                arViewProxy.session.pause()
            }
            .overlay(alignment: .top) {
                if !planeFound {
                    Text("Slowly move device horizontally side to side to calibrate")
                } else {
                    Text("Plane found!")
                        .bold()
                        .foregroundStyle(.green)
                }
            }
    }
    
    private func addPlane(renderer: SCNSceneRenderer, node: SCNNode, anchor: ARAnchor) {
        guard let planeAnchor = anchor as? ARPlaneAnchor,
              let device = renderer.device,
              let planeGeometry = ARSCNPlaneGeometry(device: device)
        else { return }
        
        planeFound = true
        
        planeGeometry.update(from: planeAnchor.geometry)
        
        let material = SCNMaterial()
        material.isDoubleSided = true
        material.diffuse.contents = UIColor.white.withAlphaComponent(0.65)
        planeGeometry.materials = [material]
        
        let planeNode = SCNNode(geometry: planeGeometry)
        
        node.addChildNode(planeNode)
    }
}

struct ARScene {
    private(set) var onAddNodeAction: ((SCNSceneRenderer, SCNNode, ARAnchor) -> Void)?
    
    private let proxy: ARSceneProxy
    
    init(proxy: ARSceneProxy) {
        self.proxy = proxy
    }
    
    func onAddNode(
        perform action: @escaping (SCNSceneRenderer, SCNNode, ARAnchor) -> Void
    ) -> Self {
        var view = self
        view.onAddNodeAction = action
        return view
    }
}

extension ARScene: UIViewRepresentable {
    func makeUIView(context: Context) -> ARSCNView {
        let arView = ARSCNView()
        arView.delegate = context.coordinator
        arView.session.delegate = context.coordinator
        
        proxy.arView = arView
        return arView
    }
    
    func updateUIView(_ uiView: ARSCNView, context: Context) {
        context.coordinator.onAddNodeAction = onAddNodeAction
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
}

extension ARScene {
    class Coordinator: NSObject, ARSCNViewDelegate, ARSessionDelegate {
        var onAddNodeAction: ((SCNSceneRenderer, SCNNode, ARAnchor) -> Void)?
        
        func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
            onAddNodeAction?(renderer, node, anchor)
        }
    }
}

@MainActor
class ARSceneProxy: NSObject, @preconcurrency ARSessionProviding {
    fileprivate var arView: ARSCNView!
    
    @objc dynamic var session: ARSession {
        arView.session
    }
}

Any help is greatly appreciated!

We've found a viable temporary workaround through an @unchecked Sendable container and coordination via an OSAllocatedUnfairLock

ARKit delegate code broken by Swift 6
 
 
Q