feat(apple): stage-3 presenter — glass-gated present pacing as a live A/B
ci / web (push) Successful in 52s
apple / swift (push) Successful in 1m11s
ci / docs-site (push) Successful in 1m2s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
ci / bench (push) Successful in 5m47s
docker / deploy-docs (push) Failing after 37s
release / apple (push) Successful in 8m15s
android / android (push) Successful in 12m57s
arch / build-publish (push) Successful in 13m28s
deb / build-publish (push) Successful in 12m15s
apple / screenshots (push) Successful in 5m55s
ci / rust (push) Successful in 17m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m24s
ci / web (push) Successful in 52s
apple / swift (push) Successful in 1m11s
ci / docs-site (push) Successful in 1m2s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
ci / bench (push) Successful in 5m47s
docker / deploy-docs (push) Failing after 37s
release / apple (push) Successful in 8m15s
android / android (push) Successful in 12m57s
arch / build-publish (push) Successful in 13m28s
deb / build-publish (push) Successful in 12m15s
apple / screenshots (push) Successful in 5m55s
ci / rust (push) Successful in 17m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m22s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m24s
Stage-2's present-on-arrival saturates CAMetalLayer's FIFO image queue whenever the stream rate runs at the panel's refresh (iOS always vsync-latches; the macOS 26 compositor latch-paces our out-of-band presents the same way): one early burst fills the queue to maximumDrawableCount and — arrivals then matching latches one-for-one — it never drains. That sticky depth is the measured 29-30 ms display stage on the 120 Hz ProMotion devices, and the full-queue regime is where host<->panel clock drift turns into the reported fixed-interval repeats/drops. The 240 Hz Studio never saturates, which is why it never showed either symptom. Stage-3 is the same pipeline with a PresentGate: at most ONE presented-but-undisplayed drawable in flight; the drawable's presented handler reopens the gate and re-signals the render thread, so the next present always takes the freshest newest-wins ring frame — the hidden queue latency becomes explicit, correct frame drops. A 100 ms stale fallback force-opens a gate whose handler never fires (the "presents aren't damage" hazard class) so a pathological system degrades visibly instead of freezing; the PUNKTFUNK_PRESENT_DEBUG `forced` counter exposes it (0 on healthy systems). Selection: the Settings > Display presenter picker now ships in release builds (stage 2 default / stage 3 experimental; the freeze-prone stage-1 diagnostic stays DEBUG-only), resolved per session with a PUNKTFUNK_PRESENTER=stage1|stage2|stage3 env override for CLI A/B. The pf-present debug line gains gated/forced/inflightMax — inflightMax is the direct image-queue-depth measurement for the A/B. Live-verified both ways against a real host at 1080p120: stage-3 holds 120/120 fps with inflightMax=1, forced=0, glass deltas p50 8.33 ms; stage-2 is behaviorally unchanged (120/120 fps, inflightMax=2 even on the wired 240 Hz setup — the saturation signal in miniature). Unit tests cover the gate (one-in-flight, stale force-open, idempotent release) and the presenter resolution (env override, release stage-1 gating); macOS tests green, iOS/tvOS xcodebuild clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import XCTest
|
||||
|
||||
#if canImport(Metal)
|
||||
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).
|
||||
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.
|
||||
func testGateAdmitsOneInFlightPresent() {
|
||||
let gate = PresentGate()
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0), "an idle gate must admit the first present")
|
||||
XCTAssertFalse(gate.tryAcquire(now: 0.001), "a pending present must close the gate")
|
||||
gate.release()
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0.002), "release must reopen the gate")
|
||||
XCTAssertEqual(gate.drainForced(), 0, "no stale present was force-cleared")
|
||||
}
|
||||
|
||||
/// 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.
|
||||
func testGateForceOpensAfterStaleTimeout() {
|
||||
let gate = PresentGate()
|
||||
XCTAssertTrue(gate.tryAcquire(now: 10))
|
||||
// Within the stale window the gate stays closed.
|
||||
XCTAssertFalse(gate.tryAcquire(now: 10 + PresentGate.staleAfter - 0.01))
|
||||
// Past it, the pending present is presumed lost: reopen, and count the force-clear.
|
||||
XCTAssertTrue(gate.tryAcquire(now: 10 + PresentGate.staleAfter + 0.01))
|
||||
XCTAssertEqual(gate.drainForced(), 1)
|
||||
XCTAssertEqual(gate.drainForced(), 0, "drain resets the counter")
|
||||
}
|
||||
|
||||
/// Release is idempotent (a late stale-path double-release must be harmless), and an
|
||||
/// acquire-then-release with no present (empty ring after arming) leaves the gate clean.
|
||||
func testGateReleaseIsIdempotent() {
|
||||
let gate = PresentGate()
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0))
|
||||
gate.release()
|
||||
gate.release() // the stale-cleared present's handler firing late
|
||||
XCTAssertTrue(gate.tryAcquire(now: 0.001))
|
||||
XCTAssertEqual(gate.drainForced(), 0)
|
||||
}
|
||||
|
||||
// MARK: - PresenterChoice
|
||||
|
||||
func testPresenterChoiceDefaultsToStage2() {
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true), .stage2)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true), .stage2)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage2", env: nil, allowStage1: true), .stage2)
|
||||
}
|
||||
|
||||
func testPresenterChoiceResolvesStage3FromSettingAndEnv() {
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage3", env: nil, allowStage1: true), .stage3)
|
||||
// The env override wins over the persisted setting (A/B without touching settings)…
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage2", env: "stage3", allowStage1: true), .stage3)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage3", env: "stage2", allowStage1: true), .stage2)
|
||||
// …but an EMPTY env var is "unset", not an override.
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage3", env: "", allowStage1: true), .stage3)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
func testPresenterChoiceGatesStage1() {
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: true), .stage1)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false), .stage2)
|
||||
XCTAssertEqual(
|
||||
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false), .stage2)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user