mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user