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 newBounds = CGRect(x: 0, y: 0, width: currentBounds.width, height: newHeight)
if let newBounds = sanitizedGeometry(newBounds, from: "updateVideoAspectRatio") {
CATransaction.begin()
CATransaction.setDisableActions(true)
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)
}
}
#else
// 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.
@@ -371,9 +373,25 @@ final class MPVPiPBridge: NSObject {
}
#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.
/// On macOS, the frame should be relative to the window's content view.
func updateLayerFrame(_ frame: CGRect) {
guard let frame = sanitizedGeometry(frame, from: "updateLayerFrame(_:)") else { return }
sampleBufferLayer.frame = frame
}
@@ -381,7 +399,8 @@ final class MPVPiPBridge: NSObject {
/// Update the layer frame based on container view's bounds.
/// Call this on macOS when the container view's size changes.
func updateLayerFrame(for containerView: NSView) {
sampleBufferLayer.frame = containerView.bounds
guard let frame = sanitizedGeometry(containerView.bounds, from: "updateLayerFrame(for:)") else { return }
sampleBufferLayer.frame = frame
}
#endif
@@ -390,7 +409,9 @@ final class MPVPiPBridge: NSObject {
/// as the layer must be in a visible window hierarchy.
func moveLayer(to containerView: PlatformView) {
sampleBufferLayer.removeFromSuperlayer()
sampleBufferLayer.frame = containerView.bounds
if let frame = sanitizedGeometry(containerView.bounds, from: "moveLayer(to:)") {
sampleBufferLayer.frame = frame
}
#if os(iOS)
containerView.layer.addSublayer(sampleBufferLayer)
#elseif os(macOS)
@@ -886,14 +907,19 @@ extension MPVPiPBridge {
}
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
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
guard abs(heightDelta) >= 1 else { return }
var frame = pipWindow.frame
frame.size.height += heightDelta
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)
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
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 {
LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match PiP window: \(sampleBufferLayer.frame) -> \(newFrame)", category: .mpv)
@@ -1012,7 +1038,7 @@ extension MPVPiPBridge {
} else {
// Fallback: try to match superlayer
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 {
LoggingService.shared.debug("MPVPiPBridge: Resizing layer to match superlayer: \(sampleBufferLayer.frame) -> \(superBounds)", category: .mpv)
CATransaction.begin()