MTKView draw method causes EXC_BAD_ACCESS crash

Hello, I am using MTKView to display: camera preview & video playback. I am testing on iPhone 16. App crashes at a random moment whenever MTKView is rendering CIImage.

MetalView:

public enum MetalActionType {
  case image(CIImage)
  case buffer(CVPixelBuffer)
}

public struct MetalView: UIViewRepresentable {

  let mtkView = MTKView()
  public let actionPublisher: any Publisher<MetalActionType, Never>

  public func makeCoordinator() -> Coordinator {
    Coordinator(self)
  }

  public func makeUIView(context: UIViewRepresentableContext<MetalView>) -> MTKView {

    guard let metalDevice = MTLCreateSystemDefaultDevice() else {
      return mtkView
    }
    mtkView.device = metalDevice
    mtkView.framebufferOnly = false
    mtkView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0)
    mtkView.drawableSize = mtkView.frame.size
    mtkView.delegate = context.coordinator
    mtkView.isPaused = true
    mtkView.enableSetNeedsDisplay = true
    mtkView.preferredFramesPerSecond = 60

    context.coordinator.ciContext = CIContext(
      mtlDevice: metalDevice, options: [.priorityRequestLow: true, .highQualityDownsample: false])
    context.coordinator.metalCommandQueue = metalDevice.makeCommandQueue()
    context.coordinator.actionSubscriber = actionPublisher.sink { type in
      switch type {
      case .buffer(let pixelBuffer):
        context.coordinator.updateCIImage(pixelBuffer)
        break
      case .image(let image):
        context.coordinator.updateCIImage(image)
        break
      }
    }

    return mtkView
  }
  public func updateUIView(_ nsView: MTKView, context: UIViewRepresentableContext<MetalView>) {

  }

  public class Coordinator: NSObject, MTKViewDelegate {
    var parent: MetalView
    var metalCommandQueue: MTLCommandQueue!
    var ciContext: CIContext!
    private var image: CIImage? {
      didSet {
        Task { @MainActor in
          self.parent.mtkView.setNeedsDisplay()  //<--- call Draw method
        }
      }
    }
    var actionSubscriber: (any Combine.Cancellable)?

    private let operationQueue = OperationQueue()

    init(_ parent: MetalView) {
      self.parent = parent
      operationQueue.qualityOfService = .background
      super.init()
    }

    public func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
    }

    public func draw(in view: MTKView) {

      guard let drawable = view.currentDrawable, let ciImage = image,
        let commandBuffer = metalCommandQueue.makeCommandBuffer(), let ci = ciContext
      else {
        return
      }
      //making sure nothing is nil, now we can add the current frame to the operationQueue for processing
      operationQueue.addOperation(
        MetalOperation(
          drawable: drawable, drawableSize: view.drawableSize, ciImage: ciImage,
          commandBuffer: commandBuffer, pixelFormat: view.colorPixelFormat, ciContext: ci))

    }

    //consumed by Subscriber
    func updateCIImage(_ img: CIImage) {
      image = img
    }

    //consumed by Subscriber
    func updateCIImage(_ buffer: CVPixelBuffer) {
      image = CIImage(cvPixelBuffer: buffer)
    }
  }
}

now the MetalOperation class:

private class MetalOperation: Operation, @unchecked Sendable {
  let drawable: CAMetalDrawable
  let drawableSize: CGSize
  let ciImage: CIImage
  let commandBuffer: MTLCommandBuffer
  let pixelFormat: MTLPixelFormat
  let ciContext: CIContext

  init(
    drawable: CAMetalDrawable, drawableSize: CGSize, ciImage: CIImage,
    commandBuffer: MTLCommandBuffer, pixelFormat: MTLPixelFormat, ciContext: CIContext
  ) {
    self.drawable = drawable
    self.drawableSize = drawableSize
    self.ciImage = ciImage
    self.commandBuffer = commandBuffer
    self.pixelFormat = pixelFormat
    self.ciContext = ciContext
  }

  override func main() {

    let width = Int(drawableSize.width)
    let height = Int(drawableSize.height)

    let ciWidth = Int(ciImage.extent.width)  //<-- Thread 22: EXC_BAD_ACCESS (code=1, address=0x5e71f5490) A bad access to memory terminated the process.
    let ciHeight = Int(ciImage.extent.height)
    let destination = CIRenderDestination(
      width: width, height: height, pixelFormat: pixelFormat, commandBuffer: commandBuffer,
      mtlTextureProvider: { [self] () -> MTLTexture in
        return drawable.texture
      })

    let transform = CGAffineTransform(
      scaleX: CGFloat(width) / CGFloat(ciWidth), y: CGFloat(height) / CGFloat(ciHeight))
    do {
      try ciContext.startTask(toClear: destination)
      try ciContext.startTask(toRender: ciImage.transformed(by: transform), to: destination)
    } catch {

    }

    commandBuffer.present(drawable)
    commandBuffer.commit()
    commandBuffer.waitUntilCompleted()
  }
}

Now I am no Metal expert, but I believe it's a very simple execution that shouldn't cause memory leak especially after we have already checked for whether CIImage is nil or not. I have also tried running this code without OperationQueue and also tried with @autoreleasepool but none of them has solved this problem.

Am I missing something?

It could be a problem that you are getting the currentDrawable outside the Operation. If your rendering can't keep up with the draw requests, the operations will pile up while also holding locks on the view's drawable.

Instead, you could get the view's drawable inside of the operation and discard the whole draw operation if the view is not yet read.

MTKView draw method causes EXC_BAD_ACCESS crash
 
 
Q