From 2149673f89742a1e4d8b0e90e92be6e26e63c503 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 19 Jul 2026 12:42:34 +0200 Subject: [PATCH] =?UTF-8?q?fix(apple):=20default=20iOS=20to=20glass-gated?= =?UTF-8?q?=20present=20pacing=20with=20a=20depth-2=20gate=20=E2=80=94=20t?= =?UTF-8?q?he=2023=20ms=20display-stage=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report (iPad Pro 13" M4, 2752x2064@120): display 23.1 ms on the default arrival pacing vs 14 ms glass-gated, dominating an otherwise ~14 ms pipeline. On iOS the layer ALWAYS vsync-latches (displaySyncEnabled is macOS-only API) and default-on VRR steers the panel to the stream rate, so arrival pacing's sticky-FIFO saturation (~2-3 refreshes of queue) is the common case, not the corner — the exact regime that made tvOS default to glass. - PresenterChoice.platformDefault -> stage3 on iOS/iPadOS (joins tvOS); explicit stage-2 stays the honest arrival A/B; macOS unchanged. - PresentGate generalized to a capacity: depth 1 is bit-identical to before; iOS runs depth 2 (one flip scanning out + one queued for the next latch), so a decoded frame presents immediately and latches the very next vsync instead of serializing on the previous flip's on-glass callback — expected ~5-8 ms at 120 Hz. tvOS keeps depth 1 (proven; A/B first), macOS is pinned to 1 (glass there is the DCP swapID-panic mitigation — serialization is its point). PUNKTFUNK_GATE_DEPTH (1-3) is the on-device A/B lever. - Settings picker derives its "(default)" marker from presenterDefault instead of hardcoding stage-2 as default / stage-3 as experimental. Co-Authored-By: Claude Fable 5 --- .../Settings/SettingsOptions.swift | 12 +-- .../Settings/SettingsView+Sections.swift | 25 ++--- .../PunktfunkKit/Video/SessionPresenter.swift | 74 +++++++++++---- .../PunktfunkKit/Video/Stage2Pipeline.swift | 93 ++++++++++++------- .../PresentPacingTests.swift | 84 +++++++++++++++-- 5 files changed, 207 insertions(+), 81 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift index de0b8657..b9802879 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift @@ -50,14 +50,14 @@ enum SettingsOptions { return options } - /// The platform's presenter default (mirrors SessionPresenter's platformDefault — tvOS runs - /// glass pacing, everything else 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 — tvOS and + /// iOS/iPadOS run glass pacing, macOS arrival). Views seed their @AppStorage display from + /// this so an untouched picker shows what actually runs. static var presenterDefault: String { - #if os(tvOS) - "stage3" - #else + #if os(macOS) "stage2" + #else + "stage3" #endif } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index 8abe3542..bccaa24b 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -412,27 +412,28 @@ extension SettingsView { #endif } - // Stage-2 (Metal/VTDecompressionSession, present on frame arrival) is the proven default; - // stage-3 is the same pipeline with glass-gated present pacing — a user-visible A/B while the - // pacing work settles (see Stage2Pipeline's PresentPacing for the queue-saturation rationale). - // Stage-1 (compressed video straight to the system layer) stays a DEBUG-only diagnostic — it - // freezes hard on a lost HEVC reference. + // 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. @ViewBuilder var presenterSection: some View { Section { Picker("Presenter", selection: $presenter) { - Text("Stage 2 (default)").tag("stage2") - Text("Stage 3 (experimental)").tag("stage3") - #if DEBUG - Text("Stage 1 (debug)").tag("stage1") - #endif + ForEach(SettingsOptions.presenters, id: \.tag) { option in + Text(option.tag == SettingsOptions.presenterDefault + ? "\(option.label) (default)" : option.label) + .tag(option.tag) + } } } header: { Text("Video presenter") } footer: { - Text("Stage 2: each frame is shown the moment it's decoded — proven, but on displays " + 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 — at most one undisplayed frame in flight, always the freshest, " + + "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.") .font(.geist(12, relativeTo: .caption)) diff --git a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift index c4552afd..403aebfe 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift @@ -1,8 +1,10 @@ -// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: stage-2 (explicit -// VTDecompressionSession decode → CAMetalLayer, driven by the hosting view's CADisplayLink) is the -// default; 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. +// 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. // // Main-thread only: start/layout/stop and the display-link tick all run on the main runloop. @@ -59,13 +61,18 @@ enum PresenterChoice: Equatable { } } - /// tvOS defaults to GLASS pacing: an Apple TV is the sticky-FIFO worst case by construction — - /// a fixed 60 Hz panel fed a 60 fps stream, where arrival pacing pins the layer's image queue - /// at ~3 drawables and every frame rides ~50 ms of queue (the measured display stage there). - /// The Settings picker can still force stage-2 for an A/B. Everything else keeps stage-2 (the - /// proven default; ProMotion/desktop panels out-tick the stream often enough to drain). + /// 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. static var platformDefault: PresenterChoice { - #if os(tvOS) + #if os(tvOS) || os(iOS) .stage3 #else .stage2 @@ -99,6 +106,34 @@ final class SessionPresenter { return .arrival } + /// The glass gate's in-flight present budget (`PresentGate` capacity) for this platform. + /// + /// - 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. + /// + /// `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. + 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 + } + private var pump: StreamPump? private var stage2: Stage2Pipeline? private var stage2Link: CADisplayLink? @@ -140,12 +175,13 @@ final class SessionPresenter { stop() self.connection = connection - // Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a - // CAMetalLayer/display-link present): it can detect + recover a wedged decoder where - // stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-3 is - // the same pipeline with glass-gated present pacing (the settings picker's live A/B — see - // PresentPacing). Stage-1 is reachable only via the DEBUG presenter value; release maps it - // back to stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing). + // Presenter choice — the Metal pipeline is the DEFAULT (explicit VTDecompressionSession + // decode + a CAMetalLayer/display-link present): it can detect + recover a wedged decoder + // where stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Which + // pacing it defaults to is per-platform (glass-gated stage-3 on tvOS/iOS, arrival stage-2 + // on macOS — see PresenterChoice.platformDefault); the settings picker is the live A/B. + // Stage-1 is reachable only via the DEBUG presenter value; release maps it back to the + // default (the stage-1 pump below stays the automatic fallback if Metal is missing). #if DEBUG let allowStage1 = true #else @@ -161,7 +197,9 @@ final class SessionPresenter { endToEndMeter: endToEndMeter, decodeMeter: decodeMeter, displayMeter: displayMeter, pacing: Self.pacing( - for: choice, explicit: explicit, codec: connection.videoCodec)) { + for: choice, explicit: explicit, codec: connection.videoCodec), + gateDepth: Self.gateDepth( + env: ProcessInfo.processInfo.environment["PUNKTFUNK_GATE_DEPTH"])) { let metal = pipeline.layer // The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which // sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout(). diff --git a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index 2a6a80af..bed087fb 100644 --- a/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -20,7 +20,8 @@ // 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 ONE undisplayed drawable so the layer's +// 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. // • Rendering lives on its own thread so any `nextDrawable()` wait lands off-main (input, SwiftUI). // @@ -107,18 +108,19 @@ private final class VsyncClock: @unchecked Sendable { /// When a ready frame is pushed to the layer — the stage-2 vs stage-3 presenter split. Same decode /// half, same newest-wins ring; only the present cadence differs. /// -/// - `arrival` (stage-2, the default): present the moment a frame is decoded. Lowest latency while -/// the layer's image queue is shallow — but that queue is FIFO and consumed at one drawable per -/// refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band +/// - `arrival` (stage-2, the macOS default): present the moment a frame is decoded. Lowest latency +/// while the layer's image queue is shallow — but that queue is FIFO and consumed at one drawable +/// per refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band /// presents the same way when composited), so at stream rate ≈ refresh rate its depth is STICKY: /// one early burst (session start, a Wi-Fi clump) fills it to `maximumDrawableCount` and — with /// arrivals and latches then running at the same rate — it never drains. Every later frame rides -/// ~2–3 refreshes of queue (the measured 29–30 ms display stage on 120 Hz ProMotion panels), and +/// ~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, experimental): at most ONE presented-but-undisplayed drawable in flight -/// (`PresentGate`). The render thread presents only when the previous flip reached glass (the -/// drawable's presented handler reopens the gate and re-signals); frames decoded meanwhile +/// - `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 /// 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. @@ -132,43 +134,58 @@ public enum PresentPacing: Sendable { case glass } -/// Stage-3's present gate: admits one in-flight (presented, not yet on glass) drawable. The render -/// thread `tryAcquire`s before taking a frame; the drawable's presented handler `release`s and -/// re-signals the render thread. `staleAfter` is insurance against a present whose handler never -/// fires (the macOS "out-of-band presents aren't damage" hazard class — see MetalVideoPresenter's -/// init post-mortem): rather than freezing the stream, a stuck gate force-opens after 100 ms, a -/// visible ~10 fps degradation that PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0 -/// on healthy systems). Internal (not private) for unit tests. Sendable; lock-guarded — the -/// releaser runs on a Metal callback thread. +/// Stage-3's present gate: admits `capacity` in-flight (presented, not yet on glass) drawables. +/// The render thread `tryAcquire`s before taking a frame; the drawable's presented handler +/// `release`s and re-signals the render thread. Depth 1 fully serializes presents on the on-glass +/// callback — which costs a refresh whenever the callback's own latency pushes the next present +/// past a vsync; depth 2 keeps one flip queued behind the one scanning out, so a decoded frame +/// presents immediately and latches the very next vsync while the queue still can't build (see +/// `SessionPresenter.gateDepth` for the per-platform choice). `staleAfter` is insurance against a +/// present whose handler never fires (the macOS "out-of-band presents aren't damage" hazard class +/// — see MetalVideoPresenter's init post-mortem): rather than freezing the stream, a full gate +/// force-opens a slot 100 ms after its oldest present, a visible ~10 fps degradation that +/// PUNKTFUNK_PRESENT_DEBUG's `forced` counter exposes (it reads 0 on healthy systems). Internal +/// (not private) for unit tests. Sendable; lock-guarded — the releaser runs on a Metal callback +/// thread. final class PresentGate: @unchecked Sendable { - /// How long one pending present may hold the gate before it's presumed lost. + /// How long one pending present may hold its slot before it's presumed lost. static let staleAfter: CFTimeInterval = 0.1 private let lock = NSLock() - private var pending = false - private var armedAt: CFTimeInterval = 0 + private let capacity: Int + /// Arm instants of the in-flight presents, oldest first (≤ `capacity` entries). + private var armed: [CFTimeInterval] = [] private var forced = 0 - /// Arm the gate for one present. False = a present is already in flight (and not stale) — - /// leave the frame in the ring; the presented handler's release/re-signal (or the next + /// `capacity` = the in-flight present budget (clamped to ≥ 1) — see the type doc. + init(capacity: Int = 1) { + self.capacity = max(1, capacity) + } + + /// Arm the gate for one present. False = the gate is full of live presents (none stale) — + /// leave the frame in the ring; a presented handler's release/re-signal (or the next /// display-link tick) retries with the freshest frame then. func tryAcquire(now: CFTimeInterval) -> Bool { lock.lock() defer { lock.unlock() } - if pending { - guard now - armedAt > Self.staleAfter else { return false } - forced += 1 // presumed-lost present — reopen rather than stall the stream + if armed.count >= capacity { + // Full: reopen only by presuming the OLDEST in-flight present lost (its handler + // never fired) rather than stalling the stream. + guard let oldest = armed.first, now - oldest > Self.staleAfter else { return false } + armed.removeFirst() + forced += 1 } - pending = true - armedAt = now + armed.append(now) return true } - /// The in-flight present reached glass (or was dropped, or its render failed before a present - /// was registered) — reopen. Idempotent: a late stale-path double-release is harmless. + /// One in-flight present reached glass (or was dropped, or its render failed before a present + /// was registered) — free the oldest slot. A release with nothing in flight is a no-op; a + /// lost present's handler firing late after its stale force-open can transiently over-admit + /// one flip, which the next glass callback corrects. func release() { lock.lock() - pending = false + if !armed.isEmpty { armed.removeFirst() } lock.unlock() } @@ -195,7 +212,7 @@ private final class PresentDebugStats: @unchecked Sendable { private var glassDeltasMs: [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 should peg it at 1). + /// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth). private var inFlight = 0 private var maxInFlight = 0 @@ -285,6 +302,9 @@ public final class Stage2Pipeline { /// Present cadence — `.arrival` (stage-2) or `.glass` (stage-3, the present gate). Fixed for /// the pipeline's lifetime; SessionPresenter resolves it per session (see PresentPacing). private let pacing: PresentPacing + /// The glass gate's in-flight present budget (`PresentGate` capacity) — meaningful only under + /// `.glass`; SessionPresenter resolves it per platform (see `SessionPresenter.gateDepth`). + private let gateDepth: Int private let endToEndMeter: LatencyMeter? private let decodeMeter: LatencyMeter? private let displayMeter: LatencyMeter? @@ -328,16 +348,19 @@ public final class Stage2Pipeline { /// render + vsync — the tail stage-2 exists to shorten). All optional: metering never gates /// the presenter choice. Returns nil if Metal can't be set up (headless / no GPU) — caller /// falls back to the stage-1 presenter. `pacing` selects the stage-2 (arrival) vs stage-3 - /// (glass-gated) present cadence — see PresentPacing. + /// (glass-gated) present cadence — see PresentPacing; `gateDepth` is the glass gate's + /// in-flight present budget (see `SessionPresenter.gateDepth`). public init?( endToEndMeter: LatencyMeter?, decodeMeter: LatencyMeter? = nil, displayMeter: LatencyMeter? = nil, - pacing: PresentPacing = .arrival + pacing: PresentPacing = .arrival, + gateDepth: Int = 1 ) { guard let presenter = MetalVideoPresenter.make() else { return nil } self.presenter = presenter self.pacing = pacing + self.gateDepth = gateDepth self.endToEndMeter = endToEndMeter self.decodeMeter = decodeMeter self.displayMeter = displayMeter @@ -534,9 +557,9 @@ public final class Stage2Pipeline { && UserDefaults.standard.bool(forKey: DefaultsKey.vsync)) let debugStats = presentDebug ? PresentDebugStats() : nil let vsyncClock = vsyncClock - // Stage-3's one-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`. - let gate: PresentGate? = pacing == .glass ? PresentGate() : nil + // 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`. + let gate: PresentGate? = pacing == .glass ? PresentGate(capacity: gateDepth) : nil let renderThread = Thread { defer { renderStopped.signal() } // Every iteration drains its own autorelease pool (`return` = the old `continue`): diff --git a/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift b/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift index a574424c..21cfe447 100644 --- a/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift @@ -4,13 +4,14 @@ import XCTest import QuartzCore @testable import PunktfunkKit -/// Stage-3 present pacing: the one-in-flight `PresentGate` and the stage-1/2/3 `PresenterChoice` -/// resolution (setting + PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate). +/// 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. final class PresentPacingTests: XCTestCase { // MARK: - PresentGate - /// The core invariant: one present in flight. A second acquire while pending must fail (the - /// frame stays in the ring for the presented handler's re-signal); release reopens. + /// The depth-1 invariant: one present in flight. A second acquire while pending must fail + /// (the frame stays in the ring for the presented handler's re-signal); release reopens. func testGateAdmitsOneInFlightPresent() { let gate = PresentGate() XCTAssertTrue(gate.tryAcquire(now: 0), "an idle gate must admit the first present") @@ -20,6 +21,32 @@ 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. + func testGateDepthTwoAdmitsTwoInFlightPresents() { + let gate = PresentGate(capacity: 2) + XCTAssertTrue(gate.tryAcquire(now: 0)) + XCTAssertTrue(gate.tryAcquire(now: 0.001), "depth 2 must admit a queued second flip") + XCTAssertFalse(gate.tryAcquire(now: 0.002), "the third present must wait for glass") + gate.release() + XCTAssertTrue(gate.tryAcquire(now: 0.003), "one glass callback frees one slot") + XCTAssertFalse(gate.tryAcquire(now: 0.004)) + XCTAssertEqual(gate.drainForced(), 0) + } + + /// Depth 2 staleness anchors to the OLDEST in-flight present: a full gate stays closed while + /// the oldest is live, force-opens once it ages out, and the younger present keeps its slot. + func testGateDepthTwoForceOpensOnTheOldestStalePresent() { + let gate = PresentGate(capacity: 2) + XCTAssertTrue(gate.tryAcquire(now: 10)) + XCTAssertTrue(gate.tryAcquire(now: 10.05)) + XCTAssertFalse(gate.tryAcquire(now: 10 + PresentGate.staleAfter - 0.01)) + XCTAssertTrue(gate.tryAcquire(now: 10 + PresentGate.staleAfter + 0.01)) + XCTAssertEqual(gate.drainForced(), 1) + // The 10.05 present is still live, so the gate is full again right after the force-open. + XCTAssertFalse(gate.tryAcquire(now: 10 + PresentGate.staleAfter + 0.02)) + } + /// The lost-handler insurance: a present whose handler never fires (the macOS "presents /// aren't damage" hazard class) must not freeze the stream — past `staleAfter` the gate /// force-opens and counts the event for the PUNKTFUNK_PRESENT_DEBUG `forced` stat. @@ -47,11 +74,21 @@ final class PresentPacingTests: XCTestCase { // MARK: - PresenterChoice - func testPresenterChoiceDefaultsToStage2() { + /// 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. + func testPresenterChoiceFallsBackToPlatformDefault() { + #if os(macOS) + XCTAssertEqual(PresenterChoice.platformDefault, .stage2) + #else + XCTAssertEqual(PresenterChoice.platformDefault, .stage3) + #endif XCTAssertEqual( - PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true), .stage2) + PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true), + PresenterChoice.platformDefault) XCTAssertEqual( - PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true), .stage2) + PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true), + PresenterChoice.platformDefault) XCTAssertEqual( PresenterChoice.resolve(setting: "stage2", env: nil, allowStage1: true), .stage2) } @@ -70,14 +107,16 @@ final class PresentPacingTests: XCTestCase { } /// 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 stage-2. + /// builds); a leftover "stage1" value in a release build maps back to the platform default. func testPresenterChoiceGatesStage1() { XCTAssertEqual( PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: true), .stage1) XCTAssertEqual( - PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false), .stage2) + PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false), + PresenterChoice.platformDefault) XCTAssertEqual( - PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), .stage2) + PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), + PresenterChoice.platformDefault) } /// `explicit` is nil exactly when `resolve` would fall back to the platform default — the @@ -118,5 +157,30 @@ final class PresentPacingTests: XCTestCase { XCTAssertEqual( SessionPresenter.pacing(for: .stage3, explicit: nil, codec: .pyrowave), .glass) } + + // 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. + 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") + #endif + XCTAssertEqual( + SessionPresenter.gateDepth(env: "0"), SessionPresenter.gateDepth(env: nil), + "out-of-range env values fall back to the platform depth") + XCTAssertEqual( + SessionPresenter.gateDepth(env: "garbage"), SessionPresenter.gateDepth(env: nil)) + } } #endif