Why can't I send an NSImage across actor boundaries?

@OneSadCookie not sure what I'm missing then.

This is my function

public func logoImage(size:LogoSize) -> sending HSImage? {

    var logoPath:Path?
    
    switch size {
    case .large:
        logoPath = path(for: .logo)
    case .small:
        logoPath = path(for: .logoSmall)
    case .preferSmall:
        logoPath = path(for: .logoSmall) ?? path(for: .logo)
    }
    
    guard let logoPath else {
        return nil
    }
    
    return HSImage(contentsOfFile: logoPath.string)
}

I'm using XCode 16.2
I'm in Swift 5 mode with RBI explicitly turned on
I still get the warning.

Non-sendable type 'HSImage?' (aka 'Optional') returned by call to main actor-isolated function cannot cross actor boundary; this is an error in the Swift 6 language mode

You mention Optional<T> is Sendable iff T is Sendable but I'm not sure why that is relevant. The point here is that T is not Sendable...

I don't think that warning comes from the code you've posted, though?

I'd want to see

  • the type containing the code you posted
  • the code calling logoImage
  • the type containing the code calling logoImage
  • exactly which line the warning is from

I guess?

You mention Optional<T> is Sendable iff T is Sendable but I'm not sure why that is relevant. The point here is that T is not Sendable ...

The point is to say that the region-based analysis only cares about Sendable, and Optional doesn't interfere with that. Optional<T> behaves exactly the same as T.

2 Likes

The warning appears at the caller of that code

@Observable
@MainActor
public class IdentityAssetManager {

   ...

    public func logoImage(size:LogoSize) -> sending HSImage? {

        var logoPath:Path?
        
        switch size {
        case .large:
            logoPath = path(for: .logo)
        case .small:
            logoPath = path(for: .logoSmall)
        case .preferSmall:
            logoPath = path(for: .logoSmall) ?? path(for: .logo)
        }
        
        guard let logoPath else {
            return nil
        }
        
        return HSImage(contentsOfFile: logoPath.string)
    }



which is then called from


class CompositionImageLayerBuilder {

