Search code examples
cobjective-cmacosgo

Rapid memory growth caused by Go calling Objective-C


MacOS 12.6.8 Apple M1 Pro

go version go1.20.7 darwin/arm64

Invoking the function GetWindowImage leads to rapid memory expansion.

Code:

package main

/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Cocoa -framework ApplicationServices

#import <Cocoa/Cocoa.h>
#import <ApplicationServices/ApplicationServices.h>

CGWindowID GetWindowNumber(const char *title) {
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
    NSString *windowTitle = [NSString stringWithUTF8String:title];
    CFArrayRef windowList = CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly, kCGNullWindowID);
    CGWindowID windowNumber = 0;
    for (NSDictionary *entry in (__bridge NSArray *)windowList) {
        NSString *winTitle = [entry objectForKey:(id)kCGWindowName];
        // NSLog(@"Window Title: %@", winTitle);
        if ([winTitle isEqualToString:windowTitle]) {
            windowNumber = (CGWindowID)[[entry objectForKey:(id)kCGWindowNumber] integerValue];
            break;
        }
    }
    CFRelease(windowList);
    [pool drain];
    return windowNumber;
}


CFDataRef CaptureWindowImageData(CGWindowID windowNumber) {
    CGWindowID windowList[] = {windowNumber};
    CFArrayRef windowArray = CFArrayCreate(NULL, (const void **)windowList, 1, NULL);
    CGImageRef image = CGWindowListCreateImageFromArray(CGRectNull, windowArray, kCGWindowImageDefault);
    NSImage *nsImage = [[NSImage alloc] initWithCGImage:image size:NSZeroSize];
    CFRelease(windowArray);
    CGImageRelease(image);

    NSBitmapImageRep *rep = [NSBitmapImageRep imageRepWithData:[nsImage TIFFRepresentation]];
    NSDictionary *props = [NSDictionary dictionaryWithObject:[NSNumber numberWithFloat:1.0] forKey:NSImageCompressionFactor];
    CFDataRef imageData = (CFDataRef)[rep representationUsingType:NSBitmapImageFileTypePNG properties:props];

    [nsImage release];
    [rep release];

    return imageData;
}
*/
import "C"
import (
    "bytes"
    "fmt"
    "image"
    "image/png"
    "unsafe"
)

func GetWindowNumber(name string) uint {
    cTitle := C.CString(name)
    defer C.free(unsafe.Pointer(cTitle))
    return uint(C.GetWindowNumber(cTitle))
}

func GetWindowImage(windowNumber uint) (image.Image, error) {
    imageData := C.CaptureWindowImageData(C.uint(windowNumber))
    if imageData == C.CFDataRef(unsafe.Pointer(nil)) {
        return nil, fmt.Errorf("failed to get window image data for %d", windowNumber)
    }
    defer C.CFRelease(C.CFTypeRef(imageData))
    dataLength := C.CFDataGetLength(imageData)
    dataPtr := C.CFDataGetBytePtr(imageData)
    data := C.GoBytes(unsafe.Pointer(dataPtr), C.int(dataLength))
    return png.Decode(bytes.NewReader(data))
}

func main() {
    winId := GetWindowNumber("Clock")
    fmt.Println(winId)
    for i := 0; i < 100; i++ {
        _, err := GetWindowImage(winId)
        if err != nil {
            panic(err)
        }
    }
}

I'm currently unable to determine where the problem lies, whether it's in Objective-C or C.

Please help diagnose the issue and guide on how to fix the code.


Solution

  • First, there's a small bug here that when you fix the memory leak will cause crashes, so to fix that first, remove the [rep release] call. You must only call release when you have taken (shared) ownership:

    You take ownership of an object if you create it using a method whose name begins with "alloc" or "new" or contains "copy" (for example, alloc, newObject, or mutableCopy), or if you send it a retain message. You are responsible for relinquishing ownership of objects you own using release or autorelease. Any other time you receive an object, you must not release it.

    See Basic Memory Management Rules for details.

    So, in CaptureWindowImageData, delete the line [rep release].

    With that fixed, your problem is that you're generating large objects on the autorelease pool and never draining the pool. When you are called from a non-ObjC context, its is your responsibility to manage the pool. Since you're currently returning an autoreleased object, this requires a little bit of a dance to hold onto it. I haven't built this, but it should work:

    // Rename this to Create... rather than Capture... It now returns a
    // retained object, so needs to have the word Create in its name.
    // https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFMemoryMgmt/Concepts/Ownership.html#//apple_ref/doc/uid/20001148-103029
    CFDataRef CreateWindowImageData(CGWindowID windowNumber) {
        // Create your pool
        NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
    
        CGWindowID windowList[] = {windowNumber};
    
        // ...
    
        [nsImage release];
        // [rep release];   <---- delete this; you don't own this object
    
        // Hold onto your imageData
        CFRetain(imageData);
    
        // Drain the rest of the pool
        [pool drain];
    
        return imageData;
    }
    

    With that, your Go code should be correct (except for fixing the name of the function). It correctly releases the object already.

    Core Foundation and Foundation have extremely consistent naming conventions to indicate memory management. Once you learn the rules, you can know at glance when to call release and when not to.

    For ObjC (Foundation), look for alloc, new, copy, or retain, and or C (Core Foundation), look for Create, Copy, or Retain. Those all mean you must call release (or CFRelease). If you didn't call something with those names, you must not release the object.

    If you want an object to live beyond the current autorelease pool, you must retain it (or have created it with one of the words above). And if you are being called from a context that does not create an autorelease pool, it's up to you to create and drain it.