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
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:
@@ -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 ~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().
|
||||
|
||||
@@ -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`):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user