Skip to content

iOS/macOS: every Flutter texture session leaks a screen-sized IOSurface (~22 MB on iPad Pro 12.9) — two unbalanced retains #178

Description

@wperchinumio

Versions: thermion_dart / thermion_flutter 0.4.1, Flutter 3.44.2,
iPad Pro 12.9" M1 (iOS 26.6) and macOS — the code path is the shared
darwin/ plugin.

Summary

Every create/destroy cycle of a ThermionWidget texture on iOS/macOS
permanently leaks the full-screen BGRA IOSurface backing the Flutter
texture (2732×2048×4 ≈ 22 MB per cycle on iPad Pro 12.9). An app that
repeatedly opens and closes a Thermion view (in our case a 3D museum the
user enters/exits) is killed by jetsam after a few dozen cycles.

Measured with a phys_footprint probe: leak rate matches one screen-sized
surface per widget session exactly, and task_vm_info attribution shows
the growth entirely on the graphics ledger (phys_footprint − internal),
with heap flat.

Root cause — two stacked unbalanced retains

1. darwin/Classes/MetalTextureWrapper.swiftpassRetained never
balanced.
allocate() publishes the texture address across the FFI
boundary with an intentional +1:

let metalTexturePtr = Unmanaged.passRetained(metalTexture!).toOpaque()

but destroyTexture() never releases it — it only flushes the CV cache:

@objc public func destroyTexture()  {
   if let cache = self.cvMetalTextureCache {
       CVMetalTextureCacheFlush(cache, 0)
   }
}

The MTLTexture — created from the CVPixelBuffer's IOSurface with
makeTexture(descriptor:iosurface:plane:), which retains the IOSurface —
therefore lives forever. (The render-target-unsupported fallback path also
passRetaineds a second texture and overwrites metalTextureAddress,
orphaning the first +1 unreleasably.)

2. lib/src/platform/src/thermion_flutter_plugin_native.dart — destroyed
descriptors are never removed from the static _descriptors list.
The
removal mechanism is dead code: _renderFrame drains a _destroyed list…

for (final descriptor in _destroyed) {
  _descriptors.remove(descriptor);
  ...
}
_destroyed.clear();

…but nothing anywhere adds to _destroyed. So every
DarwinPlatformTextureDescriptorImpl is retained for the life of the app,
ARC-holding its MetalTextureWrapperCVPixelBuffer → IOSurface.

Refcount accounting on the MTLTexture per session: 3 retains (the
wrapper's ARC property, the Dart-side explicit retain() in
DarwinPlatformTextureDescriptorImpl.allocate, and the passRetained
FFI address) vs 1 release (the Dart-side release() in destroy()).
Net +2 → the surface can never be freed.

Partial fix we run in production (patch below)

Balancing the passRetained is not safe to do inline in
destroyTexture(): it runs at widget dispose, while the Filament engine
that imported the texture may still be tearing down asynchronously — an
immediate release SIGSEGV'd reproducibly. It's also not safe to release at
the next allocate() unconditionally: the widget re-allocates ~100 ms
after first layout (size settling), and that same-session allocate would
free a texture whose engine is alive with frames in flight (also
reproduced). Our patch parks the retained pointer at destroyTexture()
and drains it at the next allocate(), age-gated to ≥ 2 s parked
covering both in-flight GPU work and an exiting engine's teardown. Steady
state: at most one parked surface, reclaimed on the next session.

diff --git a/darwin/Classes/MetalTextureWrapper.swift b/darwin/Classes/MetalTextureWrapper.swift
--- a/darwin/Classes/MetalTextureWrapper.swift
+++ b/darwin/Classes/MetalTextureWrapper.swift
@@ -24,6 +24,8 @@ import GLKit
     }
 
     @objc public static func allocate(width:Int64, height:Int64, isDepth:Bool, isStencil:Bool) -> MetalTextureWrapper {
+        // Balance the passRetained(+1) of textures destroyed in previous sessions.
+        drainPendingReleases()
         let metalDevice = MTLCreateSystemDefaultDevice()!
 
         if isDepth {
@@ -169,6 +171,11 @@ import GLKit
 
                     if let rtTexture = metalDevice.makeTexture(descriptor: rtDescriptor, iosurface: iosurfaceRef, plane: 0) {
                         print("Successfully created render target texture from IOSurface")
+                        // The address is being replaced — balance the passRetained(+1)
+                        // taken on the ORIGINAL CV-cache texture above, or it leaks unreleasably.
+                        if let orphaned = UnsafeRawPointer(bitPattern: metalTextureAddress) {
+                            Unmanaged<AnyObject>.fromOpaque(orphaned).release()
+                        }
                         // Replace the original texture with the render target version
                         metalTexture = rtTexture
                         let metalTexturePtr = Unmanaged.passRetained(metalTexture!).toOpaque()
@@ -202,7 +209,46 @@ import GLKit
         return texture.usage.contains(.renderTarget)
     }
 
+    // Deferred, age-gated release of the passRetained(+1) taken in allocate().
+    // - Immediate release in destroyTexture() SIGSEGVs: the importing engine
+    //   tears down asynchronously after widget dispose.
+    // - Un-gated release at next allocate() SIGSEGVs: the widget re-allocates
+    //   ~100 ms after first layout, same engine still alive.
+    // 2 s covers in-flight GPU work (~50 ms) and engine teardown (~1-3 s).
+    private static let _kMinParkSeconds: CFAbsoluteTime = 2.0
+    private static var _pendingRelease: [(addr: Int, parkedAt: CFAbsoluteTime)] = []
+    private static let _pendingReleaseLock = NSLock()
+
+    static func drainPendingReleases() {
+        let now = CFAbsoluteTimeGetCurrent()
+        var ripe: [Int] = []
+        _pendingReleaseLock.lock()
+        var keep: [(addr: Int, parkedAt: CFAbsoluteTime)] = []
+        for entry in _pendingRelease {
+            if now - entry.parkedAt >= _kMinParkSeconds {
+                ripe.append(entry.addr)
+            } else {
+                keep.append(entry)
+            }
+        }
+        _pendingRelease = keep
+        _pendingReleaseLock.unlock()
+        for addr in ripe {
+            if let ptr = UnsafeRawPointer(bitPattern: addr) {
+                Unmanaged<AnyObject>.fromOpaque(ptr).release()
+            }
+        }
+    }
+
     @objc public func destroyTexture()  {
+       if metalTextureAddress != -1 && metalTextureAddress != 0 {
+           MetalTextureWrapper._pendingReleaseLock.lock()
+           MetalTextureWrapper._pendingRelease.append(
+               (addr: metalTextureAddress, parkedAt: CFAbsoluteTimeGetCurrent()))
+           MetalTextureWrapper._pendingReleaseLock.unlock()
+       }
        if let cache = self.cvMetalTextureCache {
            CVMetalTextureCacheFlush(cache, 0)
        }

⚠️ Caution about completing the fix (bug 2)

We also tried the obvious fix for bug 2 — purging destroyed descriptors
from _descriptors (removeWhere((d) => d.isDestroyed) in
_renderFrame). That makes the refcounts balance fully (3/3)… and then
the app SIGSEGVs within two sessions: once the surface genuinely frees,
something still dereferences it. Suspects: the Flutter raster thread's
last copyPixelBuffer present, the imported-texture path in the Filament
Metal backend, or the CVMetalTextureCache (created with
MaximumTextureAge: 0). We reverted that half and currently live with one
ARC-held surface per session. A proper upstream fix probably needs the
final release ordered after Flutter's onTextureUnregistered and the
importing engine's destruction.

Numbers (iPad Pro 12.9 M1, enter/exit a Thermion view repeatedly)

Configuration Leak per session
0.4.1 stock ~44 MB (both retains)
+ passRetained parked/age-gated release (patch above) ~22 MB (descriptor list still holds one)
+ descriptor purge (crashes — see caution) ~0, but UAF

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions