fix(apple): default iOS to glass-gated present pacing with a depth-2 gate — the 23 ms display-stage fix
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
ci / bench (push) Successful in 6m52s
release / apple (push) Successful in 8m46s
deb / build-publish (push) Successful in 11m40s
docker / deploy-docs (push) Successful in 24s
arch / build-publish (push) Successful in 12m42s
deb / build-publish-host (push) Successful in 12m6s
android / android (push) Successful in 13m55s
apple / screenshots (push) Successful in 6m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m39s
ci / rust (push) Successful in 24m30s
ci / docs-site (push) Successful in 50s
ci / web (push) Successful in 54s
apple / swift (push) Successful in 1m15s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 12:42:34 +02:00
parent c4d6e6a877
commit 2149673f89
5 changed files with 207 additions and 81 deletions
@@ -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
}
@@ -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))
@@ -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 ~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.
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 (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.
///
/// `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.
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().
@@ -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
/// ~23 refreshes of queue (the measured 2930 ms display stage on 120 Hz ProMotion panels), and
/// ~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, 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`):
@@ -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