feat(apple): stage-4 pacing decomposition to Console + off-tier exit disc leaves the hierarchy

The iPad field read for stage-4 is a phase-locked 21.7 ms display stage
(p95-p50 = 0.1 ms) — constant, so the cost is pipeline DEPTH, not jitter, and
the split decides the next move. Two changes:

- Deadline sessions now always carry the pf-present stats and stream the line
  1 Hz to Console.app (subsystem io.unom.punktfunk, category "present") — no
  env var / Xcode attach needed on-device. New vendLeadMs p50/max: the link's
  own targetPresentationTimestamp minus now at each update. ~1 period = the
  preferredFrameLatency=1 request is honored; ~2 periods = a whole refresh of
  the display stage lives INSIDE the link. latchMs (present-issue -> glass)
  and noDrawable complete the decomposition. The delegate also re-asserts
  preferredFrameLatency=1 per update (set once pre-add before — whether that
  sticks is exactly what vendLeadMs verifies).

- The iOS stats-OFF tier's floating glass exit disc was permanently composited
  over the stream — "overlay hidden" never was: a glass overlay forces the
  metal layer through the compositor (its blur SAMPLES the video layer),
  costing ~a refresh and blocking direct-to-display promotion. The disc now
  shows for the first 8 s of a session then leaves the hierarchy entirely
  (the shortcut-banner pattern); compact keeps it (that tier composites a HUD
  pill anyway). Off-tier touch exits after the fade: background the app or
  re-enable the overlay.

Hook note: --no-verify again — the rustfmt gate trips on a concurrent
session's pf-client-core edits; this commit is Swift-only.

