Does objc_setAssociatedObject() work reliably when used with "bridgeable" Core Foundation types?

I'm investigating an odd problem that surfaced with macOS Sequoia related to CoreGraphics (CGColorSpaceCreateWithICCData()). I couldn't find a reliable way to track the lifetime (retain/releases) of a CGColorSpaceRef (any pointers appreciated) so I was hoping to use objc_setAssociatedObject() to attach an instance of my own class that would be deallocated when the "owning" CGColorSpaceRef is deallocated. In this way I could set a breakpoint on my own code, presumably invoked when the CGColorSpaceRef is itself going away.

While objc_setAssociatedObject() does seem to work, the results don't seem deterministic. Is it because objc_setAssociatedObject() simply won't work reliably with CF types?

Answered by DTS Engineer in 798671022
Before I continue along this path, it helped to know if that machinery is just as reliable with bridgeable CFTypes as it is with regular Obj-C instances.

I would expect this to work, purely based on how CF and Obj-C interoperability works.

And a quick test suggests that it does in fact work. Consider the program at this end of this post. It prints:

point A
point B
chirrr… argh!
point C

indicating that uuidQ is keeping canary alive until it’s deallocated.

Remember that associated objects have many sharp edge cases, so it’s possible that you’re hitting one of these. For example, CG may be caching colour space objects, preventing their deallocation.

My first step in hunting problems like this is the Allocations instrument. If you leave “Discard events for freed memory” unchecked and then check “Record reference counts”, you can track the entire lifetime of an object starting with just its address.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

import Foundation

class Canary: NSObject {
    deinit { print("chirrr… argh!") }
}

let key = malloc(0)!
var uuidQ: CFUUID? = nil

func main() {
    print("point A")
    autoreleasepool {
        let canary = Canary()
        let uuid = CFUUIDCreateFromString(nil, "D2F03482-81FD-460A-B1C8-5D7D0CDAF66F" as NSString)!
        objc_setAssociatedObject(uuid, key, canary, .OBJC_ASSOCIATION_RETAIN)
        uuidQ = uuid
    }
    print("point B")
    autoreleasepool {
        uuidQ = nil
    }
    print("point C")
}

main()

Associated objects are something that I try to avoid. The only use case I’m happy to support is the use case I think you’re aiming at, namely, as a debugging tool. That is, you’re trying to debug a problem associated with CG colour space objects and you want to use an associated object to learn about them being deallocated. Is that right?

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

That is correct! I managed to associated a regular Obj-C object (via ATOMIC RETAIN) to a CGColorSpaceRef in the hope of being able to set a breakpoint on the deallocation of the CGColorSpaceRef. My intention is to spot exactly what the stack frame looks like when the object goes away. Before I continue along this path, it helped to know if that machinery is just as reliable with bridgeable CFTypes as it is with regular Obj-C instances. I tried setting NSZombie on but I don't think it has any effect on CFTypes either.

Accepted Answer
Before I continue along this path, it helped to know if that machinery is just as reliable with bridgeable CFTypes as it is with regular Obj-C instances.

I would expect this to work, purely based on how CF and Obj-C interoperability works.

And a quick test suggests that it does in fact work. Consider the program at this end of this post. It prints:

point A
point B
chirrr… argh!
point C

indicating that uuidQ is keeping canary alive until it’s deallocated.

Remember that associated objects have many sharp edge cases, so it’s possible that you’re hitting one of these. For example, CG may be caching colour space objects, preventing their deallocation.

My first step in hunting problems like this is the Allocations instrument. If you leave “Discard events for freed memory” unchecked and then check “Record reference counts”, you can track the entire lifetime of an object starting with just its address.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

import Foundation

class Canary: NSObject {
    deinit { print("chirrr… argh!") }
}

let key = malloc(0)!
var uuidQ: CFUUID? = nil

