fix(apple): windowed macOS DCP panic via presentsWithTransaction — keep real HDR, drop the SDR IOSurface path
The windowed-macOS "mismatched swapID's" @UnifiedPipeline.cpp kernel panic is the CAMetalLayer ASYNC image queue (commandBuffer.present, mandatory with displaySyncEnabled=false) diverging from WindowServer's compositor on a high-refresh composited session — it survived glass pacing (PyroWave, 2026-07-18) and hit windowed HEVC on the same 240 Hz Mac Studio (2026-07-21), so it's the async queue itself, at any codec or pacing. f407f418's mitigation routed windowed PyroWave through a BGRA8 IOSurface layer.contents pool, which cannot carry HDR — extending it to HEVC would have tone-mapped windowed HDR down to SDR. Instead, present the drawable through a Core Animation transaction (CAMetalLayer.presentsWithTransaction) for every windowed session: commit, waitUntilScheduled, then drawable.present() inside a CATransaction, so the swap commits with the layer tree and stays in lockstep with the compositor instead of racing it. The full rgba16Float / PQ / EDR render path is untouched — windowed HDR is real HDR again. Fullscreen keeps the async path (direct scanout, lowest latency, no panic there). Removes the IOSurface surface pool, its surfaceLayer sibling, and the macOS windowed PQ->SDR tone-map (tvOS keeps its no-headroom tone-map). Net -150 lines. swift build + 169 tests green. Needs on-glass soak on the 240 Hz Mac Studio (kernel race — only real hardware confirms). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,9 +14,6 @@
|
|||||||
#if canImport(Metal) && canImport(QuartzCore)
|
#if canImport(Metal) && canImport(QuartzCore)
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
import CoreVideo
|
import CoreVideo
|
||||||
#if os(macOS)
|
|
||||||
import IOSurface
|
|
||||||
#endif
|
|
||||||
import Metal
|
import Metal
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
import os
|
import os
|
||||||
@@ -198,8 +195,8 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
|||||||
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
|
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
|
||||||
// The shared PQ→display-referred-SDR tail (see pf_frag_hdr_tv's rationale above): ST 2084
|
// The shared PQ→display-referred-SDR tail (see pf_frag_hdr_tv's rationale above): ST 2084
|
||||||
// EOTF → 203-nit-anchored scene light → BT.2020→709 primaries → extended-Reinhard rolloff →
|
// EOTF → 203-nit-anchored scene light → BT.2020→709 primaries → extended-Reinhard rolloff →
|
||||||
// BT.709 OETF. Used by the tvOS biplanar tone-map and the planar (PyroWave) tone-map — the
|
// BT.709 OETF. Used by the tvOS biplanar tone-map and the tvOS planar (PyroWave) tone-map (the
|
||||||
// latter also on macOS windowed sessions, whose IOSurface present path is BGRA8-only.
|
// no-HDR-headroom fallback). macOS keeps real HDR windowed now — see `transactionalPresentStaged`.
|
||||||
static inline float3 pqToSdr(float3 pq) {
|
static inline float3 pqToSdr(float3 pq) {
|
||||||
const float m1 = 2610.0/16384.0;
|
const float m1 = 2610.0/16384.0;
|
||||||
const float m2 = 78.84375;
|
const float m2 = 78.84375;
|
||||||
@@ -230,8 +227,7 @@ fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
|
|||||||
|
|
||||||
// PyroWave planar HDR tone-map: three separate R16 planes (P010-style studio codes; the rows
|
// PyroWave planar HDR tone-map: three separate R16 planes (P010-style studio codes; the rows
|
||||||
// fold in depth-10 MSB packing) → PQ R′G′B′ → the shared SDR tail. Used when a PQ pyrowave
|
// fold in depth-10 MSB packing) → PQ R′G′B′ → the shared SDR tail. Used when a PQ pyrowave
|
||||||
// stream must land on an 8-bit surface: tvOS without HDR headroom, and macOS WINDOWED sessions
|
// stream must land on an 8-bit surface: tvOS without HDR headroom. The passthrough planar
|
||||||
// (the IOSurface present path — the DCP-panic mitigation — is BGRA8). The passthrough planar
|
|
||||||
// HDR pipeline reuses pf_frag_planar itself on an rgba16Float drawable (identical math — the
|
// HDR pipeline reuses pf_frag_planar itself on an rgba16Float drawable (identical math — the
|
||||||
// layer's itur_2100_PQ colour space + EDR metadata do the interpretation).
|
// layer's itur_2100_PQ colour space + EDR metadata do the interpretation).
|
||||||
fragment float4 pf_frag_planar_tm(VOut in [[stage_in]],
|
fragment float4 pf_frag_planar_tm(VOut in [[stage_in]],
|
||||||
@@ -259,48 +255,31 @@ public final class MetalVideoPresenter {
|
|||||||
public let layer: CAMetalLayer
|
public let layer: CAMetalLayer
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
/// The WINDOWED-mode PyroWave present target: a plain CALayer sized like `layer` (installed
|
/// WINDOWED-mode present coordination — the macOS DCP KERNEL PANIC mitigation.
|
||||||
/// 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,
|
/// The panic ("mismatched swapID's" @UnifiedPipeline.cpp, WindowServer dies, machine reboots):
|
||||||
/// WindowServer dies, machine reboots): out-of-band CAMetalLayer image-queue swaps into a
|
/// the CAMetalLayer's ASYNCHRONOUS image queue (`commandBuffer.present(drawable)` — an
|
||||||
/// COMPOSITED (windowed) session race WindowServer's own swap submissions on high-refresh
|
/// out-of-band flip, mandatory with `displaySyncEnabled=false`) diverges from WindowServer's
|
||||||
/// displays, and the race survives glass pacing — a fully serialized one-in-flight present
|
/// compositor on a high-refresh COMPOSITED (windowed) session — the compositor's notion of the
|
||||||
/// stream still panicked a 240 Hz Mac Studio (2026-07-18, twice). So in windowed mode we stop
|
/// current swap and the layer's queued swap disagree, and the DCP asserts. It survived glass
|
||||||
/// using the image queue entirely and present the way video players do: render the planar CSC
|
/// pacing: a fully serialized one-in-flight present stream still panicked a 240 Hz Mac Studio
|
||||||
/// into an IOSurface pool and swap `contents` on main — WindowServer treats it as ordinary
|
/// (2026-07-18, PyroWave), and a windowed HEVC session panicked the same machine 2026-07-21 —
|
||||||
/// damage on its own composite cadence, coalescing faster-than-refresh updates instead of
|
/// so it is the async image queue itself, at any pacing or codec, not a present rate.
|
||||||
/// latching queue swaps mid-cycle. Fullscreen keeps the CAMetalLayer path (direct-scanout
|
///
|
||||||
/// promotion, no compositing, no panic reports). Contents updates are transparent to the
|
/// The fix keeps the full render path (rgba16Float / PQ / EDR — real HDR is preserved) and
|
||||||
/// layer below when nil, so flipping modes just covers/uncovers the metal layer.
|
/// only changes HOW the drawable is presented: `CAMetalLayer.presentsWithTransaction`. With it
|
||||||
public let surfaceLayer: CALayer = {
|
/// set, we don't hand the drawable to the command buffer; we commit, wait until scheduled, then
|
||||||
let l = CALayer()
|
/// call `drawable.present()` INSIDE a CATransaction — the present is enrolled in Core
|
||||||
l.contentsGravity = .resize // frame is already aspect-fit + pixel-snapped by layout
|
/// Animation's transaction and committed together with the layer tree, so the swap stays in
|
||||||
l.isOpaque = true
|
/// lockstep with the compositor instead of racing it (Apple's documented remedy for Metal
|
||||||
l.actions = ["contents": NSNull(), "bounds": NSNull(), "position": NSNull()]
|
/// presentation drifting out of sync with CA). Fullscreen keeps the async path (direct-scanout
|
||||||
return l
|
/// promotion, lowest latency, no compositor and no panic reports there).
|
||||||
}()
|
///
|
||||||
|
/// Staged under `stagingLock` (main pushes it via `setComposited`→`setTransactionalPresent`);
|
||||||
/// One IOSurface-backed render target of the windowed present pool. All pool state is
|
/// the render thread drains it and toggles the layer property + present style. `Active` is the
|
||||||
/// RENDER-THREAD confined; only the immutable surface refs cross to main (contents swap).
|
/// render-thread copy so the layer property flips exactly once per mode change.
|
||||||
private struct SurfaceSlot {
|
private var transactionalPresentStaged = false
|
||||||
let surface: IOSurfaceRef
|
private var transactionalPresentActive = false
|
||||||
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
|
#endif
|
||||||
|
|
||||||
private let device: MTLDevice
|
private let device: MTLDevice
|
||||||
@@ -316,7 +295,7 @@ public final class MetalVideoPresenter {
|
|||||||
private let pipelinePlanar: MTLRenderPipelineState
|
private let pipelinePlanar: MTLRenderPipelineState
|
||||||
/// PyroWave planar HDR passthrough (pf_frag_planar → rgba16Float; the layer's PQ colour
|
/// PyroWave planar HDR passthrough (pf_frag_planar → rgba16Float; the layer's PQ colour
|
||||||
/// space + EDR interpret the samples) and the planar PQ→SDR tone-map (pf_frag_planar_tm →
|
/// space + EDR interpret the samples) and the planar PQ→SDR tone-map (pf_frag_planar_tm →
|
||||||
/// bgra8; tvOS without headroom + macOS windowed IOSurface presents).
|
/// bgra8; tvOS without HDR headroom).
|
||||||
private let pipelinePlanarHDR: MTLRenderPipelineState
|
private let pipelinePlanarHDR: MTLRenderPipelineState
|
||||||
private let pipelinePlanarToneMap: MTLRenderPipelineState
|
private let pipelinePlanarToneMap: MTLRenderPipelineState
|
||||||
private var textureCache: CVMetalTextureCache?
|
private var textureCache: CVMetalTextureCache?
|
||||||
@@ -592,12 +571,13 @@ public final class MetalVideoPresenter {
|
|||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
/// Park the windowed-vs-fullscreen present routing (MAIN thread — the hosting view pushes its
|
/// 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
|
/// window state on every layout). true = COMPOSITED (windowed): present the drawable through a
|
||||||
/// (the DCP swapID-panic mitigation — see `surfaceLayer`); false = the CAMetalLayer path.
|
/// Core Animation transaction (`CAMetalLayer.presentsWithTransaction` — the DCP swapID-panic
|
||||||
|
/// mitigation, see `transactionalPresentStaged`); false = FULLSCREEN: the async image queue.
|
||||||
/// Applied by the render thread on the next frame, like every other staged value here.
|
/// Applied by the render thread on the next frame, like every other staged value here.
|
||||||
public func setSurfacePresents(_ on: Bool) {
|
public func setTransactionalPresent(_ on: Bool) {
|
||||||
stagingLock.lock()
|
stagingLock.lock()
|
||||||
surfacePresentsStaged = on
|
transactionalPresentStaged = on
|
||||||
stagingLock.unlock()
|
stagingLock.unlock()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -734,36 +714,12 @@ public final class MetalVideoPresenter {
|
|||||||
) -> Bool {
|
) -> Bool {
|
||||||
stagingLock.lock()
|
stagingLock.lock()
|
||||||
let targetFromLayout = drawableTarget
|
let targetFromLayout = drawableTarget
|
||||||
#if os(macOS)
|
|
||||||
let surfaceMode = surfacePresentsStaged
|
|
||||||
#endif
|
|
||||||
stagingLock.unlock()
|
stagingLock.unlock()
|
||||||
// A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path;
|
// A PQ (HDR) pyrowave stream drives the same layer/EDR machinery as the biplanar path —
|
||||||
// macOS WINDOWED sessions stay on the SDR layer (the IOSurface path tone-maps in-shader).
|
// including macOS windowed sessions, which keep real HDR (the DCP mitigation is the
|
||||||
#if os(macOS)
|
// transactional present in `encodePresent`, not a colour downgrade).
|
||||||
configure(hdr: planes.pq && !surfaceMode)
|
|
||||||
#else
|
|
||||||
configure(hdr: planes.pq)
|
configure(hdr: planes.pq)
|
||||||
#endif
|
|
||||||
var csc = planes.csc
|
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
|
|
||||||
// PQ passthrough needs the HDR drawable; a PQ frame while the drawable is (still)
|
// PQ passthrough needs the HDR drawable; a PQ frame while the drawable is (still)
|
||||||
// 8-bit — tvOS without display headroom, or a not-yet-flipped layer — tone-maps
|
// 8-bit — tvOS without display headroom, or a not-yet-flipped layer — tone-maps
|
||||||
// in-shader instead (the pipeline must match the drawable's pixel format).
|
// in-shader instead (the pipeline must match the drawable's pixel format).
|
||||||
@@ -792,118 +748,6 @@ 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(planes.pq ? pipelinePlanarToneMap : pipelinePlanar)
|
|
||||||
encoder.setFragmentTexture(planes.y, index: 0)
|
|
||||||
encoder.setFragmentTexture(planes.cb, index: 1)
|
|
||||||
encoder.setFragmentTexture(planes.cr, index: 2)
|
|
||||||
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.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
|
/// The shared present tail of `render`/`renderPlanar`: size the drawable, encode one
|
||||||
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
/// fullscreen triangle with `pipeline` (`bind` supplies the fragment resources), schedule
|
||||||
/// the present and the on-glass callback.
|
/// the present and the on-glass callback.
|
||||||
@@ -936,6 +780,21 @@ public final class MetalVideoPresenter {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
|
||||||
#endif
|
#endif
|
||||||
|
#if os(macOS)
|
||||||
|
// Windowed (composited) → transactional present, the DCP swapID-panic mitigation (see
|
||||||
|
// `transactionalPresentStaged`). Toggle the layer property BEFORE vending a drawable so
|
||||||
|
// the vend matches how it will be presented; drained here on the render thread, flipped
|
||||||
|
// exactly once per mode change.
|
||||||
|
stagingLock.lock()
|
||||||
|
let wantsTransactional = transactionalPresentStaged
|
||||||
|
stagingLock.unlock()
|
||||||
|
if wantsTransactional != transactionalPresentActive {
|
||||||
|
transactionalPresentActive = wantsTransactional
|
||||||
|
layer.presentsWithTransaction = wantsTransactional
|
||||||
|
presenterLog.info(
|
||||||
|
"stage2: windowed transactional present \(wantsTransactional ? "ON" : "OFF", privacy: .public) (DCP swapID-panic mitigation)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
if let providedDrawable,
|
if let providedDrawable,
|
||||||
providedDrawable.texture.pixelFormat != layer.pixelFormat {
|
providedDrawable.texture.pixelFormat != layer.pixelFormat {
|
||||||
return false // config outran the vend (HDR flip) — next vend has the new format
|
return false // config outran the vend (HDR flip) — next vend has the new format
|
||||||
@@ -974,6 +833,33 @@ public final class MetalVideoPresenter {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
|
||||||
|
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
|
||||||
|
#if os(macOS)
|
||||||
|
if transactionalPresentActive {
|
||||||
|
// Windowed DCP mitigation: present the drawable THROUGH a Core Animation transaction
|
||||||
|
// (`presentsWithTransaction`, set above) instead of the async image queue, so the swap
|
||||||
|
// commits with the layer tree and stays in lockstep with the compositor (no out-of-band
|
||||||
|
// flip to race WindowServer's swaps). The present MUST run on the MAIN thread: a
|
||||||
|
// `presentsWithTransaction` present issued from this background render thread never
|
||||||
|
// flushes to the render server, so the drawable is never released — after
|
||||||
|
// maximumDrawableCount vends, `nextDrawable()` blocks forever and the stream FREEZES
|
||||||
|
// (the fullscreen→windowed switch did exactly this). So wait until the GPU work is
|
||||||
|
// scheduled (contents will be ready), then hop to main and present inside its
|
||||||
|
// transaction — the same main-thread CA hop the old IOSurface surface path used
|
||||||
|
// successfully. `presentAtMediaTime` does not apply — the CA transaction paces.
|
||||||
|
commandBuffer.commit()
|
||||||
|
commandBuffer.waitUntilScheduled()
|
||||||
|
let presentedDrawable = drawable
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
CATransaction.begin()
|
||||||
|
CATransaction.setDisableActions(true)
|
||||||
|
presentedDrawable.present()
|
||||||
|
CATransaction.commit()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#endif
|
||||||
// Scheduled on the vsync when the pipeline gave us the link's target (see the doc comment);
|
// Scheduled on the vsync when the pipeline gave us the link's target (see the doc comment);
|
||||||
// immediate otherwise. A target already in the past presents immediately — same thing.
|
// immediate otherwise. A target already in the past presents immediately — same thing.
|
||||||
if let presentAtMediaTime {
|
if let presentAtMediaTime {
|
||||||
@@ -981,8 +867,6 @@ public final class MetalVideoPresenter {
|
|||||||
} else {
|
} else {
|
||||||
commandBuffer.present(drawable)
|
commandBuffer.present(drawable)
|
||||||
}
|
}
|
||||||
// Keep the bound sources alive until the GPU finishes sampling (see the callers).
|
|
||||||
commandBuffer.addCompletedHandler { _ in _ = keepAlive }
|
|
||||||
commandBuffer.commit()
|
commandBuffer.commit()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,20 +142,17 @@ enum PresentPriority: Equatable {
|
|||||||
|
|
||||||
final class SessionPresenter {
|
final class SessionPresenter {
|
||||||
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
|
/// Present pacing for this session. Stage-3 always means glass gating; under the stage-2
|
||||||
/// default, macOS PyroWave sessions ALSO get glass gating — a kernel-panic mitigation, not a
|
/// default, macOS PyroWave sessions ALSO get glass gating — for SMOOTHNESS, not as the panic
|
||||||
/// latency tweak. macOS's DCP panics ("mismatched swapID's" @UnifiedPipeline.cpp, the whole
|
/// fix (that is the windowed transactional present — see `setComposited`). PyroWave's wavelet
|
||||||
/// machine dies) when WindowServer's swap submissions race, and the reliable trigger is
|
/// decode is near-instant Metal compute, so a network clump presents within the same
|
||||||
/// out-of-band CAMetalLayer presents (displaySyncEnabled=false — mandatory for us, see
|
/// millisecond, and it is the codec that sustains stream rates above the panel's refresh; the
|
||||||
/// MetalVideoPresenter's init) arriving faster than the compositor latches them in a
|
/// glass gate admits one presented-but-undisplayed swap at a time (serialized on the on-glass
|
||||||
/// COMPOSITED (windowed) session. Arrival pacing does exactly that with PyroWave: the wavelet
|
/// callback, 100 ms stale backstop) so those bursts coalesce in the newest-wins ring instead
|
||||||
/// decode is near-instant Metal compute, so a network clump of frames presents within the
|
/// of flooding the queue. (Glass pacing was ALSO the original DCP-panic mitigation attempt —
|
||||||
/// same millisecond, and PyroWave is the codec that sustains stream rates above the panel's
|
/// disproven: a fully serialized stream still panicked, which is why the real fix moved to the
|
||||||
/// refresh. The glass gate admits one presented-but-undisplayed swap at a time (serialized on
|
/// present mechanism.) An explicit stage-2 pick (setting/env) still forces arrival pacing —
|
||||||
/// the on-glass callback, 100 ms stale backstop), which removes the racing pattern outright;
|
/// that A/B lever must stay honest. VideoToolbox codecs keep arrival pacing: decode latency
|
||||||
/// frames the panel couldn't have shown anyway coalesce in the newest-wins ring. An explicit
|
/// spaces their presents.
|
||||||
/// stage-2 pick (setting/env) still forces arrival pacing — that A/B lever must stay honest.
|
|
||||||
/// VideoToolbox codecs keep arrival pacing: decode latency spaces their presents, and years
|
|
||||||
/// of stage-2 defaults there predate any panic report.
|
|
||||||
static func pacing(
|
static func pacing(
|
||||||
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
|
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
|
||||||
) -> PresentPacing {
|
) -> PresentPacing {
|
||||||
@@ -179,9 +176,9 @@ final class SessionPresenter {
|
|||||||
/// that can't queue at all — that's stage-4 (`PresentPacing.deadline`), not a deeper gate.
|
/// that can't queue at all — that's stage-4 (`PresentPacing.deadline`), not a deeper gate.
|
||||||
///
|
///
|
||||||
/// `PUNKTFUNK_GATE_DEPTH` (1…3) still overrides on iOS/tvOS so the standing-queue ladder
|
/// `PUNKTFUNK_GATE_DEPTH` (1…3) still overrides on iOS/tvOS so the standing-queue ladder
|
||||||
/// stays reproducible on-device; macOS is pinned to 1, env ignored — glass pacing exists
|
/// stays reproducible on-device; macOS is pinned to 1, env ignored — a deeper gate only builds
|
||||||
/// there as the DCP swapID kernel-panic mitigation (see `pacing`), and STRICT present
|
/// a standing queue (see above), and macOS glass pacing exists for PyroWave smoothness
|
||||||
/// serialization is its point. Internal (not private) for unit tests.
|
/// (see `pacing`), where depth 1 is the point. Internal (not private) for unit tests.
|
||||||
static func gateDepth(env: String?) -> Int {
|
static func gateDepth(env: String?) -> Int {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
return 1
|
return 1
|
||||||
@@ -196,10 +193,9 @@ final class SessionPresenter {
|
|||||||
private var stage2Link: CADisplayLink?
|
private var stage2Link: CADisplayLink?
|
||||||
private var metalLayer: CAMetalLayer?
|
private var metalLayer: CAMetalLayer?
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
/// The windowed-mode PyroWave present target (sibling above `metalLayer`) and the last
|
/// The last windowed-vs-fullscreen present routing pushed to the pipeline — see
|
||||||
/// routing pushed to the pipeline — see `setComposited`. Main-thread only, like all of this.
|
/// `setComposited` (the DCP swapID-panic mitigation). Main-thread only, like all of this.
|
||||||
private var surfaceLayer: CALayer?
|
private var transactionalPresentActive = false
|
||||||
private var surfacePresentsActive = false
|
|
||||||
#endif
|
#endif
|
||||||
private var connection: PunktfunkConnection?
|
private var connection: PunktfunkConnection?
|
||||||
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
/// The decoded frame's REAL pixel dimensions (ground truth, pushed by the view from the pump's
|
||||||
@@ -283,11 +279,7 @@ final class SessionPresenter {
|
|||||||
baseLayer.addSublayer(metal)
|
baseLayer.addSublayer(metal)
|
||||||
metalLayer = metal
|
metalLayer = metal
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// The windowed-PyroWave present target sits ABOVE the metal layer: transparent (nil
|
transactionalPresentActive = false
|
||||||
// contents) while the metal path presents, covering it while surface presents run.
|
|
||||||
baseLayer.addSublayer(pipeline.surfaceLayer)
|
|
||||||
surfaceLayer = pipeline.surfaceLayer
|
|
||||||
surfacePresentsActive = false
|
|
||||||
#endif
|
#endif
|
||||||
stage2 = pipeline
|
stage2 = pipeline
|
||||||
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
|
||||||
@@ -397,12 +389,6 @@ final class SessionPresenter {
|
|||||||
CATransaction.setDisableActions(true)
|
CATransaction.setDisableActions(true)
|
||||||
metalLayer.contentsScale = contentsScale
|
metalLayer.contentsScale = contentsScale
|
||||||
metalLayer.frame = snapped
|
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()
|
CATransaction.commit()
|
||||||
// Hand the resulting pixel size to the render thread (it must not read layer geometry
|
// 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
|
// cross-thread) — this is what the presenter sizes its drawable to. Uses the SNAPPED size so
|
||||||
@@ -432,26 +418,19 @@ final class SessionPresenter {
|
|||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
/// Route presents for the window's composited state (MAIN thread — the view pushes it on
|
/// 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
|
/// every layout, which fullscreen transitions always trigger). A COMPOSITED (windowed) session
|
||||||
/// COMPOSITED (windowed) session present via `surfaceLayer` contents instead of the
|
/// presents the drawable through a Core Animation transaction
|
||||||
/// CAMetalLayer image queue — the DCP "mismatched swapID's" kernel-panic mitigation (see
|
/// (`CAMetalLayer.presentsWithTransaction`) instead of the async image queue — the DCP
|
||||||
/// `MetalVideoPresenter.surfaceLayer`; the metal-swap race survives glass pacing, so pacing
|
/// "mismatched swapID's" kernel-panic mitigation (see `MetalVideoPresenter`; the async-swap
|
||||||
/// alone was not enough). VT codecs keep the metal path: no panic reports there, and their
|
/// race survives glass pacing, so pacing alone was not enough). ALL codecs: PyroWave hit it
|
||||||
/// HDR/EDR presentation has no surface-contents equivalent wired.
|
/// 2026-07-18 and windowed HEVC hit the same 240 Hz Mac Studio 2026-07-21 — it is the async
|
||||||
|
/// image queue itself, not any codec or present rate. Fullscreen keeps the async path (direct
|
||||||
|
/// scanout, lowest latency, no panic there). The full HDR/EDR render path is preserved in both.
|
||||||
func setComposited(_ composited: Bool) {
|
func setComposited(_ composited: Bool) {
|
||||||
guard let stage2, let connection else { return }
|
guard let stage2 else { return }
|
||||||
let wantsSurface = composited && connection.videoCodec == .pyrowave
|
guard composited != transactionalPresentActive else { return }
|
||||||
guard wantsSurface != surfacePresentsActive else { return }
|
transactionalPresentActive = composited
|
||||||
surfacePresentsActive = wantsSurface
|
stage2.setTransactionalPresent(composited)
|
||||||
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
|
#endif
|
||||||
|
|
||||||
@@ -469,9 +448,7 @@ final class SessionPresenter {
|
|||||||
metalLayer?.removeFromSuperlayer()
|
metalLayer?.removeFromSuperlayer()
|
||||||
metalLayer = nil
|
metalLayer = nil
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
surfaceLayer?.removeFromSuperlayer()
|
transactionalPresentActive = false
|
||||||
surfaceLayer = nil
|
|
||||||
surfacePresentsActive = false
|
|
||||||
#endif
|
#endif
|
||||||
connection = nil
|
connection = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1114,14 +1114,10 @@ public final class Stage2Pipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#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
|
/// Forward the windowed-vs-fullscreen present routing (MAIN thread — see
|
||||||
/// `MetalVideoPresenter.setSurfacePresents`).
|
/// `MetalVideoPresenter.setTransactionalPresent`, the DCP swapID-panic mitigation).
|
||||||
public func setSurfacePresents(_ on: Bool) {
|
public func setTransactionalPresent(_ on: Bool) {
|
||||||
presenter.setSurfacePresents(on)
|
presenter.setTransactionalPresent(on)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -700,9 +700,9 @@ public final class StreamLayerView: NSView {
|
|||||||
private func layoutPresenter() {
|
private func layoutPresenter() {
|
||||||
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1)
|
||||||
// Present routing tracks the window's composited state (fullscreen transitions always
|
// Present routing tracks the window's composited state (fullscreen transitions always
|
||||||
// re-layout, so this stays current): windowed PyroWave presents via surface contents —
|
// re-layout, so this stays current): a windowed session presents through a Core Animation
|
||||||
// the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited). A view
|
// transaction — the DCP swapID kernel-panic mitigation (see SessionPresenter.setComposited).
|
||||||
// not yet in a window counts as composited (the safe default).
|
// A view not yet in a window counts as composited (the safe default).
|
||||||
presenter.setComposited(!(window?.styleMask.contains(.fullScreen) ?? false))
|
presenter.setComposited(!(window?.styleMask.contains(.fullScreen) ?? false))
|
||||||
// Feed the follower only once in a window (backing scale is real then) and with real
|
// 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.
|
// bounds — a pre-window layout would report point-sized dimensions.
|
||||||
|
|||||||
Reference in New Issue
Block a user