Guard PiP bridge geometry writes against non-finite rects

Two build 264 crashes on macOS 26.5.1 hit AppKit's non-finite-frame
trap (_NSViewValidateGeometry) on AVKit's PiP sample-buffer host view
during a routine layout pass. The NaN is produced inside AVKit from
geometry we feed it - either the manual PiP window resize around a
video switch or an infinite container rect copied into the sample
buffer layer frame.

Add a sanitizedGeometry helper that rejects NaN/infinite or degenerate
rects and apply it at every layer/window geometry write in the bridge
(updateLayerFrame variants, moveLayer, updateVideoAspectRatio,
resizePiPWindow, updateLayerFrameToMatchPiPWindow). Rejected writes are
logged with their call site so the actual source can be identified when
it reproduces, instead of crashing on the next CATransaction commit.
This commit is contained in:
Arkadiusz Fal
2026-07-23 23:12:23 +02:00
parent 41d58f47a3
commit c01b8f63e1

View File

@@ -249,6 +249,7 @@ final class MPVPiPBridge: NSObject {
let newHeight = currentBounds.width / aspectRatio let newHeight = currentBounds.width / aspectRatio
let newBounds = CGRect(x: 0, y: 0, width: currentBounds.width, height: newHeight) let newBounds = CGRect(x: 0, y: 0, width: currentBounds.width, height: newHeight)
if let newBounds = sanitizedGeometry(newBounds, from: "updateVideoAspectRatio") {
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(true) CATransaction.setDisableActions(true)
sampleBufferLayer.bounds = newBounds sampleBufferLayer.bounds = newBounds
@@ -256,6 +257,7 @@ final class MPVPiPBridge: NSObject {
LoggingService.shared.debug("MPVPiPBridge: Updated aspect ratio to \(aspectRatio), layer bounds: \(newBounds)", category: .mpv) LoggingService.shared.debug("MPVPiPBridge: Updated aspect ratio to \(aspectRatio), layer bounds: \(newBounds)", category: .mpv)
} }
}
#else #else
// On iOS, don't modify bounds when PiP is inactive - this causes frame misalignment // On iOS, don't modify bounds when PiP is inactive - this causes frame misalignment
// (negative Y offset) which breaks the system's PiP restore UI positioning. // (negative Y offset) which breaks the system's PiP restore UI positioning.
@@ -371,9 +373,25 @@ final class MPVPiPBridge: NSObject {
} }
#endif #endif
/// Validate a rect before it reaches PiP geometry. AppKit traps the whole
/// app when AVKit's PiP host view derives a non-finite frame from our
/// layer/window geometry (build 264 crash), so reject NaN/infinite or
/// degenerate rects and log the site instead of storing poisoned values.
private func sanitizedGeometry(_ rect: CGRect, from site: String) -> CGRect? {
guard rect.origin.x.isFinite, rect.origin.y.isFinite,
rect.width.isFinite, rect.height.isFinite,
rect.width > 0, rect.height > 0
else {
LoggingService.shared.debug("MPVPiPBridge: rejected invalid geometry \(rect) from \(site)", category: .mpv)
return nil
}
return rect
}
/// Update the layer frame when container bounds change. /// Update the layer frame when container bounds change.
/// On macOS, the frame should be relative to the window's content view. /// On macOS, the frame should be relative to the window's content view.
func updateLayerFrame(_ frame: CGRect) { func updateLayerFrame(_ frame: CGRect) {
guard let frame = sanitizedGeometry(frame, from: "updateLayerFrame(_:)") else { return }
sampleBufferLayer.frame = frame sampleBufferLayer.frame = frame
} }
@@ -381,7 +399,8 @@ final class MPVPiPBridge: NSObject {
/// Update the layer frame based on container view's bounds. /// Update the layer frame based on container view's bounds.
/// Call this on macOS when the container view's size changes. /// Call this on macOS when the container view's size changes.
func updateLayerFrame(for containerView: NSView) { func updateLayerFrame(for containerView: NSView) {
sampleBufferLayer.frame = containerView.bounds guard let frame = sanitizedGeometry(containerView.bounds, from: "updateLayerFrame(for:)") else { return }
sampleBufferLayer.frame = frame
} }
#endif #endif
@@ -390,7 +409,9 @@ final class MPVPiPBridge: NSObject {
/// as the layer must be in a visible window hierarchy. /// as the layer must be in a visible window hierarchy.
func moveLayer(to containerView: PlatformView) { func moveLayer(to containerView: PlatformView) {
sampleBufferLayer.removeFromSuperlayer() sampleBufferLayer.removeFromSuperlayer()
sampleBufferLayer.frame = containerView.bounds if let frame = sanitizedGeometry(containerView.bounds, from: "moveLayer(to:)") {
sampleBufferLayer.frame = frame
}
#if os(iOS) #if os(iOS)
containerView.layer.addSublayer(sampleBufferLayer) containerView.layer.addSublayer(sampleBufferLayer)
#elseif os(macOS) #elseif os(macOS)
@@ -886,14 +907,19 @@ extension MPVPiPBridge {
} }
let contentRect = pipWindow.contentRect(forFrameRect: pipWindow.frame) let contentRect = pipWindow.contentRect(forFrameRect: pipWindow.frame)
guard contentRect.width > 0 else { return } guard contentRect.width > 0, contentRect.width.isFinite else { return }
let newContentHeight = contentRect.width / aspectRatio let newContentHeight = contentRect.width / aspectRatio
guard newContentHeight.isFinite, newContentHeight > 0 else {
LoggingService.shared.debug("MPVPiPBridge: resizePiPWindow - rejected invalid content height \(newContentHeight) for aspect \(aspectRatio)", category: .mpv)
return
}
let heightDelta = newContentHeight - contentRect.height let heightDelta = newContentHeight - contentRect.height
guard abs(heightDelta) >= 1 else { return } guard abs(heightDelta) >= 1 else { return }
var frame = pipWindow.frame var frame = pipWindow.frame
frame.size.height += heightDelta frame.size.height += heightDelta
frame.origin.y -= heightDelta // keep the top edge in place frame.origin.y -= heightDelta // keep the top edge in place
guard let frame = sanitizedGeometry(frame, from: "resizePiPWindow") else { return }
pipWindow.setFrame(frame, display: true, animate: false) pipWindow.setFrame(frame, display: true, animate: false)
LoggingService.shared.debug("MPVPiPBridge: Resized PiP window for aspect \(aspectRatio): \(frame)", category: .mpv) LoggingService.shared.debug("MPVPiPBridge: Resized PiP window for aspect \(aspectRatio): \(frame)", category: .mpv)
@@ -1000,7 +1026,7 @@ extension MPVPiPBridge {
// Fix mispositioned internal AVKit views that cause the black bar // Fix mispositioned internal AVKit views that cause the black bar
fixPiPLayerHostViewPosition(in: pipWindow) fixPiPLayerHostViewPosition(in: pipWindow)
let newFrame = CGRect(origin: .zero, size: windowSize) guard let newFrame = sanitizedGeometry(CGRect(origin: .zero, size: windowSize), from: "updateLayerFrameToMatchPiPWindow") else { return }
if sampleBufferLayer.frame.size != newFrame.size { if sampleBufferLayer.frame.size != newFrame.size {
LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match PiP window: \(sampleBufferLayer.frame) -> \(newFrame)", category: .mpv) LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match PiP window: \(sampleBufferLayer.frame) -> \(newFrame)", category: .mpv)
@@ -1012,7 +1038,7 @@ extension MPVPiPBridge {
} else { } else {
// Fallback: try to match superlayer // Fallback: try to match superlayer
guard let superlayer = sampleBufferLayer.superlayer else { return } guard let superlayer = sampleBufferLayer.superlayer else { return }
let superBounds = superlayer.bounds guard let superBounds = sanitizedGeometry(superlayer.bounds, from: "updateLayerFrameToMatchPiPWindow(superlayer)") else { return }
if sampleBufferLayer.frame != superBounds { if sampleBufferLayer.frame != superBounds {
LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match superlayer: \(sampleBufferLayer.frame) -> \(superBounds)", category: .mpv) LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match superlayer: \(sampleBufferLayer.frame) -> \(superBounds)", category: .mpv)
CATransaction.begin() CATransaction.begin()