From f407f418555745ed7b4a29fd8bc8ba6f65aa11c9 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 18 Jul 2026 11:51:31 +0200 Subject: [PATCH] =?UTF-8?q?fix(apple):=20present=20windowed=20macOS=20Pyro?= =?UTF-8?q?Wave=20via=20IOSurface=20layer=20contents=20=E2=80=94=20swapID?= =?UTF-8?q?=20panic=20survives=20glass=20pacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DCP "mismatched swapID's" kernel panic reproduced on a 240 Hz Mac Studio with stage-3 glass pacing active: a fully serialized, one-in-flight present stream still races WindowServer's own swap submissions. So the mitigation has to change the MECHANISM, not the rate — the CAMetalLayer image queue itself is the racing path in a composited (windowed) session. Windowed PyroWave now presents the way video players do: the planar CSC renders into a pooled IOSurface (4 × BGRA8, in-use-aware LRU reuse) and the render thread hands it to a plain CALayer's `contents` on main inside an ordinary CATransaction. WindowServer treats that as normal layer damage on its own composite cadence — no out-of-band image-queue swaps to race. Fullscreen keeps the CAMetalLayer path (direct scanout, no compositing, no panic reports); the hosting view pushes the window's composited state on every layout, and flipping modes just covers/uncovers the metal layer (no black flash). VT codecs keep the metal path everywhere: no panic reports there, and their HDR/EDR presentation has no surface-contents equivalent wired. Co-Authored-By: Claude Fable 5 --- .../Video/MetalVideoPresenter.swift | 193 ++++++++++++++++++ .../PunktfunkKit/Video/SessionPresenter.swift | 49 +++++ .../PunktfunkKit/Video/Stage2Pipeline.swift | 12 ++ .../PunktfunkKit/Views/StreamView.swift | 5 + 4 files changed, 259 insertions(+) diff --git a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift index dc2a587e..8612743c 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift @@ -14,6 +14,9 @@ #if canImport(Metal) && canImport(QuartzCore) import CoreGraphics import CoreVideo +#if os(macOS) +import IOSurface +#endif import Metal import QuartzCore import os @@ -228,6 +231,51 @@ public final class MetalVideoPresenter { /// The layer the hosting view installs (as a sublayer) and sizes to its bounds. public let layer: CAMetalLayer + #if os(macOS) + /// The WINDOWED-mode PyroWave present target: a plain CALayer sized like `layer` (installed + /// as a sibling ABOVE it), fed IOSurfaces via `contents` inside ordinary CATransactions. + /// + /// Why this exists — the macOS DCP KERNEL PANIC ("mismatched swapID's" @UnifiedPipeline.cpp, + /// WindowServer dies, machine reboots): out-of-band CAMetalLayer image-queue swaps into a + /// COMPOSITED (windowed) session race WindowServer's own swap submissions on high-refresh + /// displays, and the race survives glass pacing — a fully serialized one-in-flight present + /// stream still panicked a 240 Hz Mac Studio (2026-07-18, twice). So in windowed mode we stop + /// using the image queue entirely and present the way video players do: render the planar CSC + /// into an IOSurface pool and swap `contents` on main — WindowServer treats it as ordinary + /// damage on its own composite cadence, coalescing faster-than-refresh updates instead of + /// latching queue swaps mid-cycle. Fullscreen keeps the CAMetalLayer path (direct-scanout + /// promotion, no compositing, no panic reports). Contents updates are transparent to the + /// layer below when nil, so flipping modes just covers/uncovers the metal layer. + public let surfaceLayer: CALayer = { + let l = CALayer() + l.contentsGravity = .resize // frame is already aspect-fit + pixel-snapped by layout + l.isOpaque = true + l.actions = ["contents": NSNull(), "bounds": NSNull(), "position": NSNull()] + return l + }() + + /// One IOSurface-backed render target of the windowed present pool. All pool state is + /// RENDER-THREAD confined; only the immutable surface refs cross to main (contents swap). + private struct SurfaceSlot { + let surface: IOSurfaceRef + let texture: MTLTexture + /// Monotonic use stamp — the reuse picker takes the least-recently-rendered free slot. + var seq: UInt64 = 0 + } + + private var surfacePool: [SurfaceSlot] = [] + private var surfacePoolSize: CGSize = .zero + private var surfaceSeq: UInt64 = 0 + /// Index of the slot most recently handed to the layer — never rewritten next, even if its + /// use count already dropped (the compositor may still be scanning out the previous frame). + private var lastHandedOff: Int? + /// Staged (under `stagingLock`, like every cross-thread input): the hosting view's windowed + /// vs fullscreen state, pushed from main via `setSurfacePresents`. Drained in `renderPlanar`. + private var surfacePresentsStaged = false + /// Render-thread copy, so pool teardown happens exactly once on a mode flip. + private var surfacePresentsActive = false + #endif + private let device: MTLDevice private let queue: MTLCommandQueue /// SDR (BT.709 8-bit → bgra8) and HDR (BT.2020 PQ 10-bit → rgba16Float) pipelines. Selected per @@ -493,6 +541,18 @@ public final class MetalVideoPresenter { stagingLock.unlock() } + #if os(macOS) + /// Park the windowed-vs-fullscreen present routing (MAIN thread — the hosting view pushes its + /// window state on every layout). true = PyroWave frames present via `surfaceLayer` contents + /// (the DCP swapID-panic mitigation — see `surfaceLayer`); false = the CAMetalLayer path. + /// Applied by the render thread on the next frame, like every other staged value here. + public func setSurfacePresents(_ on: Bool) { + stagingLock.lock() + surfacePresentsStaged = on + stagingLock.unlock() + } + #endif + /// Draw one decoded frame to the next drawable and present it. RENDER THREAD (Stage2Pipeline's; /// `nextDrawable()` may block up to a frame — that wait belongs here, never on main). `isHDR` /// selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the @@ -588,9 +648,30 @@ public final class MetalVideoPresenter { ) -> Bool { stagingLock.lock() let targetFromLayout = drawableTarget + #if os(macOS) + let surfaceMode = surfacePresentsStaged + #endif stagingLock.unlock() configure(hdr: false) var csc = planes.csc + #if os(macOS) + if surfaceMode != surfacePresentsActive { + surfacePresentsActive = surfaceMode + presenterLog.info( + "stage2: windowed surface presents \(surfaceMode ? "ON" : "OFF", privacy: .public) (PyroWave DCP-panic mitigation)") + if !surfaceMode { + // Back to the metal path (fullscreen): drop the pool — at 5K it holds >100 MB, + // and re-entering windowed mode rebuilds it in one frame. + surfacePool.removeAll() + surfacePoolSize = .zero + lastHandedOff = nil + } + } + if surfaceMode { + return renderPlanarToSurface( + planes, targetFromLayout: targetFromLayout, csc: &csc, onPresented: onPresented) + } + #endif return encodePresent( decodedSize: CGSize(width: planes.width, height: planes.height), targetFromLayout: targetFromLayout, pipeline: pipelinePlanar, @@ -606,6 +687,118 @@ public final class MetalVideoPresenter { } } + #if os(macOS) + /// The windowed-mode present tail (see `surfaceLayer` for why this path exists): render the + /// planar CSC into a pooled IOSurface and hand it to `surfaceLayer.contents` on MAIN inside a + /// plain CATransaction — an ordinary damaged-layer update on WindowServer's own composite + /// cadence, no CAMetalLayer image-queue swap anywhere. `presentAtMediaTime` doesn't apply + /// (the compositor paces); `onPresented` fires after the contents swap is committed, stamped + /// with CLOCK_REALTIME then — the closest observable analogue of "reached glass" here (the + /// composite follows within a refresh, so the meters' display stage reads slightly optimistic). + private func renderPlanarToSurface( + _ planes: WaveletPlanes, targetFromLayout: CGSize, csc: inout CscUniform, + onPresented: ((Int64?) -> Void)? + ) -> Bool { + let decodedSize = CGSize(width: planes.width, height: planes.height) + let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0) + ? targetFromLayout : decodedSize + ensureSurfacePool(size: targetSize) + guard let slotIndex = takeSurfaceSlot(), + let commandBuffer = queue.makeCommandBuffer() + else { return false } + let slot = surfacePool[slotIndex] + + let pass = MTLRenderPassDescriptor() + pass.colorAttachments[0].texture = slot.texture + pass.colorAttachments[0].loadAction = .clear + pass.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1) + pass.colorAttachments[0].storeAction = .store + guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else { + return false + } + encoder.setRenderPipelineState(pipelinePlanar) + encoder.setFragmentTexture(planes.y, index: 0) + encoder.setFragmentTexture(planes.cb, index: 1) + encoder.setFragmentTexture(planes.cr, index: 2) + encoder.setFragmentBytes(&csc, length: MemoryLayout.stride, index: 0) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3) + encoder.endEncoding() + let surface = slot.surface + let surfaceLayer = surfaceLayer // captured directly — the handler must not retain self + let keepAlive: [Any] = [planes.y, planes.cb, planes.cr] + commandBuffer.addCompletedHandler { _ in + _ = keepAlive // ring textures pinned until the GPU finished sampling + DispatchQueue.main.async { + CATransaction.begin() + CATransaction.setDisableActions(true) + surfaceLayer.contents = surface + CATransaction.commit() + onPresented?( + Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())) + } + } + commandBuffer.commit() + lastHandedOff = slotIndex + return true + } + + /// (Re)build the pool at `size` — 4 BGRA8 IOSurface render targets (one on glass, one queued + /// in CA, one rendering, one spare). RENDER THREAD. A failed allocation leaves the pool empty; + /// the caller returns false and the ring's putBack + display-link retry take over. + private func ensureSurfacePool(size: CGSize) { + guard size != surfacePoolSize else { return } + surfacePool.removeAll() + surfacePoolSize = size + lastHandedOff = nil + let w = Int(size.width) + let h = Int(size.height) + guard w > 0, h > 0 else { return } + // 256-byte row alignment satisfies both IOSurface and Metal linear-texture rules. + let bytesPerRow = ((w * 4) + 255) & ~255 + let props: [String: Any] = [ + kIOSurfaceWidth as String: w, + kIOSurfaceHeight as String: h, + kIOSurfaceBytesPerElement as String: 4, + kIOSurfaceBytesPerRow as String: bytesPerRow, + kIOSurfacePixelFormat as String: kCVPixelFormatType_32BGRA, + ] + let desc = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .bgra8Unorm, width: w, height: h, mipmapped: false) + desc.usage = [.renderTarget] + desc.storageMode = .shared + for _ in 0..<4 { + guard let surface = IOSurfaceCreate(props as CFDictionary), + let texture = device.makeTexture(descriptor: desc, iosurface: surface, plane: 0) + else { + surfacePool.removeAll() + return + } + surfacePool.append(SurfaceSlot(surface: surface, texture: texture)) + } + } + + /// Pick the slot to render into: never the one just handed to the layer (the compositor may + /// still scan it), prefer surfaces the window server isn't holding (`IOSurfaceIsInUse`), and + /// among those the least recently rendered. Falls back to the LRU busy slot rather than + /// stalling — a visible glitch at worst, never a queue-up. RENDER THREAD. + private func takeSurfaceSlot() -> Int? { + guard !surfacePool.isEmpty else { return nil } + var free: Int? + var busy: Int? + for i in surfacePool.indices where i != lastHandedOff { + if !IOSurfaceIsInUse(surfacePool[i].surface) { + if free == nil || surfacePool[i].seq < surfacePool[free!].seq { free = i } + } else { + if busy == nil || surfacePool[i].seq < surfacePool[busy!].seq { busy = i } + } + } + guard let pick = free ?? busy else { return nil } + surfaceSeq += 1 + surfacePool[pick].seq = surfaceSeq + return pick + } + #endif + /// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one /// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule /// the present and the on-glass callback. diff --git a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift index b3f43a37..c4552afd 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift @@ -103,6 +103,12 @@ final class SessionPresenter { private var stage2: Stage2Pipeline? private var stage2Link: CADisplayLink? private var metalLayer: CAMetalLayer? + #if os(macOS) + /// The windowed-mode PyroWave present target (sibling above `metalLayer`) and the last + /// routing pushed to the pipeline — see `setComposited`. Main-thread only, like all of this. + private var surfaceLayer: CALayer? + private var surfacePresentsActive = false + #endif private var connection: PunktfunkConnection? /// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's /// `onDecodedSize` new-mode-IDR callback). Used for the aspect-fit in `layout` in preference to @@ -161,6 +167,13 @@ final class SessionPresenter { // sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout(). baseLayer.addSublayer(metal) metalLayer = metal + #if os(macOS) + // The windowed-PyroWave present target sits ABOVE the metal layer: transparent (nil + // contents) while the metal path presents, covering it while surface presents run. + baseLayer.addSublayer(pipeline.surfaceLayer) + surfaceLayer = pipeline.surfaceLayer + surfacePresentsActive = false + #endif stage2 = pipeline // The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger // (frame arrival is — see Stage2Pipeline's header). timestamp→targetTimestamp is the @@ -259,6 +272,12 @@ final class SessionPresenter { CATransaction.setDisableActions(true) metalLayer.contentsScale = contentsScale metalLayer.frame = snapped + #if os(macOS) + // The surface present target mirrors the metal layer's geometry exactly — its IOSurfaces + // are sized to the same snapped pixel rect, so the contents composite is a 1:1 blit too. + surfaceLayer?.contentsScale = contentsScale + surfaceLayer?.frame = snapped + #endif CATransaction.commit() // Hand the resulting pixel size to the render thread (it must not read layer geometry // cross-thread) — this is what the presenter sizes its drawable to. Uses the SNAPPED size so @@ -286,6 +305,31 @@ final class SessionPresenter { contentSize = size } + #if os(macOS) + /// Route presents for the window's composited state (MAIN thread — the view pushes it on + /// every layout, which fullscreen transitions always trigger). PyroWave sessions in a + /// COMPOSITED (windowed) session present via `surfaceLayer` contents instead of the + /// CAMetalLayer image queue — the DCP "mismatched swapID's" kernel-panic mitigation (see + /// `MetalVideoPresenter.surfaceLayer`; the metal-swap race survives glass pacing, so pacing + /// alone was not enough). VT codecs keep the metal path: no panic reports there, and their + /// HDR/EDR presentation has no surface-contents equivalent wired. + func setComposited(_ composited: Bool) { + guard let stage2, let connection else { return } + let wantsSurface = composited && connection.videoCodec == .pyrowave + guard wantsSurface != surfacePresentsActive else { return } + surfacePresentsActive = wantsSurface + stage2.setSurfacePresents(wantsSurface) + if !wantsSurface { + // Uncover the metal layer NOW (its last drawable is still attached, so fullscreen + // entry shows the previous frame until the next present — no black flash). + CATransaction.begin() + CATransaction.setDisableActions(true) + surfaceLayer?.contents = nil + CATransaction.commit() + } + } + #endif + /// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the /// stage-2 layer + link. Does not close the connection — that stays with whoever owns it. /// Idempotent. @@ -299,6 +343,11 @@ final class SessionPresenter { stage2 = nil metalLayer?.removeFromSuperlayer() metalLayer = nil + #if os(macOS) + surfaceLayer?.removeFromSuperlayer() + surfaceLayer = nil + surfacePresentsActive = false + #endif connection = nil } diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index 7c32ec3e..2a6a80af 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -630,6 +630,18 @@ public final class Stage2Pipeline { presenter.setDrawableTarget(size) } + #if os(macOS) + /// The windowed-mode PyroWave present target (see `MetalVideoPresenter.surfaceLayer` — the + /// DCP swapID-panic mitigation). The hosting view installs it as a sibling above `layer`. + public var surfaceLayer: CALayer { presenter.surfaceLayer } + + /// Forward the windowed-vs-fullscreen present routing (MAIN thread — see + /// `MetalVideoPresenter.setSurfacePresents`). + public func setSurfacePresents(_ on: Bool) { + presenter.setSurfacePresents(on) + } + #endif + /// Forward the display's current EDR headroom to the presenter (MAIN thread — a `UIScreen` /// read). tvOS flips HDR presentation between PQ passthrough and the in-shader tone-map on /// it; see `MetalVideoPresenter.setDisplayHeadroom`. diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index abead900..b0d7b14b 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -692,6 +692,11 @@ public final class StreamLayerView: NSView { /// the view's physical-pixel size (bounds → backing), so a window resize / retina move follows. private func layoutPresenter() { presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1) + // Present routing tracks the window's composited state (fullscreen transitions always + // re-layout, so this stays current): windowed PyroWave presents via surface contents — + // the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited). A view + // not yet in a window counts as composited (the safe default). + presenter.setComposited(!(window?.styleMask.contains(.fullScreen) ?? false)) // Feed the follower only once in a window (backing scale is real then) and with real // bounds — a pre-window layout would report point-sized dimensions. if window != nil, bounds.width > 0, bounds.height > 0 {