Skip to content

iOS: 300+ MiB IOSurface leak from CIContext+createCGImage in MLKVisionImage+FlutterPlugin (release builds) #863

@Toker38

Description

@Toker38

Describe your issue

On iOS, sustained camera image streaming through FaceDetector.processImage(InputImage.fromBytes(...)) leaks ~3.5 MiB of VM: IOSurface memory per detection call, accumulating to 300–500 MiB over 60 seconds and continuing to grow until the OS terminates the app.

The leak originates from the plugin's pixelBufferToVisionImage: implementation in google_mlkit_commons-0.11.1/ios/Classes/MLKVisionImage+FlutterPlugin.m, which creates a fresh CIContext for every frame and calls createCGImage:fromRect:. Each call inserts a CI::SurfaceCacheEntry that the system never releases under continuous frame load.

This is distinct from #790 (Android-focused, no root cause); this report identifies the iOS-specific source and includes Instruments measurements + a working fix.

Reproduction

  1. Use camera: ^0.11.x with ImageFormatGroup.bgra8888 on iOS at ResolutionPreset.high (720p).
  2. Start controller.startImageStream and forward each CameraImage to FaceDetector.processImage(InputImage.fromBytes(...)) at ~3 FPS (300 ms throttle).
  3. Build in --release mode on a physical iOS device (tested on iPhone, iOS 26.2).
  4. Open Instruments → Allocations, mark generations 5 s and 65 s after the camera sheet appears.
  5. Without ever pointing the camera at a face, observe VM: IOSurface growth in Generation B.

Measured leak

Snapshot Duration IOSurface growth Allocations
Generation A (warmup) 0–25 s 136 MiB 161
Generation B 25–64 s (39 s) 341 MiB 97
Generation C 64–86 s (22 s) 186 MiB 53

Each surface is exactly 3.52 MiB = 1280 × 720 × 4 (BGRA frame buffer). Net growth: ~8.5 MiB / sec under typical face-search workload. Pipeline / FaceDetector instances were verified single-instance (no Dart-side churn) via heap snapshot.

Stack trace (Allocations → IOSurface entry)

IOSurfaceClientCreateChild
-[IOSurface initWithProperties:]
IOSurfaceCreate
CreateCachedSurface
CI::SurfaceCacheEntry::SurfaceCacheEntry
__GetSurfaceFromCacheAndFill_block_invoke
GetSurfaceFromCacheAndFill
CI::ProviderNode::surfaceForROI
CI::MetalContext::render_root_node
CI::Context::render
CI::image_render_to_bitmap
-[CIContext(_createCGImageInternal) _createCGImage:fromRect:format:premultiplied:colorSpace:deferred:renderCallback:]
-[CIContext(createCGImage) createCGImage:fromRect:]
+[MLKVisionImage(FlutterPlugin) pixelBufferToVisionImage:]    <-- plugin entry
+[MLKVisionImage(FlutterPlugin) bytesToVisionImage:]
+[MLKVisionImage(FlutterPlugin) visionImageFromData:]
-[GoogleMlKitFaceDetectionPlugin handleDetection:result:]

Root cause

MLKVisionImage+FlutterPlugin.m:56 (pixelBufferToVisionImage:):

CIImage *ciImage = [CIImage imageWithCVPixelBuffer:pixelBufferRef];
CIContext *temporaryContext = [CIContext contextWithOptions:nil];   // <-- new each frame
CGImageRef videoImage = [temporaryContext createCGImage:ciImage fromRect:...];
UIImage *uiImage = [UIImage imageWithCGImage:videoImage];
return [[MLKVisionImage alloc] initWithImage:uiImage];

createCGImage:fromRect: populates an internal CI::SurfaceCacheEntry that is keyed off the (per-call) CIContext. Even when ARC releases temporaryContext, the underlying IOSurfaces persist in the system's IOAccelerator warm cache. Apple's documentation recommends a single long-lived CIContext, but per the createCGImage thread on the Apple Developer Forums, a shared CIContext + @autoreleasepool does not stop the cache growth in this scenario — only avoiding createCGImage: does.

Fix

MLKVisionImage exposes initWithBuffer: that accepts a CMSampleBufferRef directly, bypassing CoreImage entirely. This is the same path Google's official iOS sample uses (googlesamples/mlkit/ios/quickstarts/vision/VisionExample/CameraViewController.swift).

Replacement implementation:

+ (MLKVisionImage *)pixelBufferToVisionImage:(CVPixelBufferRef)pixelBufferRef {
    @autoreleasepool {
        CMSampleBufferRef sampleBuffer = NULL;
        CMVideoFormatDescriptionRef formatDesc = NULL;
        CMSampleTimingInfo timing = {kCMTimeInvalid, kCMTimeZero, kCMTimeInvalid};

        OSStatus formatStatus = CMVideoFormatDescriptionCreateForImageBuffer(
            kCFAllocatorDefault, pixelBufferRef, &formatDesc);
        if (formatStatus != noErr || formatDesc == NULL) {
            CVPixelBufferRelease(pixelBufferRef);
            return nil;
        }

        OSStatus sampleStatus = CMSampleBufferCreateForImageBuffer(
            kCFAllocatorDefault, pixelBufferRef, true, NULL, NULL,
            formatDesc, &timing, &sampleBuffer);
        CFRelease(formatDesc);
        if (sampleStatus != noErr || sampleBuffer == NULL) {
            CVPixelBufferRelease(pixelBufferRef);
            return nil;
        }

        MLKVisionImage *visionImage =
            [[MLKVisionImage alloc] initWithBuffer:sampleBuffer];

        CFRelease(sampleBuffer);
        CVPixelBufferRelease(pixelBufferRef);
        return visionImage;
    }
}

After applying this patch in a private fork:

  • [PATCH] log confirmed the path is hot under the same workload.
  • IOSurface growth in 60 s drops from ~341 MiB to near-baseline (no observable accumulation).
  • No regression in detection accuracy or latency (MLKit consumes CMSampleBuffer natively at the same speed).

A pull request implementing this is incoming.

Did you try the example app?

The leak is reproducible by replacing the example app's CameraImage source with a startImageStream loop on a real device.

Reproducible in which OS?

iOS (tested 26.2, physical device, --release build).

Plugin Version

  • google_mlkit_face_detection: 0.13.2
  • google_mlkit_commons: 0.11.1
  • camera: 0.11.0+2

Flutter / Dart

Flutter 3.x stable, Dart 3.x

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions