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.swift — passRetained 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 MetalTextureWrapper → CVPixelBuffer → 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 |
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
ThermionWidgettexture on iOS/macOSpermanently 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_footprintprobe: leak rate matches one screen-sizedsurface per widget session exactly, and
task_vm_infoattribution showsthe growth entirely on the graphics ledger (
phys_footprint − internal),with heap flat.
Root cause — two stacked unbalanced retains
1.
darwin/Classes/MetalTextureWrapper.swift—passRetainedneverbalanced.
allocate()publishes the texture address across the FFIboundary with an intentional +1:
but
destroyTexture()never releases it — it only flushes the CV cache: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 overwritesmetalTextureAddress,orphaning the first +1 unreleasably.)
2.
lib/src/platform/src/thermion_flutter_plugin_native.dart— destroyeddescriptors are never removed from the static
_descriptorslist. Theremoval mechanism is dead code:
_renderFramedrains a_destroyedlist……but nothing anywhere adds to
_destroyed. So everyDarwinPlatformTextureDescriptorImplis retained for the life of the app,ARC-holding its
MetalTextureWrapper→CVPixelBuffer→ IOSurface.Refcount accounting on the MTLTexture per session: 3 retains (the
wrapper's ARC property, the Dart-side explicit
retain()inDarwinPlatformTextureDescriptorImpl.allocate, and thepassRetainedFFI address) vs 1 release (the Dart-side
release()indestroy()).Net +2 → the surface can never be freed.
Partial fix we run in production (patch below)
Balancing the
passRetainedis not safe to do inline indestroyTexture(): it runs at widget dispose, while the Filament enginethat 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 msafter 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.
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 thenthe app SIGSEGVs within two sessions: once the surface genuinely frees,
something still dereferences it. Suspects: the Flutter raster thread's
last
copyPixelBufferpresent, the imported-texture path in the FilamentMetal backend, or the
CVMetalTextureCache(created withMaximumTextureAge: 0). We reverted that half and currently live with oneARC-held surface per session. A proper upstream fix probably needs the
final release ordered after Flutter's
onTextureUnregisteredand theimporting engine's destruction.
Numbers (iPad Pro 12.9 M1, enter/exit a Thermion view repeatedly)