CATransaction commit [Crashed: com.apple.root.user-initiated-qos.cooperative]

Description

We are developing a app for iOS and iPadOS that involves extensive custom drawing of paths, shapes, texts, etc. To improve drawing and rendering speed, we use CARenderer to generate cached images (CGImage) on a background thread. We adopted this approach based on this StackOverflow post: https://stackoverflow.com/a/75497329/9202699.

However, we are experiencing frequent crashes in our production environment that we cannot reproduce in our development environment. Despite months of debugging and seeking support from DTS and the Apple Feedback platform, we have not been able to fully resolve this issue. Our recent crash reports indicate that the crashes occur when calling CATransaction.commit().

Crash traceback

The method names in this traceback are mapped to those in the code sample below. The app name has been masked.

          Crashed: com.apple.root.user-initiated-qos.cooperative

0  MyApp                          0x887408 specialized static CAUtils.commitCATransaction() + 4340151304 (<compiler-generated>:4340151304)
1  MyApp                          0x887408 specialized static CAUtils.commitCATransaction() + 4340151304 (<compiler-generated>:4340151304)
2  MyApp                          0x8874a4 specialized static CAUtils.addDrawingItemsToRenderer(***) + 250 (CAUtils.swift:250)
3  MyApp                          0x887710 specialized static CAUtils.drawOnCGImageWithCARenderer(***) + 267 (CAUtils.swift:267)
4  MyApp                          0x8878c0 specialized static CAUtils.drawOnCGImageWithCARendererWithRetry(***) + 315 (CAUtils.swift:315)
5  MyApp                          0x736294 XXXManager.generateCGImages(***) + 570 (XXXManager.swift:570)
6  MyApp                          0x73404c closure #1 in XXXManager.updateCachedCGImages(***) + 427 (XXXManager.swift:427)
7  libswift_Concurrency.dylib     0x61104 swift::runJobInEstablishedExecutorContext(swift::Job*) + 252
8  libswift_Concurrency.dylib     0x62514 swift_job_runImpl(swift::Job*, swift::SerialExecutorRef) + 144
9  libdispatch.dylib              0x15d8c _dispatch_root_queue_drain + 392
10 libdispatch.dylib              0x16590 _dispatch_worker_thread2 + 156
11 libsystem_pthread.dylib        0x4c40 _pthread_wqthread + 228
12 libsystem_pthread.dylib        0x1488 start_wqthread + 8

Code Sample

Below is a sample of our code. While the complete snippet is too long, the issue occurs in addDrawingItemsToRenderer. Please refer to the other methods for completeness and reference purposes.

private let transactionLock = NSLock()
private let deviceLock = NSLock()
private let device = MTLCreateSystemDefaultDevice()!

/// This is the method we call from outside.
@inline(never)
static func drawOnCGImageWithCARenderer(
  layerRect: CGRect,
  drawingItems: [DrawingItem]
)
  -> CGImage? {
  guard
    let (texture, renderer) = addDrawingItemsToRenderer(
      layerRect: layerRect,
      drawingItems: drawingItems
    ) else {
    return nil
  }

  renderer.beginFrame(atTime: 0, timeStamp: nil)
  renderer.render()
  renderer.endFrame()

  guard
    let colorSpace = CGColorSpace(name: CGColorSpace.sRGB),
    let ciImage = CIImage(mtlTexture: texture, options: [.colorSpace: colorSpace]) else {
    return nil
  }

  let context = CIContext()
  return context.createCGImage(ciImage, from: ciImage.extent)
}

/// This is the method will the crash happens
@inline(never)
fileprivate static func addDrawingItemsToRenderer(
  layerRect: CGRect,
  drawingItems: [DrawingItem]
)
  -> (MTLTexture, CARenderer)? {
  // We have encapsulated everything related to CALayer and its 
  // associated creations and manipulations within CATransaction
  // as suggested by engineers from Apple Feedback Portal.
  beginCATransaction()
  defer {
    commitCATransaction() // The crash happens here
  }

  let (layer, imageWidth, imageHeight) =
    addDrawingItemsToLayer(layerRect: layerRect, drawingItems: drawingItems)

  return createTextureAndRenderer(
    layer: layer,
    imageWidth: imageWidth,
    imageHeight: imageHeight
  )
}

// Below are all internal methods. We have split the method into very 
// granular parts and marked them as @inline(never) to prevent the 
// compiler from inlining our code, which may otherwise obscure usage 
// trackback information in our crash reports.

@inline(never)
fileprivate static func beginCATransaction() {
  transactionLock.lock()
  CATransaction.begin()
}

@inline(never)
fileprivate static func commitCATransaction() {
  // From our crash report, we believe the crash happens on this line.
  CATransaction.commit()
  // It is unlikely that the lock cause the crash as we added it only recently 
  // to ensure that there is only one transaction on our background thread, 
  // and after we added this lock, the crash rate indeed lowered, but still 
  // not fully disappear
  transactionLock.unlock()
}

--------------------------------

// The methods below are provided for reference and completeness. While // they may have issues, they do not frequently appear in our crash
// reports as the one caused by `CATransaction.commit()`

@inline(never)
fileprivate static func addDrawingItemsToLayer(
  layerRect: CGRect,
  drawingItems: [DrawingItem]
)
  -> (layer: CALayer, imageWidth: CGFloat, imageHeight: CGFloat) {
  let layer = CALayer()
  layer.isGeometryFlipped = SharedAppUtils.isIOS
  layer.anchorPoint = CGPoint.zero
  layer.bounds = layerRect
  layer.masksToBounds = true

  for drawingItem in drawingItems {
    // We have thousands or hundred thousands of drawing items to add.
    // Each drawing item may produce a CALayer, CAShapeLayer or CATextLayer.
    // This is also why we want to utilise CARenderer to leverage GPU rendering.
    let sublayerForDrawingItem =
        drawingItem.createCALayerOrCATextLayerOrCAShapeLayer()
    layer.addSublayer(sublayerForDrawingItem)
  }

  let imageWidth = max(1, layer.frame.size.width * UIScreen.main.scale)
  let imageHeight = max(1, layer.frame.size.height * UIScreen.main.scale)
  layer.transform = CATransform3DMakeScale(UIScreen.main.scale, UIScreen.main.scale, 1)
  layer.frame = .init(origin: .zero, size: .init(width: imageWidth, height: imageHeight))

  return (layer, imageWidth, imageHeight)
}

@inline(never)
fileprivate static func createTextureAndRenderer(
  layer: CALayer,
  imageWidth: CGFloat,
  imageHeight: CGFloat
)
  -> (MTLTexture, CARenderer)? {
  deviceLock.lock()
  defer {
    deviceLock.unlock()
  }

  let textureDescriptor = MTLTextureDescriptor.texture2DDescriptor(
    pixelFormat: .rgba8Unorm,
    width: Int(imageWidth),
    height: Int(imageHeight),
    mipmapped: false
  )
  textureDescriptor.usage = [MTLTextureUsage.shaderRead, .shaderWrite, .renderTarget]

  guard
    let texture = device.makeTexture(descriptor: textureDescriptor) else {
    return nil
  }

  let renderer = CARenderer(mtlTexture: texture)
  renderer.bounds = layer.frame
  renderer.layer = layer.self

  return (texture, renderer)
}

Please note that the method is called on a background thread, and therefore, the crash occurs on the background thread as well.

CATransaction commit [Crashed: com.apple.root.user-initiated-qos.cooperative]
 
 
Q