fix(apple): stage-4 deadline presenter (CAMetalDisplayLink) — iOS default; gate depth back to 1

Field verdict on the depth-2 gate (M4 iPad Pro, 2752x2064@120): display stage
22-28 ms vs depth-1's 14 — a REGRESSION, not the predicted 5-8. Post-mortem:
any bounded-FIFO pacing keeps a STANDING queue on the always-vsync-latch
platforms. One burst fills every admitted slot and, with arrivals and latches
then running at the same rate, occupancy never returns to zero — each gate
slot costs one full refresh, permanently (the ladder fits exactly: arrival ~3
slots -> 30+ ms, depth 2 -> 22-28, depth 1 -> 14). A bounded FIFO caps the
queue; nothing ever drains it. And depth 1 serializes presents on the on-glass
callback's delivery lag, so neither rung can approach the sub-refresh floor.

Stage-4 (PresentPacing.deadline) inverts drawable ownership instead:
- A CAMetalDisplayLink on its own runloop thread vends ONE deadline-timed
  drawable per refresh (preferredFrameLatency 1) into a newest-wins LatestBox;
  an unpresented vend is replaced by the next (back to the pool).
- The render thread pairs it with the newest decoded frame the moment either
  half arrives — the common case presents a frame INSTANTLY into an already-
  vended drawable, latching the upcoming refresh. No image queue can form and
  nothing waits on on-glass callbacks (they only feed the meters now).
- A stashed drawable can lag a mid-session HDR reconfigure by one vend:
  encodePresent skips the mismatched-format vend (frame re-rings) instead of
  tripping Metal validation.
- iOS/iPadOS defaults to stage-4; tvOS keeps stage-3 until its own A/B
  (PUNKTFUNK_PRESENTER=stage4); macOS resolves "stage4" back to its default
  (the sync-off/DCP-panic saga — deadline pacing lands there deliberately or
  not at all). Settings picker gains Stage 4 with the derived default marker.
- Gate depth defaults to 1 everywhere again; PUNKTFUNK_GATE_DEPTH stays only
  to reproduce the standing-queue ladder on-device.
- PUNKTFUNK_PRESENT_DEBUG gains latchMs p50/max (present-issue -> on-glass:
  standing queue reads ~n x period, healthy reads < 1 period) + noDrawable=.

