Fix periodic tvOS playback stutter from render stalls on mpvQueue

Build 264 stutters for a split second every ~10 seconds. The periodic
playback-stats task fetches ~30 debug properties in one ~190ms block on
mpvQueue, and MPVClient.render() wrapped mpv_render_context_render in
mpvQueue.sync - so the render thread queued behind the fetch and stalled
~190ms, at exactly the stats interval. Build 261 predates the stats
task, which is why it was unaffected.

libmpv's render API is documented thread-safe relative to the client
API, so render-context calls no longer go through mpvQueue:

- New renderContextLock protects renderContext lifecycle vs use;
  render(), renderWithDepth(), renderSoftware(), reportSwap(),
  shouldRenderUpdateFrame() and hasRenderContext use it
- Render-update callbacks dispatch on a dedicated renderEventQueue so
  frame-ready notifications can't queue behind property/command work
- destroyRenderContext takes mpvQueue then the lock, still excluding
  in-flight renders during teardown
- The stats task skips its property fetch on iOS/tvOS unless verbose
  MPV logging is on (macOS keeps it for the render watchdog)
- performRender slow-frame warnings now break down render vs present
  time and report frame-delivery gaps for future triage
This commit is contained in:
Arkadiusz Fal
2026-07-19 00:53:06 +02:00
parent c3bb01e862
commit 6aa5fd8ee7
3 changed files with 150 additions and 76 deletions

View File