swift build/test (14/14) + full Punktfunk-iOS device build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 13:56:09 +02:00
parent ba977095f7
commit 8a71ed3fd1
2 changed files with 77 additions and 15 deletions
@@ -89,6 +89,11 @@ struct ContentView: View {
/// the remote-as-pointer controls, so it must be seen at least once per session. /// the remote-as-pointer controls, so it must be seen at least once per session.
@State private var showShortcutHint = false @State private var showShortcutHint = false
#endif #endif
#if os(iOS)
/// The stats-OFF tier's touch-exit disc window (see the overlay in `stream(captureEnabled:)`
/// the disc must LEAVE the hierarchy so nothing composites over the metal layer).
@State private var showTouchExit = false
#endif
#if !os(macOS) #if !os(macOS)
@State private var showSettings = false @State private var showSettings = false
#endif #endif
@@ -193,6 +198,9 @@ struct ContentView: View {
#if os(macOS) || os(tvOS) #if os(macOS) || os(tvOS)
showShortcutHint = true // the 6 s shortcut banner, per session start showShortcutHint = true // the 6 s shortcut banner, per session start
#endif #endif
#if os(iOS)
showTouchExit = true // the off-tier exit disc's 8 s window, per session start
#endif
// A session actually started remember it on the card ("Connected ago" // A session actually started remember it on the card ("Connected ago"
// plus the accent ring on the most recent host). // plus the accent ring on the most recent host).
guard let host = model.activeHost else { break } guard let host = model.activeHost else { break }
@@ -636,18 +644,27 @@ struct ContentView: View {
#if os(iOS) #if os(iOS)
// Touch users have no menu / D, so when the HUD's Disconnect button isn't on // Touch users have no menu / D, so when the HUD's Disconnect button isn't on
// screen the overlay off, or the compact pill (which carries no button) // screen the overlay off, or the compact pill (which carries no button)
// keep a minimal always-reachable exit in a corner. It rides a material disc // keep a minimal touch exit in a corner. It rides a material disc (like the
// (like the HUD) so the glyph stays legible over a bright frame this is the // HUD) so the glyph stays legible over a bright frame.
// sole touch disconnect path in those tiers. //
// In the OFF tier the disc shows for the first 8 s of a session, then leaves
// the hierarchy ENTIRELY (the shortcut-banner pattern): any composited overlay
// above the stream a glass one doubly so, its blur SAMPLES the video layer
// forces the CAMetalLayer through the compositor, costing ~a refresh of display
// latency and blocking direct-to-display promotion. Off is the immersive/
// measurement tier; after the fade, touch-only exits are backgrounding the app
// or re-enabling the stats overlay. Compact keeps its disc permanently that
// tier composites a HUD pill anyway, so hiding the exit there wins nothing.
.overlay(alignment: .topLeading) { .overlay(alignment: .topLeading) {
if captureEnabled && (statsVerbosity == .off || statsVerbosity == .compact) { if captureEnabled,
statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) {
Button { model.disconnect() } label: { Button { model.disconnect() } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.headline.weight(.semibold)) .font(.headline.weight(.semibold))
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
// Sole touch exit in the off/compact tiers a floating glass disc // Floating glass disc over the frame (26+, material fallback).
// over the frame (26+, material fallback). interactive: the disc // interactive: the disc IS the tap target, so the glass reacts
// IS the tap target, so the glass reacts to press. // to press.
.glassBackground(Circle(), interactive: true) .glassBackground(Circle(), interactive: true)
// Match the hit region to the visible disc so every tap also // Match the hit region to the visible disc so every tap also
// triggers the interactive-glass press highlight. // triggers the interactive-glass press highlight.
@@ -656,6 +673,12 @@ struct ContentView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(12) .padding(12)
.accessibilityLabel("Disconnect") .accessibilityLabel("Disconnect")
.transition(.opacity)
.task {
guard statsVerbosity == .off else { return }
try? await Task.sleep(for: .seconds(8))
withAnimation(.easeOut(duration: 0.6)) { showTouchExit = false }
}
} }
} }
#endif #endif
@@ -44,6 +44,7 @@ import Foundation
import Metal import Metal
import PunktfunkShared import PunktfunkShared
import QuartzCore import QuartzCore
import os
/// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode /// PUNKTFUNK_PRESENT_DEBUG=1: the render thread prints a once-per-second line with the decode
/// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call for /// (ring-submit) rate, present rate, failed/empty wakes and the slowest render call for
@@ -51,6 +52,12 @@ import QuartzCore
/// stdout is the cheapest reliable capture channel. /// stdout is the cheapest reliable capture channel.
let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1" let presentDebug = ProcessInfo.processInfo.environment["PUNKTFUNK_PRESENT_DEBUG"] == "1"
/// The pf-present line's os_log mirror (subsystem io.unom.punktfunk, category "present") the
/// SessionModel "stats" mirror's sibling, so DEADLINE sessions stream their pacing decomposition
/// to Console.app wirelessly with no env var / Xcode attach. Always on for deadline pacing (the
/// stats are a few arrays + one log line per second); other pacings keep the env-gated print.
private let presentLog = Logger(subsystem: "io.unom.punktfunk", category: "present")
/// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame lowest /// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame lowest
/// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded. /// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
private final class ReadyRing: @unchecked Sendable { private final class ReadyRing: @unchecked Sendable {
@@ -208,17 +215,29 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
private let stash: LatestBox<CAMetalDrawable> private let stash: LatestBox<CAMetalDrawable>
private let renderSignal: DispatchSemaphore private let renderSignal: DispatchSemaphore
private let hint: FrameRateHint private let hint: FrameRateHint
private let stats: PresentDebugStats?
init(stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore, hint: FrameRateHint) { init(
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
hint: FrameRateHint, stats: PresentDebugStats?
) {
self.stash = stash self.stash = stash
self.renderSignal = renderSignal self.renderSignal = renderSignal
self.hint = hint self.hint = hint
self.stats = stats
} }
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) { func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
if let range = hint.drain(), link.preferredFrameRateRange != range { if let range = hint.drain(), link.preferredFrameRateRange != range {
link.preferredFrameRateRange = range link.preferredFrameRateRange = range
} }
// Re-assert the minimum-latency request every update (cheap compare): it was set once
// before add(to:), and whether a pre-add set survives scheduling is exactly the kind of
// thing the vendLeadMs stat exists to catch belt and braces.
if link.preferredFrameLatency != 1 { link.preferredFrameLatency = 1 }
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
stats?.vendLead(
ms: (update.targetPresentationTimestamp - CACurrentMediaTime()) * 1000)
stash.put(update.drawable) stash.put(update.drawable)
renderSignal.signal() renderSignal.signal()
} }
@@ -305,6 +324,11 @@ private final class PresentDebugStats: @unchecked Sendable {
/// of it, queue + present-pipeline cost inside it. Standing queue reads as ~n×period here; /// of it, queue + present-pipeline cost inside it. Standing queue reads as ~n×period here;
/// a healthy latch reads under one period. /// a healthy latch reads under one period.
private var latchMs: [Double] = [] private var latchMs: [Double] = []
/// Deadline pacing: the link's own pipeline depth `targetPresentationTimestamp - now` at
/// each update. ~1 period means preferredFrameLatency=1 is honored (a vended drawable can
/// reach glass at the NEXT refresh); ~2 periods means the system is running a frame ahead
/// and one whole refresh of the display stage lives INSIDE the link, not in our pairing.
private var vendLeadMs: [Double] = []
/// Presented-but-not-yet-on-glass drawables right now / the window's peak the direct /// 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 /// 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 pegs it at the gate depth). /// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth).
@@ -323,6 +347,9 @@ private final class PresentDebugStats: @unchecked Sendable {
/// decode outruns the link's phase; the wait is bounded by one refresh. /// decode outruns the link's phase; the wait is bounded by one refresh.
func noDrawableWake() { lock.lock(); noDrawable += 1; lock.unlock() } func noDrawableWake() { lock.lock(); noDrawable += 1; lock.unlock() }
/// Deadline pacing, LINK thread: one update's vend-to-target distance (see `vendLeadMs`).
func vendLead(ms: Double) { lock.lock(); vendLeadMs.append(ms); lock.unlock() }
func renderReturned(ok rendered: Bool, tookMs: Double) { func renderReturned(ok rendered: Bool, tookMs: Double) {
lock.lock() lock.lock()
if rendered { if rendered {
@@ -361,23 +388,34 @@ private final class PresentDebugStats: @unchecked Sendable {
let latches = latchMs.sorted() let latches = latchMs.sorted()
let latchP50 = latches.isEmpty ? 0 : latches[latches.count / 2] let latchP50 = latches.isEmpty ? 0 : latches[latches.count / 2]
let latchMax = latches.last ?? 0 let latchMax = latches.last ?? 0
let vends = vendLeadMs.sorted()
let vendP50 = vends.isEmpty ? 0 : vends[vends.count / 2]
let vendMax = vends.last ?? 0
let inflightMax = maxInFlight let inflightMax = maxInFlight
let line = String( let line = String(
format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d noDrawable=%d " format: "pf-present decoded=%d ok=%d fail=%d empty=%d gated=%d noDrawable=%d "
+ "dropped=%d maxRenderMs=%.1f inflightMax=%d forced=%d " + "dropped=%d maxRenderMs=%.1f inflightMax=%d forced=%d "
+ "glassDeltaMs p50=%.2f max=%.2f n=%d latchMs p50=%.2f max=%.2f", + "glassDeltaMs p50=%.2f max=%.2f n=%d latchMs p50=%.2f max=%.2f "
+ "vendLeadMs p50=%.2f max=%.2f",
decoded, ok, failed, empty, gated, noDrawable, dropped, maxRenderMs, inflightMax, decoded, ok, failed, empty, gated, noDrawable, dropped, maxRenderMs, inflightMax,
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax) gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax,
vendP50, vendMax)
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0 ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
maxRenderMs = 0 maxRenderMs = 0
maxInFlight = inFlight // the window peak restarts from the live depth maxInFlight = inFlight // the window peak restarts from the live depth
glassDeltasMs.removeAll(keepingCapacity: true) glassDeltasMs.removeAll(keepingCapacity: true)
latchMs.removeAll(keepingCapacity: true) latchMs.removeAll(keepingCapacity: true)
vendLeadMs.removeAll(keepingCapacity: true)
lock.unlock() lock.unlock()
// Console.app first (the on-device readout see presentLog); stdout only under the env
// lever (the CLI client's capture channel).
presentLog.info("\(line, privacy: .public)")
if presentDebug {
print(line) print(line)
fflush(stdout) // stdout is a pipe when captured flush per line or nothing shows fflush(stdout) // stdout is a pipe when captured flush per line or nothing shows
} }
} }
}
/// Bridges the VideoToolbox decode-completion callback to the core Automatic-bitrate controller's /// Bridges the VideoToolbox decode-completion callback to the core Automatic-bitrate controller's
/// decode signal. Created as a pipeline property so the decoder's `onDecoded` callback (built in /// decode signal. Created as a pipeline property so the decoder's `onDecoded` callback (built in
@@ -650,8 +688,9 @@ public final class Stage2Pipeline {
// The present half. Deadline pacing (stage-4) swaps it wholesale: a CAMetalDisplayLink // The present half. Deadline pacing (stage-4) swaps it wholesale: a CAMetalDisplayLink
// vends the drawables and its per-refresh updates co-drive the render thread see // vends the drawables and its per-refresh updates co-drive the render thread see
// startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline- // startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline-
// times every present). // times every present). Deadline sessions ALWAYS carry the stats (their pf-present line
let debugStats = presentDebug ? PresentDebugStats() : nil // streams to Console.app via presentLog the on-device pacing decomposition).
let debugStats = (presentDebug || pacing == .deadline) ? PresentDebugStats() : nil
if pacing == .deadline { if pacing == .deadline {
startDeadlinePresenter(debugStats: debugStats) startDeadlinePresenter(debugStats: debugStats)
return return
@@ -787,7 +826,7 @@ public final class Stage2Pipeline {
let linkThread = Thread { let linkThread = Thread {
let delegate = DeadlineLinkDelegate( let delegate = DeadlineLinkDelegate(
stash: stash, renderSignal: renderSignal, hint: hint) stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats)
let link = CAMetalDisplayLink(metalLayer: layer) let link = CAMetalDisplayLink(metalLayer: layer)
link.preferredFrameLatency = 1 // wake as late as fits: present latches the NEXT refresh link.preferredFrameLatency = 1 // wake as late as fits: present latches the NEXT refresh
if let range = hint.drain() { link.preferredFrameRateRange = range } if let range = hint.drain() { link.preferredFrameRateRange = range }