From ab2bcc8e68689da67bbe242b7d4d20ea660566ef Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 19 Jul 2026 13:35:43 +0200 Subject: [PATCH] =?UTF-8?q?fix(apple):=20stage-4=20deadline=20presenter=20?= =?UTF-8?q?(CAMetalDisplayLink)=20=E2=80=94=20iOS=20default;=20gate=20dept?= =?UTF-8?q?h=20back=20to=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Settings/SettingsOptions.swift | 23 +- .../Settings/SettingsView+Sections.swift | 24 +- .../Video/MetalVideoPresenter.swift | 30 +- .../PunktfunkKit/Video/SessionPresenter.swift | 129 ++++---- .../PunktfunkKit/Video/Stage2Pipeline.swift | 277 ++++++++++++++++-- .../PunktfunkShared/DefaultsKeys.swift | 9 +- .../PresentPacingTests.swift | 105 +++++-- 7 files changed, 480 insertions(+), 117 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift index b9802879..340d27f2 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift @@ -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 } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index bccaa24b..12656d36 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -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) } diff --git a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift index 45683735..2bebfb0e 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift @@ -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 } diff --git a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift index 403aebfe..476abffa 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift @@ -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 ~2–3 refreshes of it (measured: ~50 ms on Apple TV; 23–30 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), 22–28 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 (23–30 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 ~5–8 ms at 120 Hz) REGRESSED the iPad Pro's display stage to + /// 22–28 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` (1…3) overrides on iOS/tvOS for on-device A/B, mirroring the other - /// presenter env levers. Internal (not private) for unit tests. + /// `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 + /// 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). timestamp→targetTimestamp 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) diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index bed087fb..500db1f6 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -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 capture→on-glass + decode and @@ -117,13 +120,24 @@ private final class VsyncClock: @unchecked Sendable { /// ~2–3 refreshes of queue (the measured 23–30 ms display stage on 120 Hz ProMotion panels), and /// the full-queue regime is where host↔panel 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: @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 + private let renderSignal: DispatchSemaphore + private let hint: FrameRateHint + + init(stash: LatestBox, 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() + + 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) { diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index f7c9073b..f75da1c6 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -50,11 +50,12 @@ public enum DefaultsKey { /// discrete channel, and the default N→stereo 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 diff --git a/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift b/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift index 21cfe447..1f2e1b4c 100644 --- a/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift @@ -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() + 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() + 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 14→22–28 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),