Fix tvOS GL render pipeline defects behind A10X IOFence crash

Apple TV 4K 1st gen on tvOS 26 crashes to the home screen with a
"blocked by IOFence" gpuEvent at playback start (#949) and stutters
with a starved demuxer cache (#947). Defensive fixes in the shared
iOS/tvOS OpenGL path:

- Drain in-flight GPU work (glFinish) before destroying the
  renderbuffer, so a pending presentRenderbuffer fence can't hold the
  CAEAGLLayer IOSurface being torn down or reallocated
- Never leave the EAGLContext current on the main thread: framebuffer
  create/destroy and the background glFinish now unbind on exit, and
  setup no longer binds the context on main at all
- Disable retained backing on tvOS; it exists only for iOS PiP frame
  capture and adds per-frame IOSurface fence pressure
- Report swaps to mpv after each present so display-vdrop (the tvOS
  default video-sync) gets swap-timing feedback, matching macOS

Add verbose-gated diagnostics for remote triage: playback stats every
10s (cache fill/rate, dropped frames, hwdec, avsync) and a rate-limited
slow-frame warning in performRender.

Addresses #949 and #947
This commit is contained in:
Arkadiusz Fal
2026-05-31 11:27:09 +02:00
parent 5950dbb22a
commit fa6ad2235c
3 changed files with 114 additions and 7 deletions

View File

@@ -24,6 +24,10 @@ enum MPVLogging {
private static var _lastCheckTime: UInt64 = 0
private static let cacheDurationNanos: UInt64 = 1_000_000_000 // 1 second
/// Whether verbose MPV logging is enabled (cached, safe from any thread).
/// Exposed for callers that gate their own logging on the same setting.
static var verboseEnabled: Bool { isEnabled() }
/// Check if verbose logging is enabled (cached for performance).
/// Safe to call from any thread.
private static func isEnabled() -> Bool {

View File

@@ -232,10 +232,17 @@ final class MPVRenderView: UIView {
contentScaleFactor = UIScreen.main.scale
// Configure EAGL layer
// Note: retainedBacking=true is required for glReadPixels to work (for PiP frame capture)
// Note: retainedBacking=true is required for glReadPixels to work (PiP frame capture).
// PiP exists only on iOS; on tvOS retained backing just forces the compositor to
// preserve the drawable's IOSurface every frame, adding GPU fence pressure (issue #949).
#if os(iOS)
let retainedBacking = true
#else
let retainedBacking = false
#endif
eaglLayer?.isOpaque = true
eaglLayer?.drawableProperties = [
kEAGLDrawablePropertyRetainedBacking: true,
kEAGLDrawablePropertyRetainedBacking: retainedBacking,
kEAGLDrawablePropertyColorFormat: kEAGLColorFormatRGBA8
]
@@ -318,6 +325,9 @@ final class MPVRenderView: UIView {
MPVLogging.warn("EAGLContext.setCurrent failed in appDidEnterBackground")
}
glFinish()
// This sync block executes on the calling (main) thread unbind so the
// context isn't left current on two threads.
EAGLContext.setCurrent(nil)
MPVLogging.log("glFinish completed in appDidEnterBackground")
}
}
@@ -498,14 +508,14 @@ final class MPVRenderView: UIView {
// Back on main thread for UIKit operations
await MainActor.run {
self.eaglContext = context
// Make context current
EAGLContext.setCurrent(context)
// Create framebuffer only if view has valid bounds
// (createFramebuffer makes the context current itself and unbinds on exit)
lastStableSize = bounds.size
if bounds.size != .zero {
createFramebuffer()
renderQueue.sync {
createFramebuffer()
}
} else {
MPVLogging.log("setupAsync: deferring framebuffer creation (bounds are zero)")
}
@@ -596,6 +606,10 @@ final class MPVRenderView: UIView {
if !ctxSet {
MPVLogging.warn("createFramebuffer: EAGLContext.setCurrent failed")
}
// This runs on the main thread (setup/layout paths); unbind on exit so the
// context is never left current on two threads at once (performRender binds
// it on the render queue thread each frame).
defer { EAGLContext.setCurrent(nil) }
// Generate framebuffer
glGenFramebuffers(1, &framebuffer)
@@ -650,6 +664,12 @@ final class MPVRenderView: UIView {
contextCurrent: EAGLContext.current() === eaglContext)
EAGLContext.setCurrent(eaglContext)
// Unbind on exit see createFramebuffer.
defer { EAGLContext.setCurrent(nil) }
// Drain all in-flight GPU work before deleting the renderbuffer: a pending
// presentRenderbuffer may still hold an IOFence on the CAEAGLLayer's IOSurface,
// and deleting/reallocating the surface underneath it deadlocks the GPU (issue #949).
glFinish()
destroyFramebufferResources()
}
@@ -807,8 +827,13 @@ final class MPVRenderView: UIView {
/// Frame counter for periodic verbose logging (avoid spam)
private var renderFrameLogCounter: UInt64 = 0
/// Slow-frame tracking (GPU stall diagnostics for issues #947/#949)
private var slowFrameCount: UInt64 = 0
private var lastSlowFrameWarning: Date = .distantPast
private func performRender() {
defer { isRendering = false }
let frameStart = Date()
guard let eaglContext, let mpvClient, framebuffer != 0 else {
// Log when render is skipped due to missing resources (rare but important)
@@ -871,6 +896,24 @@ final class MPVRenderView: UIView {
if !presented {
MPVLogging.warn("performRender: presentRenderbuffer FAILED",
details: "fb:\(framebuffer) rb:\(colorRenderbuffer) layer:\(layer.bounds) superview:\(superview != nil)")
} else {
// Feed swap timing back to mpv required for the display-* video-sync
// modes (tvOS default is display-vdrop) to pace frames correctly.
// macOS render views already do this.
mpvClient.reportSwap()
}
}
// Warn on GPU stalls: a render+present that takes far longer than a frame
// interval points at fence contention (see issues #947/#949). Rate-limited.
let frameDuration = Date().timeIntervalSince(frameStart)
if frameDuration > 0.05 {
slowFrameCount += 1
if Date().timeIntervalSince(lastSlowFrameWarning) > 5 {
lastSlowFrameWarning = Date()
MPVLogging.warn("performRender: slow frame",
details: "duration=\(Int(frameDuration * 1000))ms slowFramesSinceLastWarning=\(slowFrameCount) size=\(renderWidth)x\(renderHeight)")
slowFrameCount = 0
}
}

View File

@@ -120,6 +120,10 @@ final class MPVBackend: PlayerBackend {
private let bufferStallTimeout: TimeInterval = 30 // Trigger refresh after 30 seconds of stall
private var bufferStallCheckTask: Task<Void, Never>?
// Periodic playback stats logging so exported user logs can diagnose
// stuttering / cache starvation remotely (GitHub #947/#949)
private var playbackStatsTask: Task<Void, Never>?
// Video dimensions for aspect ratio detection
private var videoWidth: Int = 0
private var videoHeight: Int = 0
@@ -378,6 +382,7 @@ final class MPVBackend: PlayerBackend {
private func cleanup() {
// Stop buffer stall detection
stopBufferStallDetection()
stopPlaybackStatsLogging()
#if os(macOS)
// Clear MPV client callbacks before destroying - these reference the render view
@@ -749,6 +754,7 @@ final class MPVBackend: PlayerBackend {
// Stop buffer stall detection
stopBufferStallDetection()
stopPlaybackStatsLogging()
mpvClient?.stop()
isPlaying = false
@@ -1836,6 +1842,7 @@ extension MPVBackend: MPVClientDelegate {
switch event {
case MPV_EVENT_FILE_LOADED:
LoggingService.shared.debug("MPV: File loaded", category: .mpv)
startPlaybackStatsLogging()
// Log hwdec diagnostics on tvOS (use cached values to avoid sync fetch)
#if os(tvOS)
let codec = videoCodec.isEmpty ? "unknown" : videoCodec
@@ -1992,4 +1999,57 @@ extension MPVBackend: MPVClientDelegate {
bufferStallStartTime = nil
LoggingService.shared.debug("MPV: Buffer stall detection stopped", category: .mpv)
}
// MARK: - Playback Stats Logging
/// Log cache/frame-drop/pacing stats every 10s while a file is loaded, at INFO level
/// so they appear in user-exported logs (diagnostics for GitHub #947/#949).
/// Only logs while verbose MPV logging is enabled in settings.
private func startPlaybackStatsLogging() {
guard playbackStatsTask == nil else { return }
playbackStatsTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(10))
guard let self, !Task.isCancelled, let client = self.mpvClient else { return }
// Gate on the verbose setting each tick (not at task start) so
// toggling it mid-playback takes effect without a reload
guard MPVLogging.verboseEnabled else { continue }
let props = await client.getDebugPropertiesAsync()
func megabytes(_ bytes: Int64) -> String {
String(format: "%.1fMiB", Double(bytes) / 1_048_576)
}
var parts: [String] = ["paused=\(!self.isPlaying)"]
if let hwdec = props.hwdecCurrent { parts.append("hwdec=\(hwdec)") }
if let fps = props.estimatedVfFps { parts.append(String(format: "vf-fps=%.2f", fps)) }
if let avsync = props.avsync { parts.append(String(format: "avsync=%.3f", avsync)) }
var drops: [String] = []
if let vo = props.frameDropCount { drops.append("vo=\(vo)") }
if let dec = props.decoderFrameDropCount { drops.append("dec=\(dec)") }
if let mistimed = props.mistimedFrameCount { drops.append("mistimed=\(mistimed)") }
if let delayed = props.voDelayedFrameCount { drops.append("delayed=\(delayed)") }
if !drops.isEmpty { parts.append("dropped(\(drops.joined(separator: " ")))") }
var cache: [String] = []
if let duration = props.demuxerCacheDuration { cache.append(String(format: "dur=%.1fs", duration)) }
if let state = props.cacheState {
cache.append("fw=\(megabytes(state.forwardBytes))")
cache.append("rate=\(megabytes(state.inputRate))/s")
cache.append("eof=\(state.eofCached)")
}
if !cache.isEmpty { parts.append("cache(\(cache.joined(separator: " ")))") }
LoggingService.shared.info("MPV playback stats: \(parts.joined(separator: " "))", category: .mpv)
}
}
}
/// Stop periodic playback stats logging.
private func stopPlaybackStatsLogging() {
playbackStatsTask?.cancel()
playbackStatsTask = nil
}
}