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:
2026-07-21 14:15:39 +02:00
parent 42198eb1b6
commit f49d38826b
4 changed files with 115 additions and 258 deletions
@@ -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 RGB → the shared SDR tail. Used when a PQ pyrowave // fold in depth-10 MSB packing) → PQ RGB → 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 PQSDR tone-map (pf_frag_planar_tm /// space + EDR interpret the samples) and the planar PQSDR 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 fullscreenwindowed 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` (13) still overrides on iOS/tvOS so the standing-queue ladder /// `PUNKTFUNK_GATE_DEPTH` (13) 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.