  ...

func build(for plan: RenderPlan) async throws -> CALayer {

    let introLength = plan.preRenderedIntroLength

    let endTitleRange = CMTimeRange(start: composition.duration - plan.outroLength,
                                    duration: plan.outroLength)

    if showLogoWatermarkOnEdit {
        let watermarkRange = CMTimeRange(start: introLength,
                                         end: composition.duration - endTitleRange.duration)
        await addWatermark(range: watermarkRange)
    }

    if !plan.hasPreRenderedOutro {
        let dzText = await logoHeadline
        let subheadline = await logoSubheadline
        
  //ERROR HERE on logoImage
        let dzImage = await dropzoneProvider.assetManager?.logoImage(size:.large)

        try addEndLogo(
            image: dzImage,
            headline: dzText,
            subheadline: subheadline,
            background: .white,
            logoRange: endTitleRange
        )
    }

    let logoRange = CMTimeRange(start: composition.duration - fffLogoDuration,
                                    duration: fffLogoDuration)
    try addFFFLogo(range: logoRange)

    return parentLayer
}

(sorry for multiple edits - battling mobile internet while posting)

I'm dealing with a similar issue with CIImages that are processed in the background and then I want to send them to a View for, well, viewing. I currently have a completion closure but I can't send a CIImage over. sending doesn't appear to work with closures, and @preconcurrency Import CoreImage didn't fix the error either.

Weirdly my code compiles in Swift 6, but crashes at runtime (isn't Swift 6 supposed to catch this at compile time?). To fix the crash I'm trying to call the completion closure in a Main Actor task like this, but i get the error: Sending 'image' risks causing data races.

Task { @MainActor in
   imageHandler(image)
}

I tried wrapping CIImage in a sendable class and that did not fix the error, which seemed odd to me.

final class SendableCIImage: NSObject, @unchecked Sendable {
   var ciImage: CIImage
   init(ciImage: CIImage) {
      self.ciImage = ciImage
      super.init()
   }
}

The general problem of needing to create and/or work with non-Sendable data in the background and then get it to the main thread is usually doable. But to understand more, I'd need to see a little more code.

However, I do want to point out that using @unchecked Sendable wrappers like this are an extremely sharp tool. You absolutely can pull it off, and it may be the lowest-effort option. But you have to understand that it makes it really easy to build assumptions into your systems that you ultimately cannot maintain.

Now, in this specific case, the documentation is super-clear:

CIContext and CIImage objects are immutable, which means each can be shared safely among threads.

So, it looks like Core Image just needs to be updated to mark CIImage as Sendable. In the meantime, you can also do that yourself via an extension. But I wanted to bring all this up because while I think you are fine here, it might be useful to explore other techniques.

3 Likes

Thanks, so it seems like my main problem here is Apple lagging on Swift 6. I definitely don't like the wrapper, but my main curiosity is why doesn't the wrapper work? FWIW the extension also did not fix the error, and neither did @preconcurrency Import CoreImage.

3 Likes

There are a number of frameworks that do not support Swift concurrency well, with the degree of problems varying pretty wildly.

It's really hard for me to answer your question because I do not understand what the error is or the context around where you are seeing it. But if you share a little code we can look more closely.

1 Like

alright, this has a bit removed/renamed, but hopefully it's enough to get an idea

// Implicitly @MainActor since it's a view
final class DisplayView: NSView {
   private var imageProcessor: ImageProcessor?
   init(frame: NSRect) {
      super.init(frame: frame)
      imageProcessor = ImageProcessor(completion: { [weak self] image in 
         self?.layer?.contents = image
      })
      imageProcessor.start()
   }
}

class ImageProcessor: NSObject, ImageGetterDelegate {
   let completion: (CIImage) -> Void
   let imageGetter = ImageGetter()
   init(completion: (CIImage) -> Void) {
      self.completion = completion
      super.init()
      imageGetter.delegate = self
   }
   func start() {
      imageGetter.start()
   } 
   // this callback is always on a background queue. forcing it to return on main fixes all of the crashes, but introduces hangs as we're doing too much processing on main.
   func imageGetterDelegateCallback(image: CIImage) {
      var processedImage = image
      // CIImage is processed a bit here
      Task { @MainActor in // without this task, the following line crashes at runtime
         completion(processedImage) // compiler ERROR: Sending 'image' risks causing data races
      }
   }
}

swapping CIImage for SendableCIImage gives the same compiler error

final class SendableCIImage: NSObject, @unchecked Sendable {
   var ciImage: CIImage
   init(ciImage: CIImage) {
      self.ciImage = ciImage
      super.init()
   }
}

Ok great!

I do not quite see enough to say definitively why this crashes. But it could be because ImageGetterDelegate is not marked Sendable, but actually needs to be. This type seems to need to be able to capture its delegate on one thread but invoke its callbacks on another.

Here are a few options that might help. I made a bunch of substitutions and simplifications, but I think the problem is equivalent. I don't exactly know why your SendableCIImage doesn't work, but I don't see it in use.

import Foundation

class NonSendable {}

final class SendableContainer<T>: NSObject, @unchecked Sendable {
	let value: T
	init(_ value: T) {
		self.value = value
		super.init()
	}
}

@MainActor
class ImageProcessor: NSObject {
	let completion: (NonSendable) -> Void
	init(completion: @escaping (NonSendable) -> Void) {
		self.completion = completion
		super.init()
	}

	// option one, promise this particular instance is safe
	nonisolated func imageGetterDelegateCallback1(image: NonSendable) {
		nonisolated(unsafe) var processedImage = image
		// CIImage is processed a bit here

		Task { @MainActor in
			completion(processedImage)
		}
	}

	// option two, wrap it up
	nonisolated func imageGetterDelegateCallback2(image: NonSendable) {
		var processedImage = image
		// CIImage is processed a bit here

		let container = SendableContainer(processedImage)

		Task { @MainActor in
			completion(container.value)
		}
	}
}

Does any of this help?

I also want to add that a key component of the difficulty here was you have an a) non-Sendable type that b) is participating in concurrency (via both the delegate callback and Task). This is pretty much always problematic, and I addressed that by applying @MainActor, which seems correct based on what I see.

Thanks for the reply, the callback itself didn't appear to be the problem, it was calling the completion handler from a background thread that caused the crash. A coworker of mine figured out that the issue with the main actor task that calls the completion handler is that ImageProcessor isn't sendable and that task implicitly captures self. But I overlooked that and the compiler error did not point to that being the problem either. I'm a little surprised the Task didn't warn about implicit capture of self though.
Problem and mysteries solved for now, seems like a vague compiler error and a missing warning are what tripped me up here.

3 Likes