-
Notifications
You must be signed in to change notification settings - Fork 98
Description
Rive Runtime Bug Report: Metal Drawable Accumulation (Not Recycled)
Note: This is NOT a traditional memory leak (Instruments "Leaks" shows no leaks).
This is drawable pool exhaustion - drawables are retained but never returned to the pool for reuse.
Summary
The Rive iOS/macOS runtime accumulates Metal drawables (CAMetalDrawable) that are never released back to the drawable pool, causing GPU memory exhaustion and frame drops after extended runtime.
Environment
| Component | Version |
|---|---|
| Platform | macOS (Apple Silicon) |
| Rive Runtime | 6.15.1 (rive-ios) |
| Rive Commit | 388cebf |
| Xcode | 26.2 (Build 17C52) |
| macOS | 26.2 (Build 25C56) |
| Renderer | Rive Renderer (experimental, @_spi(RiveExperimental)) |
Symptoms
- Application runs smoothly initially
- After several hours of runtime, frame rate gradually degrades
- Eventually animation becomes extremely choppy (dropping most frames)
- Pause/unpause does NOT fix the issue
- Waiting 20+ seconds (longer than 8s auto-trim interval) does NOT fix the issue
- Only full application restart resolves the issue
Evidence
Memory Analysis (vmmap)
After ~15 hours of runtime:
IOSurface 2.8G 2.8G 2.8G 0K 0K 2.8G 0K 81
TOTAL 6.8G 4.4G 4.0G 16K 0K 2.8G 0K 6768
2.8 GB of IOSurface memory accumulated (81 IOSurface allocations)
Instruments Profiling (Allocations - Call Tree)
The Instruments Allocations tool with Call Tree view shows the complete call path from app entry to IOSurface creation:
Bytes Used % Count Symbol Library
──────────────────────────────────────────────────────────────────────────────────────
644.95 MB 100.0% 62111 __debug_main_executable_dylib_entry_point RWPP
644.95 MB 100.0% 62111 └─ static App.main() SwiftUI
643.70 MB 99.8% 57061 └─ NSApplicationMain AppKit
643.75 MB 99.8% 57622 └─ -[NSApplication run] AppKit
... (event loop)
626.78 MB 97.1% 31416 └─ -[MTKView draw] MetalKit
626.78 MB 97.1% 31414 └─ @objc RiveView.draw(_:) RiveRuntime ← RIVE
626.78 MB 97.1% 31410 └─ -[RiveRendererView drawRect:] RiveRuntime ← RIVE
626.77 MB 97.1% 31407 └─ -[RiveRendererView drawInRect:withCompletion:] RiveRuntime ← RIVE
606.00 MB 93.9% 715 └─ -[MTKView currentDrawable] MetalKit
606.00 MB 93.9% 715 └─ -[CAMetalLayer nextDrawable] QuartzCore
605.87 MB 93.9% 656 └─ CAMetalLayerPrivateNextDrawableLocked QuartzCore
605.83 MB 93.9% 95 └─ get_unused_drawable QuartzCore
605.81 MB 93.9% 17 └─ CA::Render::create_iosurface_with_pixel_format QuartzCore
605.81 MB 93.9% 13 └─ CA::SurfaceUtil::CAIOSurfaceCreate QuartzCore
605.81 MB 93.9% 13 └─ IOSurfaceCreate IOSurface
605.81 MB 93.9% 9 └─ -[IOSurface initWithProperties:] IOSurface
605.81 MB 93.9% 9 └─ IOSurfaceClientCreateChild IOSurface
The bug is in RiveRendererView's drawInRect:withCompletion: method - it obtains drawables via currentDrawable but they are never returned to the pool.
Key Finding
| Metric | Expected | Actual |
|---|---|---|
| Drawables created | 2-3 (triple buffering) | 715 |
| IOSurface memory | ~2.5 MB (3 × ~850KB) | 2.8 GB |
| Drawable recycling | Yes | No |
715 Metal drawables were created but NONE were recycled back to the pool.
No Traditional Leaks Detected
Instruments "Leaks" instrument shows green checkmarks (no leaks detected). This confirms:
- The drawables are still referenced (not orphaned pointers)
- They are simply never released back to the drawable pool
- This is a drawable lifecycle management issue, not a missing
free()
Root Cause Analysis
Based on the call stack, the bug is in RiveRendererView (RiveRuntime):
Call Chain
-[MTKView draw] // MetalKit calls this each frame
└─ @objc RiveView.draw(_:) // Rive's RiveView
└─ -[RiveRendererView drawRect:] // Rive's renderer view
└─ -[RiveRendererView drawInRect:withCompletion:] // ← BUG IS HERE
└─ -[MTKView currentDrawable] // Gets drawable (715 times!)
What's Happening
- Each frame,
RiveRendererView drawInRect:withCompletion:callscurrentDrawable - The drawable is used for rendering
- After rendering, the drawable is NOT returned to the CAMetalLayer's drawable pool
- On the next frame,
nextDrawablefinds no available drawables in the pool get_unused_drawablecreates a NEW drawable backed by a new IOSurface- This repeats every frame, accumulating drawables indefinitely
Likely Bug Location
The bug is likely in one of these Rive files:
RiveRendererView.mmorRiveRendererView.swift- Specifically in the
drawInRect:withCompletion:method - Or in how the completion handler manages the drawable
Possible Causes
- Missing
presentDrawable:- The drawable may not be presented to the display - Command buffer not committed - The MTLCommandBuffer may not be committed properly
- Drawable retained in completion block - The completion handler may be holding the drawable
- Strong reference cycle -
withCompletion:block may capture the drawable strongly - Drawable stored but never released - Some internal tracking may retain drawables
Expected Metal Drawable Lifecycle
1. drawable = [metalLayer nextDrawable] // Get from pool (or create if empty)
2. texture = drawable.texture // Use for rendering
3. [commandBuffer presentDrawable:drawable] // Schedule presentation
4. [commandBuffer commit] // Submit to GPU
5. // On completion: drawable automatically returns to pool
6. // Next frame: step 1 reuses the same drawable
Actual Behavior (Bug)
1. drawable = [metalLayer nextDrawable] // Pool empty, creates new
2. texture = drawable.texture // Use for rendering
3. [commandBuffer presentDrawable:drawable] // Maybe not called?
4. [commandBuffer commit] // Maybe not called?
5. // Drawable NEVER returns to pool
6. // Next frame: pool still empty, creates ANOTHER new drawable
7. // Repeat 715+ times until GPU memory exhausted
Reproduction Steps
- Create an app using RiveRuntime with
@_spi(RiveExperimental)renderer - Load a Rive animation (especially one with Luau scripting that draws paths)
- Let it run continuously for several hours
- Monitor memory:
watch -n 60 'vmmap -summary $(pgrep AppName) | grep IOSurface' - Observe IOSurface memory growing continuously (~1-2 MB per minute)
- After several hours, animation becomes choppy due to drawable starvation
Workaround
The only effective workaround is to periodically destroy and recreate all RiveView instances:
// 1. Capture screenshot for visual continuity
let screenshot = captureWindow()
// 2. Destroy all Rive objects
riveView.removeFromSuperview()
riveViewModel = nil
riveView = nil
// 3. Wait for deallocation (releases all drawables)
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
// 4. Recreate views
riveView = RiveView(...)
riveViewModel = RiveViewModel(...)
}This forces deallocation of the CAMetalLayer, which releases all accumulated drawables.
Impact
- Severity: High
- User Impact: Application becomes unusable after extended runtime
- Use Case Affected: Any long-running Rive animation (kiosk displays, wallpapers, dashboards)
Additional Notes
- The issue does NOT appear to be related to Luau scripting specifically
- The issue is in the core Metal rendering pipeline
- RenderContext's 8-second auto-trim does not affect drawable pool
releaseResources()is only called on RenderContext dealloc, not periodically
