0e60e58cab
ci / docs-site (push) Successful in 52s
ci / web (push) Successful in 56s
decky / build-publish (push) Successful in 21s
apple / swift (push) Successful in 1m24s
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 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
ci / bench (push) Successful in 5m53s
release / apple (push) Successful in 9m19s
deb / build-publish (push) Successful in 12m27s
arch / build-publish (push) Successful in 12m52s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-host (push) Successful in 13m18s
apple / screenshots (push) Successful in 6m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m51s
android / android (push) Successful in 18m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m15s
ci / rust (push) Successful in 27m38s
Task 1 (latency): the transactional present now commits from the RENDER
thread inside an explicit CATransaction + flush() instead of hopping to
main. The present harness (2026-07-21, 240 Hz Studio, full-size window)
measured the main hop batching presents at runloop-iteration rate when
main is busy — an active implicit transaction NESTS the explicit one —
which is the field's presents=55 @ fps=240 / display_p50 18.6 ms.
Off-main commits measured immune to main churn: glass p50 ~10 ms,
cadence a clean 4.17 ms at 240. flush() is load-bearing: without it the
render thread's own implicit transaction (drawableSize/colour writes)
swallows the explicit commit and NOTHING reaches glass — the harness
reproduced the exact freeze (every present dropped). Legacy main-hop
kept as PUNKTFUNK_TXN_PRESENT=main for field A/B. New pf-windowed
Console line (subsystem io.unom.punktfunk, category presenter)
decomposes the issue side per second.
Task 1 lever D: the f407f418 IOSurface contents-swap path is
resurrected format-aware as WindowedPresentMode.surface — bgra8 SDR /
rgba16Float+PQ-tagged HDR pool, swap committed off-main from the
completion handler; EDR anchored by the metal layer underneath.
Env-only prototype (PUNKTFUNK_WINDOWED_PRESENT=surface) until the HDR
composite is eyeballed on glass.
Task 2 (setting): punktfunk.windowedSafePresent (default ON =
transactional mitigation; OFF = the fast async path whose panic the
saga is about — the caption says so in plain words). Rows in the touch
Settings (Presentation) and gamepad settings, macOS-only.
PUNKTFUNK_WINDOWED_PRESENT=async|transaction|surface overrides for dev
A/B. maximumDrawableCount stays 3 — the harness measured 2 starving the
render loop (172/s) and worsening glass p50.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
380 lines
19 KiB
Swift
380 lines
19 KiB
Swift
import XCTest
|
||
|
||
#if canImport(Metal)
|
||
import QuartzCore
|
||
@testable import PunktfunkKit
|
||
|
||
/// Present pacing: the stage-3 bounded in-flight `PresentGate`, the stage-4 `LatestBox`
|
||
/// drawable hand-off, the stage-1/2/3/4 `PresenterChoice` resolution (setting +
|
||
/// PUNKTFUNK_PRESENTER env override + the release-build stage-1 gate + the iOS/tvOS-only
|
||
/// stage-4 gate), and the per-platform glass-gate depth.
|
||
final class PresentPacingTests: XCTestCase {
|
||
// MARK: - PresentGate
|
||
|
||
/// 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")
|
||
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")
|
||
}
|
||
|
||
/// Depth 2 (the PUNKTFUNK_GATE_DEPTH ladder rung — no longer a default; see
|
||
/// `SessionPresenter.gateDepth`'s standing-queue post-mortem): 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.
|
||
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: - PresentPriority (the user-facing latency/smoothness intent)
|
||
|
||
/// Resolution from the persisted settings: anything but an explicit "smooth" is latency
|
||
/// (the default), and the buffer setting maps 0/out-of-range/garbage to Automatic (2).
|
||
func testPresentPriorityResolution() {
|
||
XCTAssertEqual(PresentPriority.resolve(setting: nil, bufferSetting: nil), .latency)
|
||
XCTAssertEqual(PresentPriority.resolve(setting: "latency", bufferSetting: 3), .latency)
|
||
XCTAssertEqual(PresentPriority.resolve(setting: "garbage", bufferSetting: nil), .latency)
|
||
XCTAssertEqual(
|
||
PresentPriority.resolve(setting: "smooth", bufferSetting: nil),
|
||
.smooth(buffer: 2), "unset buffer = Automatic = 2")
|
||
XCTAssertEqual(
|
||
PresentPriority.resolve(setting: "smooth", bufferSetting: 0), .smooth(buffer: 2))
|
||
XCTAssertEqual(
|
||
PresentPriority.resolve(setting: "smooth", bufferSetting: 1), .smooth(buffer: 1))
|
||
XCTAssertEqual(
|
||
PresentPriority.resolve(setting: "smooth", bufferSetting: 3), .smooth(buffer: 3))
|
||
XCTAssertEqual(
|
||
PresentPriority.resolve(setting: "smooth", bufferSetting: 9),
|
||
.smooth(buffer: 2), "out-of-range buffer = Automatic")
|
||
}
|
||
|
||
/// The intent→store mapping: latency runs the zero-queue newest-wins slot, smoothness the
|
||
/// FIFO jitter buffer at the resolved capacity.
|
||
func testPresentPriorityStorePolicy() {
|
||
XCTAssertEqual(PresentPriority.latency.storePolicy, .newestWins)
|
||
XCTAssertEqual(
|
||
PresentPriority.smooth(buffer: 3).storePolicy, .fifo(capacity: 3))
|
||
}
|
||
|
||
// MARK: - FrameStore (the decoded-frame hand-off, both intents)
|
||
|
||
/// Newest-wins (latency): submit replaces the undisplayed frame, take clears, putBack
|
||
/// restores only into an empty slot — the exact pre-rebuild ReadyRing semantics.
|
||
func testFrameStoreNewestWins() {
|
||
let store = FrameStore<Int>(policy: .newestWins)
|
||
XCTAssertNil(store.take())
|
||
store.submit(1)
|
||
store.submit(2)
|
||
XCTAssertEqual(store.take(), 2, "the newer decode replaces the undisplayed frame")
|
||
XCTAssertNil(store.take())
|
||
store.putBack(7)
|
||
store.submit(8) // a fresh decode beats the putBack
|
||
store.putBack(7)
|
||
XCTAssertEqual(store.take(), 8)
|
||
XCTAssertEqual(store.drainSubmitted(), 3)
|
||
let smoothing = store.drainSmoothing()
|
||
XCTAssertEqual(smoothing.overflowDrops, 0)
|
||
XCTAssertEqual(smoothing.underflows, 0)
|
||
}
|
||
|
||
/// FIFO (smoothness): take withholds frames until the buffer has PREROLLED to capacity —
|
||
/// without preroll a steady stream drains on arrival and headroom never builds — then pops
|
||
/// oldest-first.
|
||
func testFrameStoreFifoPrerollsToCapacity() {
|
||
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
|
||
store.submit(1)
|
||
XCTAssertNil(store.take(), "one frame buffered — still building headroom")
|
||
store.submit(2)
|
||
XCTAssertEqual(store.take(), 1, "prerolled — pops the OLDEST")
|
||
store.submit(3)
|
||
XCTAssertEqual(store.take(), 2, "steady state: one in, oldest out")
|
||
XCTAssertEqual(store.take(), 3)
|
||
}
|
||
|
||
/// FIFO overflow drops the OLDEST (bounded added latency, the newest keeps flowing) and
|
||
/// counts it; running dry counts an underflow and re-arms preroll so headroom rebuilds.
|
||
func testFrameStoreFifoOverflowAndUnderflow() {
|
||
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
|
||
store.submit(1)
|
||
store.submit(2)
|
||
store.submit(3) // full — 1 (the oldest) goes
|
||
XCTAssertEqual(store.take(), 2)
|
||
XCTAssertEqual(store.take(), 3)
|
||
XCTAssertNil(store.take(), "ran dry — an underflow, preroll re-arms")
|
||
store.submit(4)
|
||
XCTAssertNil(store.take(), "rebuilding headroom after the underflow")
|
||
store.submit(5)
|
||
XCTAssertEqual(store.take(), 4)
|
||
let smoothing = store.drainSmoothing()
|
||
XCTAssertEqual(smoothing.overflowDrops, 1)
|
||
XCTAssertEqual(smoothing.underflows, 1)
|
||
}
|
||
|
||
/// FIFO putBack reinserts at the FRONT — a frame the render thread couldn't present is
|
||
/// still the oldest, so present order is preserved.
|
||
func testFrameStoreFifoPutBackPreservesOrder() {
|
||
let store = FrameStore<Int>(policy: .fifo(capacity: 2))
|
||
store.submit(1)
|
||
store.submit(2)
|
||
let f = store.take()
|
||
XCTAssertEqual(f, 1)
|
||
store.putBack(f!)
|
||
XCTAssertEqual(store.take(), 1, "the returned frame stays first out")
|
||
XCTAssertEqual(store.take(), 2)
|
||
}
|
||
|
||
// MARK: - LatestBox (stage-4's drawable hand-off)
|
||
|
||
/// Newest-wins hand-off: `put` replaces (an unpresented older drawable returns to the
|
||
/// layer's pool by release), `take` empties the slot.
|
||
func testLatestBoxNewestWins() {
|
||
let box = LatestBox<Int>()
|
||
XCTAssertNil(box.take())
|
||
box.put(1)
|
||
box.put(2)
|
||
XCTAssertEqual(box.take(), 2, "a fresher put replaces the unpresented value")
|
||
XCTAssertNil(box.take(), "take empties the slot")
|
||
}
|
||
|
||
/// `putBack` fills only an EMPTY slot: the render thread returning a drawable it took but
|
||
/// didn't present must never clobber a fresher one the link vended in between.
|
||
func testLatestBoxPutBackNeverClobbersAFresherPut() {
|
||
let box = LatestBox<Int>()
|
||
box.put(1)
|
||
let stale = box.take()
|
||
XCTAssertEqual(stale, 1)
|
||
box.putBack(stale!)
|
||
XCTAssertEqual(box.take(), 1, "putBack into a still-empty slot restores the value")
|
||
box.put(2)
|
||
let taken = box.take()
|
||
box.put(3) // the link vends a fresher drawable while the render thread holds `taken`
|
||
box.putBack(taken!)
|
||
XCTAssertEqual(box.take(), 3, "the fresher vend wins over the stale return")
|
||
}
|
||
|
||
// MARK: - PresenterChoice
|
||
|
||
/// The platform default: deadline-paced stage-4 on iOS/iPadOS AND tvOS (the vsync-latching
|
||
/// platforms where any bounded-FIFO pacing keeps a standing queue — tvOS joined in the
|
||
/// 2026-07 presentation rebuild), arrival stage-2 on macOS (sync-off presents don't queue).
|
||
/// No selection / garbage falls back to it.
|
||
func testPresenterChoiceFallsBackToPlatformDefault() {
|
||
#if os(iOS) || os(tvOS)
|
||
XCTAssertEqual(PresenterChoice.platformDefault, .stage4)
|
||
#else
|
||
XCTAssertEqual(PresenterChoice.platformDefault, .stage2)
|
||
#endif
|
||
XCTAssertEqual(
|
||
PresenterChoice.resolve(setting: nil, env: nil, allowStage1: true),
|
||
PresenterChoice.platformDefault)
|
||
XCTAssertEqual(
|
||
PresenterChoice.resolve(setting: "garbage", env: nil, allowStage1: true),
|
||
PresenterChoice.platformDefault)
|
||
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)
|
||
}
|
||
|
||
/// "stage4" (deadline pacing) resolves only on iOS/tvOS. On macOS — whose present path is
|
||
/// entangled with the sync-off/DCP-panic saga — a synced-over "stage4" value maps back to
|
||
/// the platform default instead of engaging an unvalidated pacing.
|
||
func testPresenterChoiceGatesStage4ToVsyncLatchPlatforms() {
|
||
#if os(macOS)
|
||
XCTAssertNil(PresenterChoice.explicit(setting: "stage4", env: nil, allowStage1: true))
|
||
XCTAssertEqual(
|
||
PresenterChoice.resolve(setting: "stage4", env: nil, allowStage1: true), .stage2)
|
||
XCTAssertEqual(
|
||
PresenterChoice.resolve(setting: nil, env: "stage4", allowStage1: true), .stage2)
|
||
#else
|
||
XCTAssertEqual(
|
||
PresenterChoice.explicit(setting: "stage4", env: nil, allowStage1: true), .stage4)
|
||
XCTAssertEqual(
|
||
PresenterChoice.resolve(setting: nil, env: "stage4", allowStage1: true), .stage4)
|
||
// The env override wins over the persisted setting, both directions.
|
||
XCTAssertEqual(
|
||
PresenterChoice.resolve(setting: "stage4", env: "stage3", allowStage1: true), .stage3)
|
||
XCTAssertEqual(
|
||
PresenterChoice.resolve(setting: "stage2", env: "stage4", allowStage1: true), .stage4)
|
||
#endif
|
||
}
|
||
|
||
/// 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 the platform default.
|
||
func testPresenterChoiceGatesStage1() {
|
||
XCTAssertEqual(
|
||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: true), .stage1)
|
||
XCTAssertEqual(
|
||
PresenterChoice.resolve(setting: "stage1", env: nil, allowStage1: false),
|
||
PresenterChoice.platformDefault)
|
||
XCTAssertEqual(
|
||
PresenterChoice.resolve(setting: nil, env: "stage1", allowStage1: false),
|
||
PresenterChoice.platformDefault)
|
||
}
|
||
|
||
/// `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)
|
||
// Stage-4 means deadline regardless of codec or how it was chosen.
|
||
XCTAssertEqual(
|
||
SessionPresenter.pacing(for: .stage4, explicit: nil, codec: .hevc), .deadline)
|
||
XCTAssertEqual(
|
||
SessionPresenter.pacing(for: .stage4, explicit: .stage4, codec: .pyrowave), .deadline)
|
||
}
|
||
|
||
// MARK: - Windowed present mechanism (the macOS DCP swapID-panic mitigation picker)
|
||
|
||
#if os(macOS)
|
||
/// The safe-present setting: ON/unset → the validated transactional mitigation; an explicit
|
||
/// OFF → the fast async path (the user accepted the affected-setup panic risk). The
|
||
/// PUNKTFUNK_WINDOWED_PRESENT env lever overrides both ways, `surface` is env-only (the
|
||
/// prototype mechanism), and garbage/empty env values are "unset", not an override.
|
||
func testWindowedPresentModeResolution() {
|
||
XCTAssertEqual(
|
||
SessionPresenter.windowedPresentMode(setting: nil, env: nil), .transaction,
|
||
"unset defaults to the panic mitigation")
|
||
XCTAssertEqual(
|
||
SessionPresenter.windowedPresentMode(setting: true, env: nil), .transaction)
|
||
XCTAssertEqual(
|
||
SessionPresenter.windowedPresentMode(setting: false, env: nil), .async,
|
||
"an explicit opt-out gets the fast async path")
|
||
// The dev env lever wins over the setting, both directions.
|
||
XCTAssertEqual(
|
||
SessionPresenter.windowedPresentMode(setting: true, env: "async"), .async)
|
||
XCTAssertEqual(
|
||
SessionPresenter.windowedPresentMode(setting: false, env: "transaction"),
|
||
.transaction)
|
||
XCTAssertEqual(
|
||
SessionPresenter.windowedPresentMode(setting: true, env: "surface"), .surface,
|
||
"the surface prototype is reachable via env only")
|
||
XCTAssertEqual(
|
||
SessionPresenter.windowedPresentMode(setting: false, env: "surface"), .surface)
|
||
// Garbage/empty env = unset.
|
||
XCTAssertEqual(
|
||
SessionPresenter.windowedPresentMode(setting: nil, env: "garbage"), .transaction)
|
||
XCTAssertEqual(
|
||
SessionPresenter.windowedPresentMode(setting: false, env: ""), .async)
|
||
}
|
||
#endif
|
||
|
||
// MARK: - Glass-gate depth
|
||
|
||
/// The in-flight present budget is 1 EVERYWHERE: any deeper gate keeps a standing queue —
|
||
/// the 2026-07 iPad depth-2 experiment regressed display latency 14→22–28 ms (see
|
||
/// `SessionPresenter.gateDepth`'s post-mortem). macOS additionally pins the env lever (glass
|
||
/// there is the swapID-panic mitigation — strict serialization is its point);
|
||
/// PUNKTFUNK_GATE_DEPTH still reproduces the standing-queue ladder on 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")
|
||
#else
|
||
XCTAssertEqual(
|
||
SessionPresenter.gateDepth(env: nil), 1,
|
||
"any depth >1 is a standing queue — one refresh of display latency per slot")
|
||
XCTAssertEqual(SessionPresenter.gateDepth(env: "2"), 2, "the on-device ladder 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
|