Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/PresentPacingTests.swift
T
enricobuehler 9aebc3f251
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 16s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
ci / bench (push) Successful in 7m29s
docker / deploy-docs (push) Successful in 26s
release / apple (push) Successful in 10m9s
arch / build-publish (push) Successful in 13m2s
android / android (push) Successful in 14m9s
deb / build-publish (push) Successful in 16m41s
apple / screenshots (push) Successful in 6m26s
ci / rust (push) Successful in 20m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m8s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 54s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
apple / swift (push) Successful in 1m15s
decky / build-publish (push) Successful in 20s
fix(apple): default macOS PyroWave sessions to glass-gated present pacing — DCP swapID kernel-panic mitigation
Windowed PyroWave sessions were kernel-panicking Macs ("mismatched
swapID's" @UnifiedPipeline.cpp, WindowServer → AppleMobileDisp*-DCP).
The panic is an Apple DCP bug (SketchUp/Unity/240Hz-monitor reports hit
the same signature), but our stage-2 arrival pacing feeds the trigger
pattern: displaySyncEnabled=false out-of-band presents arriving faster
than the compositor latches them in a composited (windowed) session.
PyroWave makes that pattern routine — near-instant Metal wavelet decode
turns network clumps into same-millisecond present bursts, and it is
the codec that sustains stream rates above panel refresh.

Mitigation: PyroWave sessions on macOS now default to the stage-3
PresentGate (one presented-but-undisplayed swap in flight, serialized
on the on-glass callback, 100 ms stale backstop) — the racing pattern
cannot occur. Explicit stage2/stage3 picks (setting or
PUNKTFUNK_PRESENTER) still win, so the arrival-pacing A/B stays honest.
VideoToolbox codecs keep arrival pacing (decode latency spaces their
presents; no panic reports there).

PresenterChoice gains an `explicit` resolver (nil = no user selection)
so the codec-conditional default only applies when the user hasn't
picked a stage; pacing selection is a testable helper carrying the
rationale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 11:17:48 +02:00

123 lines
6.2 KiB
Swift

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)
}
/// `explicit` is nil exactly when `resolve` would fall back to the platform default — the
/// distinction the codec-conditional pacing default rides on.
func testPresenterChoiceExplicitIsNilWithoutASelection() {
XCTAssertNil(PresenterChoice.explicit(setting: nil, env: nil, allowStage1: true))
XCTAssertNil(PresenterChoice.explicit(setting: "garbage", env: nil, allowStage1: true))
XCTAssertNil(PresenterChoice.explicit(setting: "stage1", env: nil, allowStage1: false))
XCTAssertEqual(
PresenterChoice.explicit(setting: "stage2", env: nil, allowStage1: true), .stage2)
XCTAssertEqual(
PresenterChoice.explicit(setting: nil, env: "stage3", allowStage1: true), .stage3)
}
// MARK: - Session pacing (the macOS PyroWave swapID-panic mitigation)
/// macOS PyroWave sessions under the DEFAULT stage-2 choice must get glass pacing (the
/// one-in-flight gate is the "mismatched swapID's" kernel-panic mitigation); an EXPLICIT
/// stage-2 pick must stay a faithful arrival-pacing A/B. Elsewhere the default is unchanged.
func testPacingDefaultsPyroWaveToGlassOnMacOS() {
#if os(macOS)
XCTAssertEqual(
SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .pyrowave), .glass,
"defaulted macOS PyroWave must serialize presents (swapID-panic mitigation)")
XCTAssertEqual(
SessionPresenter.pacing(for: .stage2, explicit: .stage2, codec: .pyrowave), .arrival,
"an explicit stage-2 pick must keep arrival pacing (honest A/B)")
#else
XCTAssertEqual(
SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .pyrowave), .arrival)
#endif
// Non-PyroWave defaults keep arrival pacing under stage-2 everywhere.
XCTAssertEqual(
SessionPresenter.pacing(for: .stage2, explicit: nil, codec: .hevc), .arrival)
// Stage-3 means glass regardless of codec or how it was chosen.
XCTAssertEqual(
SessionPresenter.pacing(for: .stage3, explicit: .stage3, codec: .hevc), .glass)
XCTAssertEqual(
SessionPresenter.pacing(for: .stage3, explicit: nil, codec: .pyrowave), .glass)
}
}
#endif