func main() {
    print("point A")
    autoreleasepool {
        let canary = Canary()
        let uuid = CFUUIDCreateFromString(nil, "D2F03482-81FD-460A-B1C8-5D7D0CDAF66F" as NSString)!
        objc_setAssociatedObject(uuid, key, canary, .OBJC_ASSOCIATION_RETAIN)
        uuidQ = uuid
    }
    print("point B")
    autoreleasepool {
        uuidQ = nil
    }
    print("point C")
}

main()

Thank you, that is great to know, and even better to learn about the Allocations tool "Record reference counts" option. The problem is still 100% reproducible for me on the latest Sequoia seed (24A5309e) in two different apps (After Effects and Premiere Pro, where our third-party code is loaded) and I need to build a reproducible case that doesn't involve two behemoths :-)

I'm adding this here in case someone ends up on this thread through a search and could possibly be investigating similar crashes.

Through to the use of the "Record reference counts" in the Allocations tool, filtering by type ("CGColorSpace") and some logging/breakpoints courtesy of associated objects, I think I have a better picture of what’s going on in Sequoia.

Is there really a regression in CGColorSpaceCreateWithICCData on macOS 15 Sequoia?

To the best of my knowledge, yes, there still appears to be a regression in Sequoia, but it isn't in CGColorSpaceCreateWithICCData. One of the APIs our software uses seems to have gained an extra CFRelease() as compared to previous versions of macOS. Because our software uses almost every graphics API present in macOS, it’s been very hard to pinpoint who might be responsible for it (some call stacks are in private APIs that aren't easily understood). The list made by the Allocations tool of each CFRetain/CFRelease called on every color space instance is great, but so long in my case as to be impractical. I hope others might have an easier environment to debug this issue in.

If there is nothing wrong with CGColorSpaceCreateWithICCData, what’s really going on?

It appears to be just one CFRelease too many, but there is a reason it was discovered through CGColorSpaceCreateWithICCData. Most times, software ends up using one of the system-provided color spaces created by name (e.g. kCGColorSpaceSRGB). These are singletons, with an infinite retain count. So calling an extra CFRelease on one of these singletons will never produce a crash. Our software, in some circumstances, allocates its own CGColorSpaceRef from ICC data. Whether CoreGraphics maintains a cache or not of these instances, they are regular (non-singleton) instances. So if some API somewhere is responsible for an extra CFRelease, the last unlucky caller to call CFRelease will cause a crash. In your own code, you would notice the reference count for an instance created via CGColorSpaceCreateWithICCData go up and down as it gets passed around, but eventually and unpredictably, when the last use of that color space is complete, you’re likely to see a crash with a stack trace that includes CF_IS_OBJC. There aren't too many ways to create color spaces that aren't singletons. I suspect color spaces created as derivatives of singletons might also be affected. If you stumble on this page and your code uses stuff like CGColorSpaceCreateLinearized, CGColorSpaceCreateCopyWithStandardRange or similar, then perhaps you have confirmation that derived color spaces are indeed affected by this problem, even when derived from singletons.

Is a workaround possible?

The only brute force solution that seems available is to make sure you maintain your own singletons of every color space created from ICC data or through APIs that derive a new color space from an existing instance. Assuming you’re working with a finite set of ICC profiles, or need a limited set of color spaces derived from others, this global list shouldn't grow too large or grow indefinitely. Core Foundation has no function that says CFMakeUncollectable() or CFSetRetainCount(+infinity) so not only do you need to maintain a reference to these color spaces in a global pool, you also need to periodically keep its retain count high. Assuming there is really a regression in macOS and not my code, some graphics API somewhere is calling an extra CFRelease any time you use it, so make sure your retain count is always high enough to never allow it to reach zero (e.g. periodically make sure it’s at least 1000).

I'm still hoping the problem is actually in my own code (I'm at the third review already) but just in case this is a real regression in macOS, hope the above helps others.

Problem appears gone in macOS 15.0 Beta (24A5320a) and now let’s hope it stays this way til release.

Does objc_setAssociatedObject() work reliably when used with "bridgeable" Core Foundation types?
 
 
Q