@@ -140,6 +140,18 @@ final class MPVClient: @unchecked Sendable {
private var mpv: OpaquePointer? private var mpv: OpaquePointer?
private var renderContext: OpaquePointer? private var renderContext: OpaquePointer?
private let mpvQueue = DispatchQueue(label: "stream.yattee.mpv.client", qos: .userInteractive) private let mpvQueue = DispatchQueue(label: "stream.yattee.mpv.client", qos: .userInteractive)
/// Protects `renderContext` lifecycle vs use. Render-context calls are
/// thread-safe relative to the client API per libmpv docs, so they must NOT
/// serialize through `mpvQueue`: any slow queue work (e.g. a batched debug
/// property fetch) would block `render()` and visibly stall playback
/// (~190ms hitch every 10s on tvOS, build 264 regression).
private let renderContextLock = NSLock()
/// Serial queue for render-update callback dispatch. Deliberately separate
/// from `mpvQueue` so frame-ready notifications are never delayed behind
/// property/command work.
private let renderEventQueue = DispatchQueue(label: "stream.yattee.mpv.render-events", qos: .userInteractive)
private var isDestroyed = false private var isDestroyed = false
#if os(macOS) #if os(macOS)
@@ -1780,7 +1792,9 @@ final class MPVClient: @unchecked Sendable {
return false return false
} }
renderContextLock.lock()
renderContext = ctx renderContext = ctx
renderContextLock.unlock()
log("Render context created successfully") log("Render context created successfully")
MPVLogging.log("createRenderContext: success") MPVLogging.log("createRenderContext: success")
@@ -1800,10 +1814,16 @@ final class MPVClient: @unchecked Sendable {
#else #else
// On iOS/tvOS, check if this update includes an actual video frame. // On iOS/tvOS, check if this update includes an actual video frame.
// This is needed for PiP frame capture which relies on onVideoFrameReady. // This is needed for PiP frame capture which relies on onVideoFrameReady.
client.mpvQueue.async { // Dispatched on renderEventQueue (NOT mpvQueue) so frame notifications
guard let renderCtx = client.renderContext else { return } // are never delayed behind slow property/command work.
client.renderEventQueue.async {
client.renderContextLock.lock()
var hasVideoFrame = false
if let renderCtx = client.renderContext, !client.isDestroyed {
let flags = mpv_render_context_update(renderCtx) let flags = mpv_render_context_update(renderCtx)
let hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0 hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
}
client.renderContextLock.unlock()
// Always notify for general redraw // Always notify for general redraw
client.onRenderUpdate?() client.onRenderUpdate?()
@@ -1829,7 +1849,9 @@ final class MPVClient: @unchecked Sendable {
/// - Note: This may trigger a priority inversion warning because MPV's internal /// - Note: This may trigger a priority inversion warning because MPV's internal
/// threads run at default QoS. This is unavoidable when using mpv_render_context_render. /// threads run at default QoS. This is unavoidable when using mpv_render_context_render.
func render(fbo: Int32, width: Int32, height: Int32) { func render(fbo: Int32, width: Int32, height: Int32) {
mpvQueue.sync { renderContextLock.lock()
defer { renderContextLock.unlock() }
guard let renderContext, !isDestroyed else { guard let renderContext, !isDestroyed else {
// Log when render is skipped to help diagnose black screen issues // Log when render is skipped to help diagnose black screen issues
MPVLogging.warn("render: skipped", MPVLogging.warn("render: skipped",
@@ -1860,7 +1882,6 @@ final class MPVClient: @unchecked Sendable {
} }
} }
} }
}
/// Create a render context for software rendering (CPU-based, for simulator). /// Create a render context for software rendering (CPU-based, for simulator).
/// - Returns: Whether creation succeeded /// - Returns: Whether creation succeeded
@@ -1897,7 +1918,9 @@ final class MPVClient: @unchecked Sendable {
return false return false
} }
renderContextLock.lock()
renderContext = ctx renderContext = ctx
renderContextLock.unlock()
log("Software render context created successfully") log("Software render context created successfully")
MPVLogging.log("createSoftwareRenderContext: success") MPVLogging.log("createSoftwareRenderContext: success")
@@ -1910,10 +1933,14 @@ final class MPVClient: @unchecked Sendable {
let client = Unmanaged<MPVClient>.fromOpaque(clientPtr).takeUnretainedValue() let client = Unmanaged<MPVClient>.fromOpaque(clientPtr).takeUnretainedValue()
// Check if this update includes an actual video frame // Check if this update includes an actual video frame
client.mpvQueue.async { client.renderEventQueue.async {
guard let renderCtx = client.renderContext else { return } client.renderContextLock.lock()
var hasVideoFrame = false
if let renderCtx = client.renderContext, !client.isDestroyed {
let flags = mpv_render_context_update(renderCtx) let flags = mpv_render_context_update(renderCtx)
let hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0 hasVideoFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
}
client.renderContextLock.unlock()
// Always notify for general redraw // Always notify for general redraw
client.onRenderUpdate?() client.onRenderUpdate?()
@@ -1939,7 +1966,9 @@ final class MPVClient: @unchecked Sendable {
/// - Returns: true if a frame was rendered, false otherwise /// - Returns: true if a frame was rendered, false otherwise
@discardableResult @discardableResult
func renderSoftware(buffer: UnsafeMutableRawPointer, width: Int32, height: Int32, stride: Int) -> Bool { func renderSoftware(buffer: UnsafeMutableRawPointer, width: Int32, height: Int32, stride: Int) -> Bool {
mpvQueue.sync { renderContextLock.lock()
defer { renderContextLock.unlock() }
return {
guard let renderContext, !isDestroyed else { guard let renderContext, !isDestroyed else {
return false return false
} }
@@ -1992,15 +2021,21 @@ final class MPVClient: @unchecked Sendable {
} }
return true return true
} }()
} }
/// Report that the next frame should be rendered. /// Report that the next frame should be rendered.
func reportRenderUpdate() { func reportRenderUpdate() {
mpvQueue.async { [weak self] in renderEventQueue.async { [weak self] in
guard let self, let renderContext = self.renderContext, !self.isDestroyed else { return } guard let self else { return }
self.renderContextLock.lock()
var hasFrame = false
if let renderContext = self.renderContext, !self.isDestroyed {
let flags = mpv_render_context_update(renderContext) let flags = mpv_render_context_update(renderContext)
if flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0 { hasFrame = flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
}
self.renderContextLock.unlock()
if hasFrame {
self.onRenderUpdate?() self.onRenderUpdate?()
} }
} }
@@ -2008,7 +2043,11 @@ final class MPVClient: @unchecked Sendable {
/// Destroy the render context. /// Destroy the render context.
func destroyRenderContext() { func destroyRenderContext() {
// mpvQueue serializes against create; renderContextLock excludes
// in-flight render/update calls while the context is freed.
mpvQueue.sync { mpvQueue.sync {
renderContextLock.lock()
defer { renderContextLock.unlock() }
guard let ctx = renderContext else { guard let ctx = renderContext else {
MPVLogging.log("destroyRenderContext: no context to destroy") MPVLogging.log("destroyRenderContext: no context to destroy")
return return
@@ -2022,7 +2061,9 @@ final class MPVClient: @unchecked Sendable {
/// Whether the render context is initialized. /// Whether the render context is initialized.
var hasRenderContext: Bool { var hasRenderContext: Bool {
mpvQueue.sync { renderContext != nil } renderContextLock.lock()
defer { renderContextLock.unlock() }
return renderContext != nil
} }
// MARK: - macOS OpenGL Context Management // MARK: - macOS OpenGL Context Management
@@ -2052,18 +2093,21 @@ final class MPVClient: @unchecked Sendable {
// MARK: - Frame Timing // MARK: - Frame Timing
/// Report that a frame was swapped/presented (for vsync timing). /// Report that a frame was swapped/presented (for vsync timing).
/// Called synchronously from the render thread right after present - direct
/// call (no queue hop) keeps the swap timestamp accurate for mpv's vsync
/// estimation and avoids any queue-contention delay.
func reportSwap() { func reportSwap() {
mpvQueue.async { [weak self] in renderContextLock.lock()
guard let ctx = self?.renderContext else { return } defer { renderContextLock.unlock() }
guard let ctx = renderContext, !isDestroyed else { return }
mpv_render_context_report_swap(ctx) mpv_render_context_report_swap(ctx)
} }
}
/// Check if MPV has a frame ready to render (non-blocking). /// Check if MPV has a frame ready to render (non-blocking).
/// This is safe to call from any thread - mpv's render context API is thread-safe. /// This is safe to call from any thread - mpv's render context API is thread-safe.
func shouldRenderUpdateFrame() -> Bool { func shouldRenderUpdateFrame() -> Bool {
// Don't use mpvQueue.sync here - it can cause deadlocks when called from render queue renderContextLock.lock()
// mpv_render_context_update is documented as thread-safe defer { renderContextLock.unlock() }
guard let ctx = renderContext, !isDestroyed else { return false } guard let ctx = renderContext, !isDestroyed else { return false }
let flags = mpv_render_context_update(ctx) let flags = mpv_render_context_update(ctx)
return flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0 return flags & UInt64(MPV_RENDER_UPDATE_FRAME.rawValue) != 0
@@ -2071,7 +2115,9 @@ final class MPVClient: @unchecked Sendable {
/// Get the render context directly (for thread-safe mpv render operations). /// Get the render context directly (for thread-safe mpv render operations).
var mpvRenderContext: OpaquePointer? { var mpvRenderContext: OpaquePointer? {
renderContext renderContextLock.lock()
defer { renderContextLock.unlock() }
return renderContext
} }
// MARK: - Rendering with Depth // MARK: - Rendering with Depth
@@ -2083,7 +2129,9 @@ final class MPVClient: @unchecked Sendable {
/// - height: Render height in pixels /// - height: Render height in pixels
/// - depth: Color depth (8 or 16 for 10-bit) /// - depth: Color depth (8 or 16 for 10-bit)
func renderWithDepth(fbo: Int32, width: Int32, height: Int32, depth: Int32) { func renderWithDepth(fbo: Int32, width: Int32, height: Int32, depth: Int32) {
mpvQueue.sync { renderContextLock.lock()
defer { renderContextLock.unlock() }
guard let renderContext, !isDestroyed else { guard let renderContext, !isDestroyed else {
MPVLogging.warn("renderWithDepth: skipped", MPVLogging.warn("renderWithDepth: skipped",
details: "ctx:\(renderContext != nil) destroyed:\(isDestroyed)") details: "ctx:\(renderContext != nil) destroyed:\(isDestroyed)")
@@ -2118,4 +2166,3 @@ final class MPVClient: @unchecked Sendable {
} }
} }
} }
}

View File

@@ -836,9 +836,23 @@ final class MPVRenderView: UIView {
private var slowFrameCount: UInt64 = 0 private var slowFrameCount: UInt64 = 0
private var lastSlowFrameWarning: Date = .distantPast private var lastSlowFrameWarning: Date = .distantPast
/// Debug: start time of the previous performRender, to spot gaps where mpv
/// stopped delivering frames (core-side stall vs. GL-side slow frame).
private var lastRenderStartMediaTime: CFTimeInterval = 0
private var lastGapWarning: Date = .distantPast
private func performRender() { private func performRender() {
defer { isRendering = false } defer { isRendering = false }
let frameStart = Date() let frameStart = Date()
let tFrameStart = CACurrentMediaTime()
let gapSincePrevFrame = lastRenderStartMediaTime > 0 ? tFrameStart - lastRenderStartMediaTime : 0
lastRenderStartMediaTime = tFrameStart
if hasRenderedFirstFrame, gapSincePrevFrame > 0.25,
Date().timeIntervalSince(lastGapWarning) > 2 {
lastGapWarning = Date()
MPVLogging.warn("performRender: frame delivery gap",
details: "gap=\(Int(gapSincePrevFrame * 1000))ms since previous frame")
}
guard let eaglContext, let mpvClient, framebuffer != 0 else { guard let eaglContext, let mpvClient, framebuffer != 0 else {
// Log when render is skipped due to missing resources (rare but important) // Log when render is skipped due to missing resources (rare but important)
@@ -894,6 +908,8 @@ final class MPVRenderView: UIView {
mpvClient.render(fbo: GLint(framebuffer), width: renderWidth, height: renderHeight) mpvClient.render(fbo: GLint(framebuffer), width: renderWidth, height: renderHeight)
} }
let tRenderEnd = CACurrentMediaTime()
// Present the renderbuffer (skip when PiP is active - main view is hidden anyway) // Present the renderbuffer (skip when PiP is active - main view is hidden anyway)
if !isPiPActive { if !isPiPActive {
glBindRenderbuffer(GLenum(GL_RENDERBUFFER), colorRenderbuffer) glBindRenderbuffer(GLenum(GL_RENDERBUFFER), colorRenderbuffer)
@@ -922,8 +938,10 @@ final class MPVRenderView: UIView {
slowFrameCount += 1 slowFrameCount += 1
if Date().timeIntervalSince(lastSlowFrameWarning) > 5 { if Date().timeIntervalSince(lastSlowFrameWarning) > 5 {
lastSlowFrameWarning = Date() lastSlowFrameWarning = Date()
let renderMs = Int((tRenderEnd - tFrameStart) * 1000)
let presentMs = Int((CACurrentMediaTime() - tRenderEnd) * 1000)
MPVLogging.warn("performRender: slow frame", MPVLogging.warn("performRender: slow frame",
details: "duration=\(Int(frameDuration * 1000))ms slowFramesSinceLastWarning=\(slowFrameCount) size=\(renderWidth)x\(renderHeight)") details: "duration=\(Int(frameDuration * 1000))ms render=\(renderMs)ms present+swap=\(presentMs)ms gapBefore=\(Int(gapSincePrevFrame * 1000))ms slowFramesSinceLastWarning=\(slowFrameCount) size=\(renderWidth)x\(renderHeight)")
slowFrameCount = 0 slowFrameCount = 0
} }
} }

View File

@@ -2063,6 +2063,15 @@ extension MPVBackend: MPVClientDelegate {
try? await Task.sleep(for: .seconds(10)) try? await Task.sleep(for: .seconds(10))
guard let self, !Task.isCancelled, let client = self.mpvClient else { return } guard let self, !Task.isCancelled, let client = self.mpvClient else { return }
#if !os(macOS)
// Only macOS needs the property fetch every tick (render
// watchdog below). Elsewhere the batched mpv_get_property
// calls contend with the playback core and caused a visible
// stutter every 10s on tvOS, so skip the fetch entirely
// unless verbose logging wants the stats line.
guard MPVLogging.verboseEnabled else { continue }
#endif
let props = await client.getDebugPropertiesAsync() let props = await client.getDebugPropertiesAsync()
#if os(macOS) #if os(macOS)