swift test (14/14) + full Punktfunk-iOS device build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 13:35:43 +02:00
parent d2daeacc60
commit ab2bcc8e68
7 changed files with 480 additions and 117 deletions
@@ -37,27 +37,34 @@ enum SettingsOptions {
static let hudPlacements: [(label: String, tag: String)] =
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
/// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` see SessionPresenter's
/// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
/// Present-pacing choices (`DefaultsKey.presenter` see SessionPresenter's PresenterChoice):
/// stage-2 arrival, stage-3 glass-gated, stage-4 deadline (CAMetalDisplayLink iOS/tvOS
/// only; macOS resolves it back to its default, so it isn't offered there). The freeze-prone
/// stage-1 diagnostic only ships in DEBUG builds.
static var presenters: [(label: String, tag: String)] {
var options: [(label: String, tag: String)] = [
("Stage 2", "stage2"),
("Stage 3", "stage3"),
]
#if !os(macOS)
options.append(("Stage 4", "stage4"))
#endif
#if DEBUG
options.append(("Stage 1 (debug)", "stage1"))
#endif
return options
}
/// The platform's presenter default (mirrors SessionPresenter's platformDefault tvOS and
/// iOS/iPadOS run glass pacing, macOS arrival). Views seed their @AppStorage display from
/// this so an untouched picker shows what actually runs.
/// The platform's presenter default (mirrors SessionPresenter's platformDefault iOS/iPadOS
/// runs deadline pacing, tvOS glass, macOS arrival). Views seed their @AppStorage display
/// from this so an untouched picker shows what actually runs.
static var presenterDefault: String {
#if os(macOS)
"stage2"
#else
#if os(iOS)
"stage4"
#elseif os(tvOS)
"stage3"
#else
"stage2"
#endif
}
@@ -412,12 +412,13 @@ extension SettingsView {
#endif
}
// Stage-2 (Metal/VTDecompressionSession, present on frame arrival) vs stage-3 (the same
// pipeline with glass-gated present pacing). The default is per-platform glass on iOS/tvOS,
// whose layers always vsync-latch, arrival on macOS (see Stage2Pipeline's PresentPacing for
// the queue-saturation rationale) so the "(default)" marker rides presenterDefault instead
// of a hardcoded row. Stage-1 (compressed video straight to the system layer) stays a
// DEBUG-only diagnostic it freezes hard on a lost HEVC reference.
// The present-pacing ladder: stage-2 (Metal/VTDecompressionSession, present on frame
// arrival), stage-3 (glass-gated), stage-4 (CAMetalDisplayLink deadline pacing iOS/tvOS
// only). The default is per-platform deadline on iOS, glass on tvOS, arrival on macOS
// (see Stage2Pipeline's PresentPacing for the queue-saturation rationale) so the
// "(default)" marker rides presenterDefault instead of a hardcoded row. Stage-1 (compressed
// video straight to the system layer) stays a DEBUG-only diagnostic it freezes hard on a
// lost HEVC reference.
@ViewBuilder var presenterSection: some View {
Section {
Picker("Presenter", selection: $presenter) {
@@ -432,10 +433,13 @@ extension SettingsView {
} footer: {
Text("Stage 2: each frame is shown the moment it's decoded — but on displays "
+ "running near the stream's frame rate, queued frames can add two to three "
+ "refreshes of display latency that never drains. Stage 3: presents are paced "
+ "to the display — a strict cap on undisplayed frames, always the freshest, "
+ "dropping late frames instead of queueing them. Watch the statistics overlay's "
+ "display time to compare. Applies from the next session.")
+ "refreshes of display latency that never drains. Stage 3: presents wait for "
+ "the previous frame to reach the glass — a strict cap on undisplayed frames, "
+ "always the freshest, dropping late frames instead of queueing them. "
+ "Stage 4: the display itself hands out each refresh's frame slot and the "
+ "freshest decoded frame is shown in it — no queue can form at all, the lowest "
+ "display latency. Watch the statistics overlay's display time to compare. "
+ "Applies from the next session.")
.font(.geist(12, relativeTo: .caption))
.foregroundStyle(.secondary)
}
@@ -617,10 +617,15 @@ public final class MetalVideoPresenter {
/// glass mid-refresh whenever the layer is direct-scanout promoted (fullscreen, no HUD), which
/// is the "frametimes are off with the stats HUD closed" report. nil presents immediately
/// (`PUNKTFUNK_PRESENT_MODE=immediate` the pre-fix behavior, kept as a diagnostic A/B).
///
/// `into drawable` (deadline pacing) supplies the CAMetalDisplayLink-vended drawable to
/// render into instead of calling `nextDrawable()` see `encodePresent` for the format
/// guard that skips a vend the layer's config outran.
@discardableResult
public func render(
_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false,
presentAtMediaTime: CFTimeInterval? = nil,
into drawable: CAMetalDrawable? = nil,
onPresented: ((Int64?) -> Void)? = nil
) -> Bool {
// Drain the cross-thread staging (see `stagingLock`): the layout-derived drawable size and
@@ -674,7 +679,8 @@ public final class MetalVideoPresenter {
width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer))
return encodePresent(
decodedSize: decodedSize, targetFromLayout: targetFromLayout, pipeline: pipeline,
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
onPresented: onPresented,
// Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU
// finishes sampling releasing them at scope exit could free the backing mid-read.
keepAlive: [luma, chroma, pixelBuffer]
@@ -693,6 +699,7 @@ public final class MetalVideoPresenter {
func renderPlanar(
_ planes: WaveletPlanes,
presentAtMediaTime: CFTimeInterval? = nil,
into drawable: CAMetalDrawable? = nil,
onPresented: ((Int64?) -> Void)? = nil
) -> Bool {
stagingLock.lock()
@@ -742,7 +749,8 @@ public final class MetalVideoPresenter {
return encodePresent(
decodedSize: CGSize(width: planes.width, height: planes.height),
targetFromLayout: targetFromLayout, pipeline: planarPipeline,
presentAtMediaTime: presentAtMediaTime, onPresented: onPresented,
presentAtMediaTime: presentAtMediaTime, providedDrawable: drawable,
onPresented: onPresented,
// The ring textures stay valid by ring depth; retaining them here also pins the
// slot's set until the sample completes (mirrors the biplanar keep-alive).
keepAlive: [planes.y, planes.cb, planes.cr]
@@ -869,9 +877,17 @@ public final class MetalVideoPresenter {
/// 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.
///
/// `providedDrawable` (deadline pacing) is the CAMetalDisplayLink-vended drawable to render
/// into instead of `nextDrawable()`. It was vended against the layer's config at vend time,
/// so after a mid-session reconfigure (HDR flip: `configure` above already retagged the
/// layer) its pixel format can lag the pipeline's attachment format encoding would be a
/// Metal validation failure. The guard returns false instead: the drawable drops back to
/// the pool, the caller re-rings the frame, and the link's next vend carries the new format.
private func encodePresent(
decodedSize: CGSize, targetFromLayout: CGSize, pipeline: MTLRenderPipelineState,
presentAtMediaTime: CFTimeInterval?, onPresented: ((Int64?) -> Void)?,
presentAtMediaTime: CFTimeInterval?, providedDrawable: CAMetalDrawable? = nil,
onPresented: ((Int64?) -> Void)?,
keepAlive: [Any], bind: (MTLRenderCommandEncoder) -> Void
) -> Bool {
// Size the drawable to the LAYER's pixels (its laid-out frame × contentsScale, pushed here by
@@ -884,11 +900,17 @@ public final class MetalVideoPresenter {
// (layout / Reconfigure / HDR flip and every frame of a live resize, which is fine).
let targetSize = (targetFromLayout.width > 0 && targetFromLayout.height > 0)
? targetFromLayout : decodedSize
// Under a provided (link-vended) drawable this sizes the NEXT vend the one in hand
// keeps its size, and a live-resize transient composites via contentsGravity as ever.
if layer.drawableSize != targetSize { layer.drawableSize = targetSize }
#if DEBUG
logSizeIfChanged(decoded: decodedSize, drawable: targetSize)
#endif
guard let drawable = layer.nextDrawable(),
if let providedDrawable,
providedDrawable.texture.pixelFormat != layer.pixelFormat {
return false // config outran the vend (HDR flip) next vend has the new format
}
guard let drawable = providedDrawable ?? layer.nextDrawable(),
let commandBuffer = queue.makeCommandBuffer()
else { return false }
@@ -1,10 +1,10 @@
// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: the Metal pipeline
// (explicit VTDecompressionSession decode CAMetalLayer, driven by the hosting view's
// CADisplayLink) is the default glass-paced stage-3 on tvOS + iOS, arrival-paced stage-2 on
// macOS (see PresenterChoice.platformDefault); stage-1 (StreamPump AVSampleBufferDisplayLayer)
// is the Metal-unavailable / DEBUG fallback. The views own the platform bits capture,
// window/scale tracking, and constructing the display link and delegate the shared presenter
// lifecycle here.
// (explicit VTDecompressionSession decode CAMetalLayer) is the default deadline-paced
// stage-4 on iOS, glass-paced stage-3 on tvOS, arrival-paced stage-2 on macOS (see
// PresenterChoice.platformDefault); stage-1 (StreamPump AVSampleBufferDisplayLayer) is the
// Metal-unavailable / DEBUG fallback. The views own the platform bits capture, window/scale
// tracking, and constructing the display link (arrival/glass pacing only; deadline pacing runs
// its own CAMetalDisplayLink) and delegate the shared presenter lifecycle here.
//
// Main-thread only: start/layout/stop and the display-link tick all run on the main runloop.
@@ -28,15 +28,17 @@ public final class DisplayLinkProxy: NSObject {
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
}
/// Which presenter a session runs. Stage-2/stage-3 are the same Metal pipeline with arrival vs
/// glass-gated present pacing (`PresentPacing` see Stage2Pipeline for the tradeoff, and why
/// stage-3 exists: stage-2's present-on-arrival saturates the layer's FIFO image queue on panels
/// running near the stream rate). Stage-1 (compressed video straight to the system layer) is a
/// DEBUG-only diagnostic. Internal (not private) for unit tests.
/// Which presenter a session runs. Stage-2/3/4 are the same Metal pipeline with different present
/// pacing (`PresentPacing` see Stage2Pipeline for the full tradeoff): stage-2 presents on frame
/// arrival, stage-3 gates presents on the on-glass callback, stage-4 presents into
/// CAMetalDisplayLink-vended drawables (deadline pacing iOS/tvOS only; see `PresentPacing`'s
/// doc for why the vsync-latching platforms need it). Stage-1 (compressed video straight to the
/// system layer) is a DEBUG-only diagnostic. Internal (not private) for unit tests.
enum PresenterChoice: Equatable {
case stage1
case stage2
case stage3
case stage4
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
@@ -50,29 +52,49 @@ enum PresenterChoice: Equatable {
/// The user's EXPLICIT stage selection, nil when they haven't made one (unset/unknown values,
/// and a release build's gated "stage1"). Split from `resolve` so a codec-conditional default
/// (see `SessionPresenter.pacing`) can apply only when the user hasn't picked a stage an
/// explicit "stage2" must stay a faithful A/B of arrival pacing.
/// explicit "stage2" must stay a faithful A/B of arrival pacing. "stage4" resolves only on
/// iOS/tvOS: macOS's present path is entangled with the sync-off/DCP-panic saga (see
/// MetalVideoPresenter's init) and stays on its proven pacings until deadline presents are
/// deliberately validated there a synced "stage4" value maps back to the platform default.
static func explicit(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice? {
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
switch raw {
case "stage1": return allowStage1 ? .stage1 : nil
case "stage2": return .stage2
case "stage3": return .stage3
case "stage4":
#if os(macOS)
return nil
#else
return .stage4
#endif
default: return nil
}
}
/// tvOS and iOS/iPadOS default to GLASS pacing: their layers ALWAYS vsync-latch presents into
/// the FIFO image queue (`displaySyncEnabled` is macOS-only API), so whenever the panel runs
/// near the stream rate an Apple TV's fixed 60 Hz fed a 60 fps stream by construction; an
/// iPhone/iPad fed a stream at the panel rate, which VRR (default on, preferred = stream rate)
/// makes the COMMON case arrival pacing pins the queue at ~`maximumDrawableCount` and every
/// frame rides ~23 refreshes of it (measured: ~50 ms on Apple TV; 2330 ms at 120 Hz on
/// ProMotion iPads the 2026-07 iPad Pro 2752×2064@120 field report read display 23.1 ms on
/// arrival vs 14 ms glass). The Settings picker can still force stage-2 for an A/B. macOS
/// keeps stage-2: with the layer's sync off, presents are out-of-band flips that don't queue,
/// so arrival is genuinely lowest-latency there.
/// iOS/iPadOS defaults to DEADLINE pacing (stage-4), tvOS to glass (stage-3), macOS to
/// arrival (stage-2).
///
/// The iOS/tvOS layers ALWAYS vsync-latch presents into a FIFO image queue
/// (`displaySyncEnabled` is macOS-only API), and at stream rate panel rate an Apple TV's
/// fixed 60 Hz by construction; an iPhone/iPad with VRR (default on, preferred = stream rate)
/// steering the panel to the stream that queue's depth is STICKY: one burst fills it and,
/// with arrivals and latches then running at the same rate, it NEVER drains. Every queued
/// present costs a full refresh, forever: the 2026-07 iPad Pro (2752×2064@120) field ladder
/// read ~30 ms display on arrival (~3 refreshes of queue), 2228 ms glass-gated at depth 2
/// (a standing queue of 2 the depth-2 experiment's post-mortem), 14 ms at depth 1. Glass
/// pacing (stage-3) bounds the queue but presents still serialize on the on-glass callback;
/// deadline pacing (stage-4) is the fix for the remainder: one CAMetalDisplayLink-vended
/// drawable per refresh, presented the moment a frame decodes the queue cannot exist and
/// nothing waits on callbacks (see `PresentPacing.deadline`).
///
/// tvOS stays on its proven stage-3 until stage-4 gets an on-glass A/B there
/// (`PUNKTFUNK_PRESENTER=stage4`). macOS keeps stage-2: with the layer's sync off, presents
/// are out-of-band flips that don't queue, so arrival is genuinely lowest-latency there.
static var platformDefault: PresenterChoice {
#if os(tvOS) || os(iOS)
#if os(iOS)
.stage4
#elseif os(tvOS)
.stage3
#else
.stage2
@@ -99,6 +121,7 @@ final class SessionPresenter {
static func pacing(
for choice: PresenterChoice, explicit: PresenterChoice?, codec: VideoCodec
) -> PresentPacing {
if choice == .stage4 { return .deadline }
if choice == .stage3 { return .glass }
#if os(macOS)
if explicit == nil, codec == .pyrowave { return .glass }
@@ -106,31 +129,27 @@ final class SessionPresenter {
return .arrival
}
/// The glass gate's in-flight present budget (`PresentGate` capacity) for this platform.
/// The glass gate's in-flight present budget (`PresentGate` capacity): 1 everywhere.
///
/// - iOS/iPadOS: 2 one flip scanning out plus one queued for the next latch. A decoded
/// frame presents immediately (the gate is open in steady state) and latches the very next
/// vsync, so the present cadence never serializes on the on-glass callback's own latency
/// depth 1's extra-refresh cost (the field-measured 14 ms display stage at 120 Hz, vs a
/// ~half-refresh floor). The queue still can't build past two flips, so arrival pacing's
/// FIFO saturation (2330 ms) stays gone.
/// - tvOS: 1 the proven fixed-60-Hz config. Depth 2 should shorten its display stage the
/// same way but is unmeasured there; A/B first (`PUNKTFUNK_GATE_DEPTH=2`), then flip.
/// - macOS: pinned to 1, env ignored glass pacing exists there as the DCP swapID
/// kernel-panic mitigation (see `pacing`), and STRICT present serialization is its point.
/// Depth 1 is the only depth that works. The 2026-07 depth-2 experiment (one flip scanning
/// out + one queued, predicted ~58 ms at 120 Hz) REGRESSED the iPad Pro's display stage to
/// 2228 ms vs depth 1's 14: any second gate slot becomes a STANDING queue a burst fills
/// it, and with presents and latches then running at the same rate the occupancy never
/// returns to zero, so every frame permanently rides one extra refresh per slot. A bounded
/// FIFO can cap the queue but nothing ever drains it; the prediction assumed an idle queue
/// that doesn't exist after the first Wi-Fi clump. Sub-refresh display latency needs pacing
/// that can't queue at all that's stage-4 (`PresentPacing.deadline`), not a deeper gate.
///
/// `PUNKTFUNK_GATE_DEPTH` (13) overrides on iOS/tvOS for on-device A/B, mirroring the other
/// presenter env levers. Internal (not private) for unit tests.
/// `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
/// there as the DCP swapID kernel-panic mitigation (see `pacing`), and STRICT present
/// serialization is its point. Internal (not private) for unit tests.
static func gateDepth(env: String?) -> Int {
#if os(macOS)
return 1
#else
if let env, let depth = Int(env), (1...3).contains(depth) { return depth }
#if os(tvOS)
return 1
#else
return 2
#endif
#endif
}
@@ -192,12 +211,12 @@ final class SessionPresenter {
env: ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENTER"],
allowStage1: allowStage1)
let choice = explicit ?? PresenterChoice.platformDefault
let pacing = Self.pacing(for: choice, explicit: explicit, codec: connection.videoCodec)
if choice != .stage1,
let pipeline = Stage2Pipeline(
endToEndMeter: endToEndMeter, decodeMeter: decodeMeter,
displayMeter: displayMeter,
pacing: Self.pacing(
for: choice, explicit: explicit, codec: connection.videoCodec),
pacing: pacing,
gateDepth: Self.gateDepth(
env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"])) {
let metal = pipeline.layer
@@ -216,14 +235,19 @@ final class SessionPresenter {
// The link is the vsync CLOCK + putBack-retry nudge, not the presentation trigger
// (frame arrival is see Stage2Pipeline's header). timestamptargetTimestamp is the
// link's own report of the current refresh period (tracks VRR rate changes).
let proxy = DisplayLinkProxy { [weak self] link in
self?.stage2?.renderTick(
targetMediaTime: link.targetTimestamp,
period: link.targetTimestamp - link.timestamp)
// DEADLINE pacing needs neither: its CAMetalDisplayLink (pipeline-owned) is the vsync
// clock, and every one of its updates re-checks the ring, which IS the retry tick
// a second link would only fight it over the frame-rate hint.
if pacing != .deadline {
let proxy = DisplayLinkProxy { [weak self] link in
self?.stage2?.renderTick(
targetMediaTime: link.targetTimestamp,
period: link.targetTimestamp - link.timestamp)
}
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
link.add(to: .main, forMode: .common)
stage2Link = link
}
let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:)))
link.add(to: .main, forMode: .common)
stage2Link = link
syncFrameRate(hz: connection.currentMode().refreshHz)
pipeline.start(
connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd,
@@ -251,7 +275,12 @@ final class SessionPresenter {
/// rate (it already tracks the display and must NOT be capped to the stream rate).
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
private func syncFrameRate(hz: UInt32) {
guard hz > 0, let link = stage2Link else { return }
guard hz > 0 else { return }
// Deadline pacing: the hint goes to the pipeline's CAMetalDisplayLink instead (staged;
// applied from the link's own thread see Stage2Pipeline.setFrameRateHint). A no-op
// under arrival/glass pacing, where the CADisplayLink below is the one hinted link.
stage2?.setFrameRateHint(hz: Float(hz))
guard let link = stage2Link else { return }
let hzF = Float(hz)
let allowVRR = UserDefaults.standard.object(forKey: DefaultsKey.allowVRR) as? Bool ?? true
#if os(macOS)
@@ -18,11 +18,14 @@
// V-Sync ON: present(at: next vsync) predicted from the link's last phase/period, at most one
// period ahead by construction, falling back to immediate when the link data is stale a
// schedule can never sit far in the future holding drawables hostage.
// Present PACING is the stage-2 vs stage-3 presenter split (`PresentPacing`, chosen per session
// by SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
// frame arrival; stage-3 additionally gates presents to a bounded number of undisplayed
// drawables (the gate depth see PresentPacing + SessionPresenter.gateDepth) so the layer's
// FIFO image queue can never saturate see PresentPacing's doc for the full rationale.
// Present PACING is the stage-2/3/4 presenter split (`PresentPacing`, chosen per session by
// SessionPresenter from the presenter setting / PUNKTFUNK_PRESENTER): stage-2 presents on
// frame arrival; stage-3 additionally gates presents on the on-glass callback (`PresentGate`)
// so the layer's FIFO image queue can never saturate; stage-4 (iOS/tvOS) presents into
// CAMetalDisplayLink-vended drawables the moment a frame decodes deadline pacing, where the
// queue cannot exist at all see PresentPacing's doc for the full rationale. Under deadline
// pacing the render thread below is fed by BOTH the decoder callback and the link's per-refresh
// updates (which vend the drawable), and the V-Sync policy/vsync clock don't apply.
// Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI).
//
// The render thread also stamps the unified latency stages (end-to-end captureon-glass + decode and
@@ -117,13 +120,24 @@ private final class VsyncClock: @unchecked Sendable {
/// ~23 refreshes of queue (the measured 2330 ms display stage on 120 Hz ProMotion panels), and
/// the full-queue regime is where hostpanel clock drift turns into periodic repeats/drops (the
/// "fixed-interval" jitter reports).
/// - `glass` (stage-3, the tvOS + iOS default): at most a small BOUNDED number of presented-but-
/// undisplayed drawables in flight (`PresentGate`; the depth 1 or 2 is per-platform, see
/// `SessionPresenter.gateDepth`). The render thread presents only while a gate slot is free (a
/// drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
/// - `glass` (stage-3, the tvOS default): at most a small BOUNDED number of presented-but-
/// undisplayed drawables in flight (`PresentGate`; depth 1 see `SessionPresenter.gateDepth`
/// for why deeper is a regression). The render thread presents only while a gate slot is free
/// (a drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
/// coalesce in the newest-wins ring. Freshness is preserved by DROPPING stale frames before
/// present instead of queueing them behind the display the hidden queue latency becomes
/// explicit, correct frame drops.
/// explicit, correct frame drops. The residual cost: presents serialize on the on-glass
/// callback, whose own delivery latency pushes each present ~a refresh past the frame's decode
/// (the field-measured 14 ms display stage at 120 Hz vs the ~half-refresh floor).
/// - `deadline` (stage-4, the iOS/iPadOS default; iOS/tvOS only see
/// `PresenterChoice.explicit`): a CAMetalDisplayLink vends ONE drawable per refresh
/// (`preferredFrameLatency` 1) into a newest-wins hand-off slot, and the render thread pairs
/// it with the newest decoded frame THE MOMENT either half arrives usually the frame, into
/// an already-vended drawable. The image queue cannot exist (one vended drawable in flight,
/// ever), nothing serializes on the on-glass callback (the link's next vend is the pace), and
/// the present is deadline-timed by the system to latch the upcoming refresh. This is the only
/// pacing whose steady state can reach the sub-refresh display floor on the always-vsync-latch
/// platforms; `arrival`/`glass` remain the on-device A/B rungs.
///
/// macOS PyroWave sessions default to `glass` even though the platform default is stage-2: burst
/// presents into a composited (windowed) layer are the trigger pattern for the macOS DCP
@@ -132,6 +146,82 @@ private final class VsyncClock: @unchecked Sendable {
public enum PresentPacing: Sendable {
case arrival
case glass
case deadline
}
/// Newest-wins 1-slot hand-off box (the generic sibling of `ReadyRing`): deadline pacing's
/// drawable stash the link thread `put`s each update's vended drawable (replacing an
/// unpresented older one, which just returns to the layer's pool), the render thread `take`s.
/// `putBack` returns a taken value only while the slot is still empty, so a fresher `put` from
/// the other thread is never clobbered by a stale return. Internal (not private) for unit tests.
/// Sendable; lock-guarded.
final class LatestBox<T>: @unchecked Sendable {
private let lock = NSLock()
private var value: T?
func put(_ v: T) { lock.lock(); value = v; lock.unlock() }
func putBack(_ v: T) {
lock.lock()
if value == nil { value = v }
lock.unlock()
}
func take() -> T? {
lock.lock()
defer { lock.unlock() }
let v = value
value = nil
return v
}
}
/// Deadline pacing's staged frame-rate hint. SessionPresenter pushes the stream rate from the
/// MAIN thread (session start + every layout/Reconfigure); the link's own thread drains and
/// applies it, so the CAMetalDisplayLink is only ever touched from the thread that runs it. The
/// floor is PINNED at the stream rate no idle ramp-down: with a low floor the link idles toward
/// it on a static scene (infinite GOP no frames), and the first damage frame after idle would
/// wait out a slow tick before it could present. Empty wakes at stream rate are near-free; the
/// PANEL still idles via VRR because no presents happen. Sendable; lock-guarded.
private final class FrameRateHint: @unchecked Sendable {
private let lock = NSLock()
private var pending: CAFrameRateRange?
func stage(hz: Float) {
guard hz > 0 else { return }
lock.lock()
pending = CAFrameRateRange(minimum: hz, maximum: max(hz, 120), preferred: hz)
lock.unlock()
}
func drain() -> CAFrameRateRange? {
lock.lock()
defer { lock.unlock() }
let p = pending
pending = nil
return p
}
}
/// The CAMetalDisplayLink delegate for deadline pacing: each per-refresh update stashes its
/// vended drawable (newest wins) and nudges the render thread which also wakes on decoder
/// arrivals, so whichever half completes the (frame, drawable) pair triggers the present. Also
/// applies the staged frame-rate hint from the link's own thread. Retained by the link thread's
/// closure (the link holds it weak); captures only the shared boxes, never the pipeline the
/// same no-self-capture rule as the pump/render threads.
private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
private let stash: LatestBox<CAMetalDrawable>
private let renderSignal: DispatchSemaphore
private let hint: FrameRateHint
init(stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore, hint: FrameRateHint) {
self.stash = stash
self.renderSignal = renderSignal
self.hint = hint
}
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
if let range = hint.drain(), link.preferredFrameRateRange != range {
link.preferredFrameRateRange = range
}
stash.put(update.drawable)
renderSignal.signal()
}
}
/// Stage-3's present gate: admits `capacity` in-flight (presented, not yet on glass) drawables.
@@ -206,10 +296,15 @@ final class PresentGate: @unchecked Sendable {
private final class PresentDebugStats: @unchecked Sendable {
private let lock = NSLock()
private var last = CACurrentMediaTime()
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0
private var ok = 0, failed = 0, empty = 0, dropped = 0, gated = 0, noDrawable = 0
private var maxRenderMs = 0.0
private var lastGlassNs: Int64 = 0
private var glassDeltasMs: [Double] = []
/// Present-issue on-glass delay per frame (system presentedTime minus the render call's
/// start) the DIRECT decomposition of the display stage: ring/pairing wait lives upstream
/// of it, queue + present-pipeline cost inside it. Standing queue reads as ~n×period here;
/// a healthy latch reads under one period.
private var latchMs: [Double] = []
/// Presented-but-not-yet-on-glass drawables right now / the window's peak the direct
/// measurement of the layer image-queue depth the stage-3 gate exists to bound (stage-2 on a
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth).
@@ -223,6 +318,11 @@ private final class PresentDebugStats: @unchecked Sendable {
/// is normal, it just shows the gate working.
func gatedWake() { lock.lock(); gated += 1; lock.unlock() }
/// Deadline pacing: a decoded frame is waiting but the link hasn't vended this interval's
/// drawable yet the frame presents on the link's next update. A high count just means
/// decode outruns the link's phase; the wait is bounded by one refresh.
func noDrawableWake() { lock.lock(); noDrawable += 1; lock.unlock() }
func renderReturned(ok rendered: Bool, tookMs: Double) {
lock.lock()
if rendered {
@@ -236,12 +336,13 @@ private final class PresentDebugStats: @unchecked Sendable {
lock.unlock()
}
func presented(atNs: Int64?) {
func presented(atNs: Int64?, issuedNs: Int64) {
lock.lock()
inFlight = max(0, inFlight - 1) // clamp: the handler can beat renderReturned's increment
if let atNs {
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
lastGlassNs = atNs
latchMs.append(Double(atNs - issuedNs) / 1e6)
} else {
dropped += 1
}
@@ -257,16 +358,21 @@ private final class PresentDebugStats: @unchecked Sendable {
let deltas = glassDeltasMs.sorted()
let p50 = deltas.isEmpty ? 0 : deltas[deltas.count / 2]
let dMax = deltas.last ?? 0
let latches = latchMs.sorted()
let latchP50 = latches.isEmpty ? 0 : latches[latches.count / 2]
let latchMax = latches.last ?? 0
let inflightMax = maxInFlight
let line = String(
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d dropped=%d "
+ "maxRenderMs=%.1f inflightMax=%d forced=%d glassDeltaMs p50=%.2f max=%.2f n=%d",
decoded, ok, failed, empty, gated, dropped, maxRenderMs, inflightMax,
gate?.drainForced() ?? 0, p50, dMax, deltas.count)
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d noDrawable=%d "
+ "dropped=%d maxRenderMs=%.1f inflightMax=%d forced=%d "
+ "glassDeltaMs p50=%.2f max=%.2f n=%d latchMs p50=%.2f max=%.2f",
decoded, ok, failed, empty, gated, noDrawable, dropped, maxRenderMs, inflightMax,
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax)
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
maxRenderMs = 0
maxInFlight = inFlight // the window peak restarts from the live depth
glassDeltasMs.removeAll(keepingCapacity: true)
latchMs.removeAll(keepingCapacity: true)
lock.unlock()
print(line)
fflush(stdout) // stdout is a pipe when captured flush per line or nothing shows
@@ -338,6 +444,9 @@ public final class Stage2Pipeline {
private let vsyncClock = VsyncClock()
private let renderStopped = DispatchSemaphore(value: 0)
private var renderJoinable = false
/// Deadline pacing's staged CAMetalDisplayLink frame-rate hint (see `FrameRateHint`).
/// Created unconditionally (cheap); only the deadline link thread drains it.
private let frameRateHint = FrameRateHint()
/// The Metal layer the hosting view installs + sizes.
public var layer: CAMetalLayer { presenter.layer }
@@ -538,6 +647,16 @@ public final class Stage2Pipeline {
pumpJoinable = true
thread.start()
// The present half. Deadline pacing (stage-4) swaps it wholesale: a CAMetalDisplayLink
// vends the drawables and its per-refresh updates co-drive the render thread see
// startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline-
// times every present).
let debugStats = presentDebug ? PresentDebugStats() : nil
if pacing == .deadline {
startDeadlinePresenter(debugStats: debugStats)
return
}
// The render thread: one present per display-link signal. It owns every layer format/colour/
// drawable interaction (see MetalVideoPresenter's threading notes); with displaySyncEnabled on,
// nextDrawable's up-to-a-frame wait lands here instead of on main. The 100 ms timed wait is
@@ -555,7 +674,6 @@ public final class Stage2Pipeline {
let vsyncEnabled = presentMode == "vsync"
|| (presentMode != "immediate"
&& UserDefaults.standard.bool(forKey: DefaultsKey.vsync))
let debugStats = presentDebug ? PresentDebugStats() : nil
let vsyncClock = vsyncClock
// Stage-3's bounded in-flight present gate; nil = stage-2's present-on-arrival. A local
// (like the ring) so neither the render thread nor the presented handlers capture `self`.
@@ -591,6 +709,7 @@ public final class Stage2Pipeline {
let presentAt = vsyncEnabled
? vsyncClock.nextVsync(after: CACurrentMediaTime()) : nil
let renderStarted = CACurrentMediaTime()
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
let onGlass: (Int64?) -> Void = { presentedNs in
// Stage-3: the flip reached glass (or was dropped) free the present slot,
// then re-signal so the freshest waiting ring frame goes out immediately.
@@ -608,7 +727,7 @@ public final class Stage2Pipeline {
// Display stage = decoded on-glass. Both instants are client CLOCK_REALTIME,
// so no skew offset applies.
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
debugStats?.presented(atNs: presentedNs)
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
}
// One present tail, two decode sources: the VideoToolbox biplanar buffer or the
// PyroWave Metal planes the ring, pacing and meters are agnostic to which.
@@ -637,16 +756,134 @@ public final class Stage2Pipeline {
renderThread.start()
}
/// Deadline pacing's present half (stage-4 see `PresentPacing.deadline`): a
/// CAMetalDisplayLink on its own runloop thread vends ONE drawable per refresh into the
/// newest-wins stash, and the render thread pairs it with the newest decoded frame the
/// moment either half completes the pair the common case is a decoded frame presenting
/// instantly into an already-vended drawable, which the system then latches at the upcoming
/// refresh (`preferredFrameLatency` 1). No image queue can form (one vended drawable in
/// flight, ever) and nothing serializes on the on-glass callback. An unpresented stashed
/// drawable is simply replaced by the next update (back to the layer's pool), so the stash
/// is never stale by more than a refresh while the link runs.
///
/// Threading mirrors the arrival/glass half: neither thread captures `self`; the link is
/// created, driven and invalidated entirely on its own thread (CAMetalDisplayLink is only
/// ever touched there the frame-rate hint crosses via `FrameRateHint`); the link thread's
/// runloop iterations each drain an autorelease pool (a vended CAMetalDrawable is
/// autoreleased like a `nextDrawable()` one see the render loop's identical rule); the
/// 100 ms runloop horizon is the stop-flag poll, so teardown is bounded without a join.
private func startDeadlinePresenter(debugStats: PresentDebugStats?) {
let token = token
let ring = ring
let renderSignal = renderSignal
let renderStopped = renderStopped
let presenter = presenter
let endToEndMeter = endToEndMeter
let displayMeter = displayMeter
let offsetNs = offsetNs
let hint = frameRateHint
let layer = presenter.layer
let stash = LatestBox<CAMetalDrawable>()
let linkThread = Thread {
let delegate = DeadlineLinkDelegate(
stash: stash, renderSignal: renderSignal, hint: hint)
let link = CAMetalDisplayLink(metalLayer: layer)
link.preferredFrameLatency = 1 // wake as late as fits: present latches the NEXT refresh
if let range = hint.drain() { link.preferredFrameRateRange = range }
link.delegate = delegate // weak this closure is the strong ref
link.add(to: RunLoop.current, forMode: .default)
while !token.isStopped {
autoreleasepool {
_ = RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.1))
}
}
link.invalidate()
}
linkThread.name = "punktfunk-stage4-link"
linkThread.qualityOfService = .userInteractive
linkThread.start()
let renderThread = Thread {
defer { renderStopped.signal() }
// Per-iteration autorelease pool same contract as the arrival/glass loop (the
// vended drawable and its retinue are autoreleased objects on a runloop-less thread).
while !token.isStopped { autoreleasepool {
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
debugStats?.flushIfDue(ring: ring, gate: nil)
return
}
// Present needs the PAIR. Take the drawable first: if it's missing, the frame
// stays in the ring untouched (coalescing newest-wins) for the link's next
// update a wait bounded by one refresh.
guard !token.isStopped, let drawable = stash.take() else {
debugStats?.noDrawableWake()
debugStats?.flushIfDue(ring: ring, gate: nil)
return
}
guard let frame = ring.take() else {
// No frame yet return the drawable for the next arrival. putBack, not put:
// the link may have vended a fresher drawable in between, and that one wins.
stash.putBack(drawable)
debugStats?.emptyWake()
debugStats?.flushIfDue(ring: ring, gate: nil)
return
}
let renderStarted = CACurrentMediaTime()
let issuedNs = Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: renderStarted)
let onGlass: (Int64?) -> Void = { presentedNs in
let atNs = presentedNs
?? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: CACurrentMediaTime())
endToEndMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs)
displayMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0)
debugStats?.presented(atNs: presentedNs, issuedNs: issuedNs)
}
let rendered: Bool
switch frame.image {
case .video(let pixelBuffer, let isHDR):
rendered = presenter.render(
pixelBuffer, isHDR: isHDR, into: drawable, onPresented: onGlass)
case .planar(let planes):
rendered = presenter.renderPlanar(
planes, into: drawable, onPresented: onGlass)
}
debugStats?.renderReturned(
ok: rendered, tookMs: (CACurrentMediaTime() - renderStarted) * 1000)
if !rendered {
// The vended drawable is spent either way (an unused/mismatched one drops
// back to the pool); the frame retries on the link's next vend. A format
// mismatch (mid-session HDR flip caught between the layer reconfigure and
// the next vend) self-heals the same way see encodePresent's guard.
ring.putBack(frame)
}
debugStats?.flushIfDue(ring: ring, gate: nil)
} }
}
renderThread.name = "punktfunk-stage2-render"
renderThread.qualityOfService = .userInteractive
renderJoinable = true
renderThread.start()
}
/// MAIN thread, once per display-link tick: refresh the vsync clock (V-Sync-mode scheduling)
/// and nudge the render thread. The nudge is NOT the presentation trigger frame arrival is
/// (see the header) it only retries a frame a transient `nextDrawable` failure put back into
/// the ring, which matters under the host's infinite GOP where a static scene sends no
/// replacement frame.
/// replacement frame. Arrival/glass pacing only deadline sessions have no CADisplayLink
/// (their CAMetalDisplayLink's updates are both clock and retry).
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
vsyncClock.set(target: targetMediaTime, period: period)
renderSignal.signal()
}
/// MAIN thread (SessionPresenter session start + every layout/Reconfigure): hint the
/// deadline link with the stream cadence. Staged; the link's own thread applies it (see
/// `FrameRateHint`). No-op under arrival/glass pacing, where the hosting view's CADisplayLink
/// is the hinted link.
public func setFrameRateHint(hz: Float) {
frameRateHint.stage(hz: hz)
}
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread see
/// `MetalVideoPresenter.setDrawableTarget`).
public func setDrawableTarget(_ size: CGSize) {
@@ -50,11 +50,12 @@ public enum DefaultsKey {
/// discrete channel, and the default Nstereo downmix grabs channels 0/1 (silence when the mic
/// is higher up), so we fold to mono ourselves. Only meaningful for multi-channel devices.
public static let micChannel = "punktfunk.micChannel"
/// Which presenter runs a session: "stage2" (default explicit decode + Metal present on
/// frame arrival), "stage3" (same pipeline, glass-gated present pacing the experimental
/// low-display-latency A/B; see Stage2Pipeline's PresentPacing), or "stage1" (DEBUG-only
/// Which presenter runs a session: "stage2" (explicit decode + Metal present on frame
/// arrival the macOS default), "stage3" (same pipeline, glass-gated present pacing the
/// tvOS default), "stage4" (CAMetalDisplayLink deadline pacing, iOS/tvOS only the iOS
/// default; see Stage2Pipeline's PresentPacing for the ladder), or "stage1" (DEBUG-only
/// system-layer fallback). Resolved once per session by SessionPresenter;
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3 overrides it for A/B.
/// PUNKTFUNK_PRESENTER=stage1|stage2|stage3|stage4 overrides it for A/B.
public static let presenter = "punktfunk.presenter"
/// macOS: V-Sync the stream's presents each decoded frame flips on the next display vsync
/// (evenly paced, no tearing under direct scanout) instead of as soon as the GPU finishes
@@ -4,9 +4,10 @@ import XCTest
import QuartzCore
@testable import PunktfunkKit
/// Stage-3 present pacing: the bounded in-flight `PresentGate` (depth 1 + depth 2), the
/// stage-1/2/3 `PresenterChoice` resolution (setting + PUNKTFUNK_PRESENTER env override + the
/// release-build stage-1 gate), and the per-platform glass-gate depth.
/// Present pacing: the stage-3 bounded in-flight `PresentGate`, the stage-4 `LatestBox`
/// drawable hand-off, the stage-1/2/3/4 `PresenterChoice` resolution (setting +
/// PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate + the iOS/tvOS-only
/// stage-4 gate), and the per-platform glass-gate depth.
final class PresentPacingTests: XCTestCase {
// MARK: - PresentGate
@@ -21,8 +22,10 @@ final class PresentPacingTests: XCTestCase {
XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared")
}
/// Depth 2 (the iOS default): a second present may queue behind the flip scanning out the
/// bound only bites at the THIRD. One release (a glass callback) reopens exactly one slot.
/// Depth 2 (the PUNKTFUNK_GATE_DEPTH ladder rung no longer a default; see
/// `SessionPresenter.gateDepth`'s standing-queue post-mortem): a second present may queue
/// behind the flip scanning out the bound only bites at the THIRD. One release (a glass
/// callback) reopens exactly one slot.
func testGateDepthTwoAdmitsTwoInFlightPresents() {
let gate = PresentGate(capacity: 2)
XCTAssertTrue(gate.tryAcquire(now: 0))
@@ -72,16 +75,48 @@ final class PresentPacingTests: XCTestCase {
XCTAssertEqual(gate.drainForced(), 0)
}
// MARK: - LatestBox (stage-4's drawable hand-off)
/// Newest-wins hand-off: `put` replaces (an unpresented older drawable returns to the
/// layer's pool by release), `take` empties the slot.
func testLatestBoxNewestWins() {
let box = LatestBox<Int>()
XCTAssertNil(box.take())
box.put(1)
box.put(2)
XCTAssertEqual(box.take(), 2, "a fresher put replaces the unpresented value")
XCTAssertNil(box.take(), "take empties the slot")
}
/// `putBack` fills only an EMPTY slot: the render thread returning a drawable it took but
/// didn't present must never clobber a fresher one the link vended in between.
func testLatestBoxPutBackNeverClobbersAFresherPut() {
let box = LatestBox<Int>()
box.put(1)
let stale = box.take()
XCTAssertEqual(stale, 1)
box.putBack(stale!)
XCTAssertEqual(box.take(), 1, "putBack into a still-empty slot restores the value")
box.put(2)
let taken = box.take()
box.put(3) // the link vends a fresher drawable while the render thread holds `taken`
box.putBack(taken!)
XCTAssertEqual(box.take(), 3, "the fresher vend wins over the stale return")
}
// MARK: - PresenterChoice
/// The platform default: glass-paced stage-3 where the layer always vsync-latches (iOS,
/// tvOS arrival pacing saturates the FIFO image queue there), arrival stage-2 on macOS
/// (sync-off presents don't queue). No selection / garbage falls back to it.
/// The platform default: deadline-paced stage-4 on iOS/iPadOS (the vsync-latching platform
/// where any bounded-FIFO pacing keeps a standing queue), glass stage-3 on tvOS (proven
/// stage-4 A/Bs first), arrival stage-2 on macOS (sync-off presents don't queue). No
/// selection / garbage falls back to it.
func testPresenterChoiceFallsBackToPlatformDefault() {
#if os(macOS)
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
#else
#if os(iOS)
XCTAssertEqual(PresenterChoice.platformDefault, .stage4)
#elseif os(tvOS)
XCTAssertEqual(PresenterChoice.platformDefault, .stage3)
#else
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
#endif
XCTAssertEqual(
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true),
@@ -106,6 +141,29 @@ final class PresentPacingTests: XCTestCase {
PresenterChoice.resolve(setting: "stage3", env: "", allowStage1: true), .stage3)
}
/// "stage4" (deadline pacing) resolves only on iOS/tvOS. On macOS whose present path is
/// entangled with the sync-off/DCP-panic saga a synced-over "stage4" value maps back to
/// the platform default instead of engaging an unvalidated pacing.
func testPresenterChoiceGatesStage4ToVsyncLatchPlatforms() {
#if os(macOS)
XCTAssertNil(PresenterChoice.explicit(setting: "stage4", env: nil, allowStage1: true))
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage4", env: nil, allowStage1: true), .stage2)
XCTAssertEqual(
PresenterChoice.resolve(setting: nil, env: "stage4", allowStage1: true), .stage2)
#else
XCTAssertEqual(
PresenterChoice.explicit(setting: "stage4", env: nil, allowStage1: true), .stage4)
XCTAssertEqual(
PresenterChoice.resolve(setting: nil, env: "stage4", allowStage1: true), .stage4)
// The env override wins over the persisted setting, both directions.
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage4", env: "stage3", allowStage1: true), .stage3)
XCTAssertEqual(
PresenterChoice.resolve(setting: "stage2", env: "stage4", allowStage1: true), .stage4)
#endif
}
/// Stage-1 (the freeze-prone system-layer diagnostic) resolves only where allowed (DEBUG
/// builds); a leftover "stage1" value in a release build maps back to the platform default.
func testPresenterChoiceGatesStage1() {
@@ -156,25 +214,30 @@ final class PresentPacingTests: XCTestCase {
SessionPresenter.pacing(for: .stage3, explicit: .stage3, codec: .hevc), .glass)
XCTAssertEqual(
SessionPresenter.pacing(for: .stage3, explicit: nil, codec: .pyrowave), .glass)
// Stage-4 means deadline regardless of codec or how it was chosen.
XCTAssertEqual(
SessionPresenter.pacing(for: .stage4, explicit: nil, codec: .hevc), .deadline)
XCTAssertEqual(
SessionPresenter.pacing(for: .stage4, explicit: .stage4, codec: .pyrowave), .deadline)
}
// MARK: - Glass-gate depth
/// The per-platform in-flight present budget: 2 on iOS/iPadOS (one flip scanning out + one
/// queued the display-latency fix), 1 on tvOS (proven config), 1 pinned on macOS (glass
/// there is the swapID-panic mitigation strict serialization is its point, so the env
/// lever must not widen it). PUNKTFUNK_GATE_DEPTH A/Bs iOS/tvOS; out-of-range/garbage
/// values are ignored.
/// The in-flight present budget is 1 EVERYWHERE: any deeper gate keeps a standing queue
/// the 2026-07 iPad depth-2 experiment regressed display latency 142228 ms (see
/// `SessionPresenter.gateDepth`'s post-mortem). macOS additionally pins the env lever (glass
/// there is the swapID-panic mitigation strict serialization is its point);
/// PUNKTFUNK_GATE_DEPTH still reproduces the standing-queue ladder on iOS/tvOS.
/// Out-of-range/garbage values are ignored.
func testGateDepthPlatformDefaultsAndEnvOverride() {
#if os(macOS)
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 1, "macOS is pinned to 1")
#elseif os(tvOS)
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 1)
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 2, "the on-device A/B lever")
#else
XCTAssertEqual(SessionPresenter.gateDepth(env: nil), 2)
XCTAssertEqual(SessionPresenter.gateDepth(env: "1"), 1, "the on-device A/B lever")
XCTAssertEqual(
SessionPresenter.gateDepth(env: nil), 1,
"any depth >1 is a standing queue — one refresh of display latency per slot")
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 2, "the on-device ladder lever")
#endif
XCTAssertEqual(
SessionPresenter.gateDepth(env: "0"), SessionPresenter.gateDepth(env: nil),