diff --git a/api/openapi.json b/api/openapi.json index 2cd138f7..13ed549f 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -3495,7 +3495,7 @@ "operationId": "forceUpdateCheck", "responses": { "200": { - "description": "Refreshed update-check state (`last_error` carries a failed check)", + "description": "Refreshed update-check state (`last_error` carries a failed check; `not_published` an empty channel, which is not one)", "content": { "application/json": { "schema": { @@ -7372,7 +7372,8 @@ "apply", "channel_hint", "check_disabled", - "available" + "available", + "not_published" ], "properties": { "apply": { @@ -7452,6 +7453,10 @@ } ] }, + "not_published": { + "type": "boolean", + "description": "The check reached the feed and found this channel has **no release published yet** —\nan expected state (a channel nobody has announced to answers with a 404), not a\nfailure. Mutually exclusive with `last_error`, so a UI can say \"nothing published yet\"\ninstead of painting an empty feed as a broken host. Never set once a manifest has been\nseen for this channel: a feed that loses a document it used to serve stays an error." + }, "opt_in_hint": { "type": [ "string", diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt index 31e89b29..100a8e27 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/HostConnect.kt @@ -2,6 +2,7 @@ package io.unom.punktfunk import android.content.Context import android.os.Build +import android.util.Log import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.VideoDecoders @@ -45,18 +46,40 @@ suspend fun connectToHost( // Transport-level half of "Low-latency mode (experimental)" (DSCP marking on the media // sockets) — must be applied before connect, since sockets are tagged at creation. NativeBridge.nativeSetLowLatencyMode(settings.lowLatencyMode) + val multiSlice = VideoDecoders.multiSliceTolerant() + val partialFrame = VideoDecoders.partialFrameCapable() + // Slice-progressive delivery: decoder truth AND the async decode loop — the legacy + // sync loop feeds whole AUs only, so parts must never arrive when it is selected. + val frameParts = settings.lowLatencyMode && partialFrame + val codecBits = VideoDecoders.decodableCodecBits() + // Automatic codec (P5, measured NP3 ↔ RTX 4090): AV1 beat HEVC by ~1.2 ms end-to-end at + // identical conditions, so under "Automatic" this device prefers AV1 when it hardware- + // decodes it (the AV1 bit is only ever set for a real, non-blocked hardware decoder) AND + // it lacks FEATURE_PartialFrame — a partial-frame device keeps HEVC, whose slice overlap + // AV1 cannot ride (AV1 has no slices; the host's chunked poll never arms). The host + // honors the preference only inside the probed shared codec set, so an AV1-less encoder + // still resolves HEVC. An explicit user choice always wins unchanged. + val preferredCodec = settings.preferredCodec().takeIf { it != 0 } + ?: if (codecBits and 4 != 0 && !partialFrame) 4 else 0 + // The connect-time capability readout (`adb logcat -s pf.caps`): the P2 slice pipeline + // is client-inert unless BOTH probes pass — this line says which decoder failed one. + Log.i( + "pf.caps", + VideoDecoders.capsReport() + + " → multiSlice=$multiSlice parts=$frameParts prefer=$preferredCodec" + + " (lowLatency=${settings.lowLatencyMode})", + ) NativeBridge.nativeConnect( host, port, w, h, hz, identity.certPem, identity.privateKeyPem, pinHex, settings.bitrateKbps, settings.compositor, gamepadPref, - hdrEnabled, VideoDecoders.multiSliceTolerant(), - // Slice-progressive delivery: decoder truth AND the async decode loop — the legacy - // sync loop feeds whole AUs only, so parts must never arrive when it is selected. - settings.lowLatencyMode && VideoDecoders.partialFrameCapable(), + hdrEnabled, multiSlice, + frameParts, settings.audioChannels, // What this device can decode (H.264|HEVC always, AV1 when a real decoder exists) + - // the user's soft codec preference — the host resolves the emitted codec from both. - VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs, + // the soft codec preference (user choice, or the Automatic AV1 rule above) — the + // host resolves the emitted codec from both. + codecBits, preferredCodec, timeoutMs, launch, // The host's approval-list / trust-store label for this device — the same // Build.MODEL convention the pairing dialogs use for nativePair. diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt index 8cb9b2c4..98ea20dc 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Settings.kt @@ -1,6 +1,7 @@ package io.unom.punktfunk import android.content.Context +import android.hardware.display.DisplayManager import android.os.Build import android.util.Log import android.view.Display @@ -331,14 +332,31 @@ class SettingsStore(context: Context) { } } +/** + * The display to probe for capability/mode queries: the context's own display when it is already + * associated with one, else the DEFAULT display via [DisplayManager]. A `punktfunk://` deep-link + * COLD start can reach the connect before the activity is attached to its display — + * `context.display` then throws, and the old `false`/1080p60 fallbacks silently downgraded the + * whole session (no HDR advertised / non-native mode) with nothing in the log. The default + * display IS the panel on phones and TVs; the activity-display distinction only matters on + * multi-display setups, where the attached path still wins whenever it is available. + */ +private fun probeDisplay(context: Context): Display? = + runCatching { context.display }.getOrNull() + ?: runCatching { + context.getSystemService(DisplayManager::class.java) + ?.getDisplay(Display.DEFAULT_DISPLAY) + }.getOrNull().also { + if (it != null) Log.i("punktfunk", "display probe: context unattached — using DEFAULT_DISPLAY") + } + /** * The device's native display mode as a landscape `(width, height, hz)` — the long edge is the - * width, since we stream a desktop. Falls back to 1920×1080@60 if the display can't be read. - * [context] must be a visual (Activity) context. + * width, since we stream a desktop. Falls back to 1920×1080@60 if no display can be read at all + * (see [probeDisplay] for the cold-start fallback that makes that a last resort). */ fun nativeDisplayMode(context: Context): Triple { - // getDisplay() throws on a non-visual context rather than returning null — guard it. - val display = runCatching { context.display }.getOrNull() ?: return Triple(1920, 1080, 60) + val display = probeDisplay(context) ?: return Triple(1920, 1080, 60) val mode = display.mode val w = mode.physicalWidth val h = mode.physicalHeight @@ -353,7 +371,12 @@ fun nativeDisplayMode(context: Context): Triple { * capability gate the Apple/Windows clients apply. */ fun displaySupportsHdr(context: Context): Boolean { - val display = runCatching { context.display }.getOrNull() ?: return false + val display = probeDisplay(context) + if (display == null) { + // Distinguishable from a real SDR verdict — a silent `false` here cost an HDR session. + Log.w("punktfunk", "display HDR probe: no display reachable — advertising SDR") + return false + } val types = buildSet { // API 34+: the sanctioned per-mode query (Display.Mode.getSupportedHdrTypes). The // deprecated Display-level hdrCapabilities can return EMPTY on Android 14+ devices diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt index 26fb5821..9f58199c 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/SettingsScreen.kt @@ -673,12 +673,22 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an // Only codecs this device can actually decode are offered — a preference the client never // advertises would be a dead setting (see [codecOptionsFor]). val av1Capable = remember { VideoDecoders.pickDecoder("video/av01") != null } + // Mirror the Automatic AV1 rule in HostConnect (hardware AV1 AND no partial-frame + // support) so the picker says what "Automatic" actually does on THIS device. + val autoPrefersAv1 = remember { + VideoDecoders.decodableCodecBits() and 4 != 0 && !VideoDecoders.partialFrameCapable() + } SettingDropdown( label = "Video codec", options = codecOptionsFor(s.codec, av1Capable), selected = s.codec, field = "codec", - caption = "A preference — the host falls back if it can't encode this one.", + caption = if (autoPrefersAv1) { + "A preference — the host falls back if it can't encode this one. " + + "Automatic prefers AV1 on this device." + } else { + "A preference — the host falls back if it can't encode this one." + }, ) { c -> update(s.copy(codec = c)) } // HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle is diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt index 1b909097..8160226d 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StatsOverlay.kt @@ -129,10 +129,30 @@ internal fun StatsOverlay( } else { "" } + // P3 decode split (s[30]/s[31]): `feed` = received→queued (hand-off + input-slot + // wait) + `codec` = queued→decoded (codec-pure) — rendered when a sample landed. + val decodeTerm = if (s.size >= 33 && (s[30] > 0 || s[31] > 0)) { + "decode ${"%.1f".format(s[15])} " + + "(feed ${"%.1f".format(s[30])} + codec ${"%.1f".format(s[31])})" + } else { + "decode ${"%.1f".format(s[15])}" + } statLine( - "= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm$presents", + "= $hostTerms + $decodeTerm$displayTerm$presents", Color.White, ) + // Metric fairness: the Apple client's HUD shaves ~2 refresh periods of OS + // pipeline floor off its shown display/end-to-end; Android shows raw. This twin + // applies the same shave so iPhone↔Android HUD numbers compare directly. + if (dispValid && hz > 0) { + val shave = 2000.0 / hz + statLine( + "≈ Apple-HUD equiv: end-to-end " + + "${"%.1f".format((s[24] - shave).coerceAtLeast(0.0))} · display " + + "${"%.1f".format((s[23] - shave).coerceAtLeast(0.0))} (−2 refresh)", + Color(0xFFA8D8B8), + ) + } } } counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) } @@ -178,12 +198,17 @@ private fun counterLine(s: DoubleArray, lostTotal: Long): String? { val fec = s[20].toLong() val frames = s[21].toLong() if (lost == 0L && skipped == 0L && fec == 0L) return null + // The overflow subset of `skipped` (s[32]): whole AUs dropped before feeding — the decoder + // fell behind. Absent (0 / old layout) the plain count keeps meaning benign pacing drops. + val overflow = if (s.size >= 33) s[32].toLong() else 0L return buildList { if (lost > 0) { val pct = 100.0 * lost / (frames + lost).coerceAtLeast(1) add("lost $lost (${"%.1f".format(pct)}%)") } - if (skipped > 0) add("skipped $skipped") + if (skipped > 0) { + add(if (overflow > 0) "skipped $skipped (⚠ $overflow overflow)" else "skipped $skipped") + } if (fec > 0) add("FEC $fec") }.joinToString(" · ") } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index d60ddb7d..43e00947 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -17,6 +17,7 @@ import android.os.Build import android.text.InputType import android.util.Log import android.view.KeyEvent +import android.view.Surface import android.view.SurfaceHolder import android.view.SurfaceView import android.view.View @@ -679,7 +680,24 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { } } - override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {} + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + // Re-assert the frame-rate vote: a buffer-geometry change can reset + // the surface's frame-rate setting on some OEM builds, silently + // dropping the 120 Hz pin mid-stream. Mirrors the native hint's + // policy (FIXED_SOURCE; ALWAYS only on the TV low-latency path — + // phones stay seamless so a re-hint can never force a mode flicker). + if (streamHz > 0) runCatching { + holder.surface.setFrameRate( + streamHz.toFloat(), + Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE, + if (isTv && lowLatencyMode) { + Surface.CHANGE_FRAME_RATE_ALWAYS + } else { + Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS + }, + ) + } + } override fun surfaceDestroyed(holder: SurfaceHolder) { // Surface gone (backgrounding, or on the way out). Stop the threads that diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/TouchInput.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/TouchInput.kt index 5adfe0ff..a6db199d 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/TouchInput.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/TouchInput.kt @@ -105,8 +105,20 @@ internal suspend fun PointerInputScope.streamTouchPassthrough(handle: Long, styl NativeBridge.nativeSendTouch(handle, it, 2, 0, 0, sw, sh) } c.positionChanged() -> - ids[c.id]?.let { - NativeBridge.nativeSendTouch(handle, it, 1, x, y, sw, sh) + ids[c.id]?.let { id -> + // Batched MotionEvents coalesce intermediate points into the + // historical list — forward them in order so a fast swipe keeps + // its real curvature on the host (usually empty during a stream: + // unbuffered dispatch is requested, so this costs nothing). + for (hs in c.historical) { + NativeBridge.nativeSendTouch( + handle, id, 1, + hs.position.x.roundToInt().coerceIn(0, sw - 1), + hs.position.y.roundToInt().coerceIn(0, sh - 1), + sw, sh, + ) + } + NativeBridge.nativeSendTouch(handle, id, 1, x, y, sw, sh) } } c.consume() @@ -289,7 +301,10 @@ internal suspend fun PointerInputScope.streamTouchInput( accY -= outY } } else { - moveAbs(p.position.x, p.position.y) // direct: cursor follows the finger + // Direct: cursor follows the finger — historical points first (batched + // MotionEvent samples), so the host cursor traces the finger's real path. + for (hs in p.historical) moveAbs(hs.position.x, hs.position.y) + moveAbs(p.position.x, p.position.y) } } ev.changes.forEach { it.consume() } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/VideoDecoders.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/VideoDecoders.kt index bbf591c6..2e4124c0 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/VideoDecoders.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/VideoDecoders.kt @@ -100,6 +100,34 @@ object VideoDecoders { } } + /** + * One-line per-mime probe readout for the connect log (`adb logcat -s pf.caps`): which + * decoder each advertised mime resolves to and whether it declares `FEATURE_PartialFrame` — + * the P2 slice-pipeline gate that is otherwise invisible until a stream behaves differently. + */ + fun capsReport(): String { + val mimes = buildList { + add("video/avc") + add("video/hevc") + if (decodableCodecBits() and 4 != 0) add("video/av01") + } + val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos } + .getOrNull() ?: return "codec list unavailable" + return mimes.joinToString(" ") { mime -> + val pick = pickDecoder(mime)?.name + val partial = pick?.let { p -> + infos.firstOrNull { it.name == p }?.let { info -> + runCatching { + info.getCapabilitiesForType(mime) + .isFeatureSupported(CodecCapabilities.FEATURE_PartialFrame) + }.getOrNull() + } + } + "${mime.removePrefix("video/")}=${pick ?: "platform-default"}" + + " partialFrame=${partial ?: "?"}" + } + } + fun pickDecoder(mime: String): DecoderChoice? { if (mime.isEmpty()) return null val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos } diff --git a/clients/android/native/src/decode/async_loop.rs b/clients/android/native/src/decode/async_loop.rs index fc4dcac7..68ca50e1 100644 --- a/clients/android/native/src/decode/async_loop.rs +++ b/clients/android/native/src/decode/async_loop.rs @@ -16,7 +16,7 @@ use std::time::{Duration, Instant}; use super::display::{ apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker, }; -use super::latency::{note_decoded_pts, now_realtime_ns, take_flags}; +use super::latency::{note_decoded_pts, now_realtime_ns, take_flags, take_stamp}; use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter}; use super::setup::{ android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime, @@ -280,6 +280,10 @@ pub(super) fn run_async( let mut oversized_dropped: u64 = 0; // Slice-progressive continuity ledger (see `PartFeed`). let mut part_open: Option = None; + // Queued-instant stamps (pts → realtime ns at the AU's LAST piece entering the codec) — the + // P3 decode-split ledger: `feed` = received→queued, `codec` = queued→decoded. Always on + // (one vDSO clock read per AU); consumed by `present_ready`. + let mut queued_stamps: VecDeque<(u64, i128)> = VecDeque::new(); // Freeze-until-reanchor gate (see the sync loop for the rationale). Armed on a frame-index gap // (the feeder's Au verdict), a parked-AU overflow drop, a dropped-count climb, or a recoverable // codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to @@ -349,7 +353,7 @@ pub(super) fn run_async( p.on_vsync(); } } - stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too + stats.note_skipped_overflow(aus_dropped); // parked-AU overflow: skips, flagged as such if fmt_dirty { apply_hdr_dataspace(&codec, &window, &mut applied_ds); } @@ -361,6 +365,7 @@ pub(super) fn run_async( &mut fed, &mut oversized_dropped, &mut part_open, + &mut queued_stamps, &mut gate, ); let had_output = !ready.is_empty(); @@ -372,6 +377,8 @@ pub(super) fn run_async( &mut ready, &stats, &in_flight, + &mut queued_stamps, + &meter, clock_offset.load(Ordering::Relaxed), &tracker, &mut presenter, @@ -762,6 +769,7 @@ pub(super) struct PartFeed { /// [`BUFFER_FLAG_PARTIAL_FRAME`] except the AU's last, all at the AU's pts. `part_open` is the /// continuity ledger — any break (gap, orphan, oversize) abandons the AU per [`PartFeed::pts_us`]'s /// close contract and re-syncs at the next `first`. +#[allow(clippy::too_many_arguments)] // one call site; the split ledger threads through like the gate fn feed_ready( codec: &MediaCodec, client: &NativeClient, @@ -770,6 +778,7 @@ fn feed_ready( fed: &mut u64, oversized_dropped: &mut u64, part_open: &mut Option, + queued_stamps: &mut VecDeque<(u64, i128)>, gate: &mut ReanchorGate, ) { while !pending_aus.is_empty() && !free_inputs.is_empty() { @@ -859,9 +868,15 @@ fn feed_ready( } } else { // `fed` counts ACCESS UNITS toward the HUD's fed/decoded balance — the closing - // piece (or a whole AU) bumps it. + // piece (or a whole AU) bumps it. The queued stamp marks the same instant (the AU + // is fully in the codec's hands): the P3 decode split measures `codec` from here, + // so a slice-progressive head start shows up as codec-pure shrink. if last { *fed += 1; + queued_stamps.push_back((pts_us, now_realtime_ns())); + if queued_stamps.len() > IN_FLIGHT_CAP { + queued_stamps.pop_front(); // stale — codec never echoed it back + } } *part_open = if last { None @@ -876,7 +891,8 @@ fn feed_ready( } } -/// Route the ready outputs toward glass. With the timeline presenter (default): fold each output +/// Route the ready outputs toward glass, recording each one's decode-split + e2e first. With the +/// timeline presenter (default): fold each output /// through the re-anchor gate in pts order, hand the approved ones to the presenter's store /// (newest-wins / smoothing FIFO — the actual release happens in `Presenter::pump`, budgeted and /// timeline-timed), and release withheld concealment unrendered. Legacy (`arrival` sysprop): @@ -892,6 +908,8 @@ fn present_ready( ready: &mut Vec, stats: &crate::stats::VideoStats, in_flight: &Mutex>, + queued_stamps: &mut VecDeque<(u64, i128)>, + meter: &PresentMeter, clock_offset: i64, tracker: &DisplayTracker, presenter: &mut Option, @@ -903,22 +921,42 @@ fn present_ready( if ready.is_empty() { return; } - // Pair each output's decode stage (feeds the ABR decode signal always; the HUD histogram only - // while visible) — both consume the receipt map, so enter for either. - if stats.enabled() || measure_decode { + // Pair each output's decode stage (the ABR decode signal + the HUD histogram consume the + // receipt map; the P3 split's codec-pure half needs only the queued stamp, so it records + // even with both off — that keeps the 1 Hz pf.present mirror HUD-off readable). + { + let want_stage = stats.enabled() || measure_decode; let mut g = in_flight .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); for o in ready.iter() { - note_decoded_pts( - client, - measure_decode, - stats, - &mut g, - clock_offset, - o.pts_us, - o.decoded_ns, - ); + let received_ns = if want_stage { + note_decoded_pts( + client, + measure_decode, + stats, + &mut g, + clock_offset, + o.pts_us, + o.decoded_ns, + ) + } else { + None + }; + let queued = take_stamp(queued_stamps, o.pts_us); + let codec_us = queued.map(|q| ((o.decoded_ns - q).max(0) / 1000) as u64); + let feed_us = match (queued, received_ns) { + (Some(q), Some(r)) => Some(((q - r).max(0) / 1000) as u64), + _ => None, + }; + // Always-on e2e for the 1 Hz pf.present mirror (same formula + clamp as the HUD's + // capture→decoded headline in `note_decoded_pts`). + let e2e_ns = o.decoded_ns + clock_offset as i128 - o.pts_us as i128 * 1000; + let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64); + meter.note_decode(feed_us, codec_us, e2e_us); + if let Some(c) = codec_us { + stats.note_decode_split(feed_us, c); + } } } // Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards — diff --git a/clients/android/native/src/decode/latency.rs b/clients/android/native/src/decode/latency.rs index f8e57f1a..10b96118 100644 --- a/clients/android/native/src/decode/latency.rs +++ b/clients/android/native/src/decode/latency.rs @@ -20,7 +20,8 @@ pub(super) fn now_realtime_ns() -> i128 { /// entries older than it are evicted (decode order == input order here — low-latency, no /// B-frames — so anything before it was dropped inside the codec or stamped before a flush). /// `decoded_ns` is the availability instant: the dequeue (sync loop) or the output callback's -/// stamp (async loop). +/// stamp (async loop). Returns the receipt stamp it paired (if any) so the caller can split the +/// `decode` stage further (feed wait vs codec-pure) without re-walking the map. pub(super) fn note_decoded_pts( client: &NativeClient, measure_decode: bool, @@ -29,7 +30,7 @@ pub(super) fn note_decoded_pts( clock_offset: i64, pts_us: u64, decoded_ns: i128, -) { +) -> Option { // Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go. let mut received_ns = None; while let Some(&(p, r)) = in_flight.front() { @@ -61,6 +62,24 @@ pub(super) fn note_decoded_pts( let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64); stats.note_decoded(e2e_us, decode_us); } + received_ns +} + +/// The queued-instant stamp for a decoded output, keyed by the echoed `presentationTimeUs` — the +/// same monotonic evict-as-you-go pairing as [`take_flags`], over an `(pts_us, realtime_ns)` map +/// (the feed side stamps each AU as its last piece enters the codec). A miss returns `None` — +/// the split is simply not recorded for that frame. +pub(super) fn take_stamp(map: &mut VecDeque<(u64, i128)>, pts_us: u64) -> Option { + while let Some(&(p, t)) = map.front() { + if p > pts_us { + break; // future frame — leave it for its own output buffer + } + map.pop_front(); + if p == pts_us { + return Some(t); + } + } + None } /// The AU `user_flags` for a decoded output, keyed by the echoed `presentationTimeUs`. Recovery diff --git a/clients/android/native/src/decode/presenter.rs b/clients/android/native/src/decode/presenter.rs index 025947f2..1eda9928 100644 --- a/clients/android/native/src/decode/presenter.rs +++ b/clients/android/native/src/decode/presenter.rs @@ -126,6 +126,15 @@ pub(super) struct PresentMeter { struct PresentMeterInner { latch_us: Vec, displays: u64, + /// The `decode` stage's feed split, received→queued µs (P3 science: hand-off + input-slot + /// wait). Empty when no receipt stamp matched (HUD off and ABR not measuring decode). + feed_us: Vec, + /// The codec-pure half, queued→decoded µs, measured from the AU's LAST piece — always on, + /// so a HUD-off logcat A/B still reads the decoder's own time. + codec_us: Vec, + /// Capture→decoded end-to-end µs (skew-corrected, clamped) — always on for the same reason: + /// the wireless A/B's headline without having to reach the on-screen HUD. + e2e_us: Vec, } impl PresentMeter { @@ -134,6 +143,9 @@ impl PresentMeter { inner: Mutex::new(PresentMeterInner { latch_us: Vec::with_capacity(256), displays: 0, + feed_us: Vec::with_capacity(256), + codec_us: Vec::with_capacity(256), + e2e_us: Vec::with_capacity(256), }), } } @@ -152,14 +164,51 @@ impl PresentMeter { } } - fn drain(&self) -> (Vec, u64) { + /// One decoded frame's always-on measurements: the `decode`-stage split (feed = + /// received→queued when a receipt stamp matched; codec = queued→decoded when the queued + /// stamp did) and the capture→decoded end-to-end, µs. Decode thread; poison-proof. + pub(super) fn note_decode( + &self, + feed_us: Option, + codec_us: Option, + e2e_us: Option, + ) { + let mut g = self + .inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(f) = feed_us { + if g.feed_us.len() < 4096 { + g.feed_us.push(f); + } + } + if let Some(c) = codec_us { + if g.codec_us.len() < 4096 { + g.codec_us.push(c); + } + } + if let Some(e) = e2e_us { + if g.e2e_us.len() < 4096 { + g.e2e_us.push(e); + } + } + } + + #[allow(clippy::type_complexity)] // one caller unpacks it in place; a struct would be noise + fn drain(&self) -> (Vec, u64, Vec, Vec, Vec) { let mut g = self .inner .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let displays = g.displays; g.displays = 0; - (std::mem::take(&mut g.latch_us), displays) + ( + std::mem::take(&mut g.latch_us), + displays, + std::mem::take(&mut g.feed_us), + std::mem::take(&mut g.codec_us), + std::mem::take(&mut g.e2e_us), + ) } } @@ -386,7 +435,9 @@ impl Presenter { /// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) / /// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) / /// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) / - /// `vsync` (the measured panel period). + /// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the + /// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless + /// A/B headline) / `vsync` (the measured panel period). /// /// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period, /// coherence ‰)` when a window actually flushed — the phase-lock reporter's v2 error signal @@ -400,11 +451,14 @@ impl Presenter { return None; } self.last_flush = Instant::now(); - let (latch, displays) = meter.drain(); + let (latch, displays, feed, codec, e2e) = meter.drain(); if self.released == 0 && displays == 0 { return None; // idle stream — nothing worth a line } let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us)); + let (feed_p50, feed_max) = p50_max_ms(feed); + let (codec_p50, codec_max) = p50_max_ms(codec); + let (e2e_p50, e2e_max) = p50_max_ms(e2e); let circ = clock.and_then(|c| { punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns())) }); @@ -416,7 +470,9 @@ impl Presenter { log::info!( target: "pf.present", "released={} displays={} paced={} noBudget={} forced={} qDry={} \ - paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} circ={:.2}ms coh={} \ + paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \ + feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \ + e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \ vsyncMs={:.2} panelMs={:.2}", self.released, displays, @@ -428,6 +484,12 @@ impl Presenter { pace_max, latch_p50, latch_max, + feed_p50, + feed_max, + codec_p50, + codec_max, + e2e_p50, + e2e_max, circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0), circ.map(|(_, c)| c).unwrap_or(0), period_ms, diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index 4eb3edca..8a0ad945 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -83,6 +83,28 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSetLowLaten punktfunk_core::transport::set_dscp_default(enabled != 0); } +/// `debug.punktfunk.force_parts` = 1: arm slice-progressive parts delivery even when the +/// Kotlin `FEATURE_PartialFrame` probe said no — the rebuild-free on-glass experiment for a +/// decoder that may accept `BUFFER_FLAG_PARTIAL_FRAME` without declaring the feature (the NP3's +/// c2.qti decoders declare nothing). Android-only; everywhere else the probe verdict stands. +#[cfg(target_os = "android")] +fn force_parts_sysprop() -> bool { + let mut buf = [0u8; 92]; // PROP_VALUE_MAX + // SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe. + let n = unsafe { + libc::__system_property_get( + c"debug.punktfunk.force_parts".as_ptr(), + buf.as_mut_ptr().cast(), + ) + }; + n > 0 && std::str::from_utf8(&buf[..n as usize]).unwrap_or("").trim() == "1" +} + +#[cfg(not(target_os = "android"))] +fn force_parts_sysprop() -> bool { + false +} + /// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps, /// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch, /// deviceName): Long`. @@ -155,6 +177,24 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo } else { Some((cert, key)) }; + // Slice-progressive parts, by decoder truth (Kotlin's FEATURE_PartialFrame probe) — with a + // sysprop escape hatch for the on-glass science question the probe can't answer: does the + // decoder ACTUALLY choke on BUFFER_FLAG_PARTIAL_FRAME input, or does it merely not declare + // the feature? (`adb shell setprop debug.punktfunk.force_parts 1` + stream restart; a codec + // that can't take parts errors recoverably and the reanchor gate + keyframe path recovers.) + let force_parts = force_parts_sysprop(); + let frame_parts = frame_parts_ok != 0 || force_parts; + // The connect-time capability readout (`adb logcat -s pf.caps`): the P2 slice pipeline is + // inert client-side unless BOTH probes pass — this line is the one place that says which. + log::info!( + target: "pf.caps", + "decoder caps: multi_slice={} partial_frame={}{} hdr={} codec_bits={:#x}", + multi_slice_ok != 0, + frame_parts_ok != 0, + if force_parts { " (FORCED by sysprop)" } else { "" }, + hdr_enabled != 0, + video_codecs, + ); let pin: Option<[u8; 32]> = if pin_hex.is_empty() { None } else { @@ -230,9 +270,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo // should say what the client does). punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK, // Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on - // every decoder this device would use): AU prefixes then arrive as `Frame::part` - // pieces and the decode loop feeds them with BUFFER_FLAG_PARTIAL_FRAME. - frame_parts_ok != 0, + // every decoder this device would use; `debug.punktfunk.force_parts` overrides for the + // on-glass experiment): AU prefixes then arrive as `Frame::part` pieces and the decode + // loop feeds them with BUFFER_FLAG_PARTIAL_FRAME. + frame_parts, launch, // a store-qualified library id to boot into a game, or None for the desktop device_name, // Kotlin's Build.MODEL — the host's approval-list / trust-store label pin, // Some → Crypto on host-fp mismatch diff --git a/clients/android/native/src/session/planes.rs b/clients/android/native/src/session/planes.rs index 6c5b3aea..41d27a17 100644 --- a/clients/android/native/src/session/planes.rs +++ b/clients/android/native/src/session/planes.rs @@ -177,11 +177,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo( } /// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD -/// (unified stats spec, `design/stats-unification.md`). Returns 26 doubles +/// (unified stats spec, `design/stats-unification.md`). Returns 33 doubles /// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost, /// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, /// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms, -/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive]` +/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive, +/// feedP50Ms, codecP50Ms, skippedOverflowWindow]` /// (the flags are 1.0/0.0; indexes 0–21 match the previous 22-double layout — 0–13 the original /// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15 /// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17 @@ -198,7 +199,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo( /// term — `pace` = decoded→release (store + glass budget) p50 at 26, `latch` = /// release→displayed (SurfaceFlinger) p50 at 27, the window's on-glass confirm count at 28 /// (`presents` vs `fps` is the presenter-health pair), and 29 = 1.0 while the timeline presenter -/// is active this session), or `null` when no decode thread is running. +/// is active this session; 30/31 are the `decode` stage's split p50s — `feed` = +/// received→queued (hand-off + input-slot wait) at 30 and `codec` = queued→decoded (codec-pure, +/// from the AU's last piece) at 31, both 0.0 when no sample landed (sync loop); 32 is the +/// parked-AU overflow subset of the window's `skipped` at 19 (decoder fell behind, vs benign +/// newest-wins pacing)), or `null` when no decode thread is running. /// Poll ~1 Hz from the UI; each call /// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on /// the host build too (Kotlin only ever calls it on device). @@ -222,7 +227,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats( .drain(h.client.frames_dropped(), h.client.fec_recovered_shards()); let mode = h.client.mode(); let color = h.client.color; - let buf: [f64; 30] = [ + let buf: [f64; 33] = [ snap.fps, snap.mbps, snap.e2e_p50_ms, @@ -270,6 +275,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats( snap.latch_p50_ms, snap.presents as f64, if h.stats.presenter_active() { 1.0 } else { 0.0 }, + // The `decode` stage's split (P3 science): feed = received→queued (hand-off + + // input-slot wait), codec = queued→decoded (codec-pure) — and the parked-AU + // overflow subset of `skipped` (decoder-health vs benign pacing drops). + snap.feed_p50_ms, + snap.codec_p50_ms, + snap.skipped_overflow as f64, ]; let arr = match env.new_double_array(buf.len() as jsize) { Ok(a) => a, diff --git a/clients/android/native/src/stats.rs b/clients/android/native/src/stats.rs index f89a9ba0..0c36fc22 100644 --- a/clients/android/native/src/stats.rs +++ b/clients/android/native/src/stats.rs @@ -76,6 +76,12 @@ struct Inner { /// The other half of the split, release→displayed (SurfaceFlinger's latch + scanout), µs — /// from the `OnFrameRendered` render timestamps. `pace + latch ≈ display` per frame. latch_us: Vec, + /// The `decode` stage's feed split, received→queued (hand-off + input-slot wait), µs. Empty + /// when no receipt stamp matched (HUD off and ABR not measuring decode). + feed_us: Vec, + /// The other half, queued→decoded (codec-pure: the decoder's own time on the AU, measured + /// from its LAST piece so a slice-progressive head start shows up as a shrink here), µs. + codec_us: Vec, /// Frames confirmed on glass this window (`OnFrameRendered` callbacks) — the `presents`-vs- /// `fps` health pair: presents ≪ fps means the presenter is dropping/serializing; an fps /// deficit is upstream. @@ -83,6 +89,10 @@ struct Inner { /// Client-side newest-wins/pacing drops this window (decoded frames released without /// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter. skipped: u64, + /// The subset of `skipped` that was parked-AU OVERFLOW (the decoder fell behind and whole + /// AUs were dropped before feeding) — a decoder-health signal, vs the benign newest-wins + /// pacing majority. Always ≤ `skipped`. + skipped_overflow: u64, /// Baselines for windowing the session-cumulative connector counters: the unrecoverable-drop /// and FEC-recovered totals as of the last drain (or the enable that opened the window), so /// each snapshot reports only THIS window's `lost` / `FEC` (spec line 4). @@ -119,6 +129,11 @@ pub struct Snapshot { /// path / no render callbacks). pub pace_p50_ms: f64, pub latch_p50_ms: f64, + /// The `decode` stage's split p50s (ms): `feed` = received→queued (hand-off + input-slot + /// wait), `codec` = queued→decoded (codec-pure, from the AU's last piece). 0.0 when no + /// sample landed (sync loop / no receipt stamps). + pub feed_p50_ms: f64, + pub codec_p50_ms: f64, /// Frames confirmed on glass this window (`OnFrameRendered` callbacks). pub presents: u64, /// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window @@ -135,6 +150,8 @@ pub struct Snapshot { pub lost: u64, /// Client-side newest-wins/pacing drops this window (spec `skipped`). pub skipped: u64, + /// The parked-AU overflow subset of `skipped` (decoder fell behind; ≤ `skipped`). + pub skipped_overflow: u64, /// FEC shards recovered this window (spec `FEC`, windowed from the cumulative counter). pub fec: u64, } @@ -167,8 +184,11 @@ impl VideoStats { e2e_disp_us: Vec::with_capacity(256), pace_us: Vec::with_capacity(256), latch_us: Vec::with_capacity(256), + feed_us: Vec::with_capacity(256), + codec_us: Vec::with_capacity(256), presents: 0, skipped: 0, + skipped_overflow: 0, last_dropped_total: 0, last_fec_total: 0, skew_corrected: false, @@ -219,8 +239,11 @@ impl VideoStats { g.e2e_disp_us.clear(); g.pace_us.clear(); g.latch_us.clear(); + g.feed_us.clear(); + g.codec_us.clear(); g.presents = 0; g.skipped = 0; + g.skipped_overflow = 0; g.last_dropped_total = dropped_total; g.last_fec_total = fec_total; } @@ -314,6 +337,45 @@ impl VideoStats { g.skipped += n; } + /// Record parked-AU OVERFLOW drops (whole AUs dropped before feeding — the decoder fell + /// behind). Counts into `skipped` too, plus the overflow-only counter, so the HUD can tell + /// benign newest-wins pacing from a decoder that can't keep up. + // Driven only by the android-only decode thread; unreferenced on the host build — expected. + #[cfg_attr(not(target_os = "android"), allow(dead_code))] + pub fn note_skipped_overflow(&self, n: u64) { + if n == 0 || !self.enabled.load(Ordering::Relaxed) { + return; // HUD hidden — skip the lock + } + // Poison-proof for the same reason as `note_received`. + let mut g = self + .inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + g.skipped += n; + g.skipped_overflow += n; + } + + /// Record one decoded frame's `decode`-stage split: `feed` = received→queued (hand-off + + /// input-slot wait; absent when no receipt stamp matched) and `codec` = queued→decoded + /// (codec-pure, measured from the AU's LAST piece — a slice-progressive head start shows + /// as a shrink here), both µs. + // Driven only by the android-only decode thread; unreferenced on the host build — expected. + #[cfg_attr(not(target_os = "android"), allow(dead_code))] + pub fn note_decode_split(&self, feed_us: Option, codec_us: u64) { + if !self.enabled.load(Ordering::Relaxed) { + return; // HUD hidden — skip the lock + } + // Poison-proof for the same reason as `note_received`. + let mut g = self + .inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(f) = feed_us { + g.feed_us.push(f); + } + g.codec_us.push(codec_us); + } + /// Record one decoded output frame: its capture→decoded `end-to-end` sample and its /// received→decoded `decode` stage sample (either may be absent — e.g. the receipt stamp for /// this pts predates the HUD being shown). @@ -408,6 +470,8 @@ impl VideoStats { g.e2e_disp_us.sort_unstable(); g.pace_us.sort_unstable(); g.latch_us.sort_unstable(); + g.feed_us.sort_unstable(); + g.codec_us.sort_unstable(); let snap = Snapshot { fps, mbps, @@ -421,6 +485,8 @@ impl VideoStats { disp_valid: !g.e2e_disp_us.is_empty(), pace_p50_ms: pctl_ms(&g.pace_us, 0.50), latch_p50_ms: pctl_ms(&g.latch_us, 0.50), + feed_p50_ms: pctl_ms(&g.feed_us, 0.50), + codec_p50_ms: pctl_ms(&g.codec_us, 0.50), presents: g.presents, host_p50_ms: pctl_ms(&g.host_us, 0.50), net_p50_ms: pctl_ms(&g.net_us, 0.50), @@ -429,6 +495,7 @@ impl VideoStats { frames: g.frames, lost: dropped_total.saturating_sub(g.last_dropped_total), skipped: g.skipped, + skipped_overflow: g.skipped_overflow, fec: fec_total.saturating_sub(g.last_fec_total), }; g.window_start = Instant::now(); @@ -443,8 +510,11 @@ impl VideoStats { g.e2e_disp_us.clear(); g.pace_us.clear(); g.latch_us.clear(); + g.feed_us.clear(); + g.codec_us.clear(); g.presents = 0; g.skipped = 0; + g.skipped_overflow = 0; g.last_dropped_total = dropped_total; g.last_fec_total = fec_total; snap diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index 97fddc09..f4729d00 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -480,8 +480,23 @@ fn headless_check_update() -> glib::ExitCode { "installed {} ({}, {})", status.current, status.kind, status.channel ); - println!("available {}", status.latest); - if let Some(err) = &status.error { + // `latest` falls back to `current` when the check couldn't run — printing that as + // "available" would read as a confirmed answer we don't have. + if status.error.is_some() { + println!("available unknown"); + } else { + println!("available {}", status.latest); + } + if status.not_published { + // Says what it is, in words, instead of a raw HTTP status. The exit code still + // reports "could not tell" (see the doc comment above): an empty channel is the + // absence of evidence that this build is current, and a mistyped + // PUNKTFUNK_UPDATE_FEED is indistinguishable from one out here. + println!( + "update nothing published on the {} channel yet", + status.channel + ); + } else if let Some(err) = &status.error { eprintln!("check-update: {err}"); } else if status.update_available { println!("update yes"); diff --git a/crates/pf-client-core/src/update.rs b/crates/pf-client-core/src/update.rs index 5d788087..b33d9cb3 100644 --- a/crates/pf-client-core/src/update.rs +++ b/crates/pf-client-core/src/update.rs @@ -99,6 +99,18 @@ pub struct Status { /// Why the check couldn't complete. `update_available` is always false when set. #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, + /// The feed answered, but this channel has **no release published yet** — an expected + /// state rather than a malfunction, so a caller can say so plainly instead of showing a + /// raw "HTTP 404". + /// + /// Deliberately NOT symmetric with the host's `UpdateStatus`, which clears `last_error` + /// for this case: there the consumer is a human reading a console, and a red "last check + /// failed" on an empty feed is the bug being fixed. Here the consumer is a shell script + /// reading an exit code, so `error` stays set and `--check-update` keeps returning 1. + /// An empty channel is not evidence that this build is current, and a mistyped + /// `PUNKTFUNK_UPDATE_FEED` is indistinguishable from one out here. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub not_published: bool, } /// The Ed25519 keys trusted for update manifests — pinned once in [`pf_update_check`] so the @@ -302,6 +314,7 @@ pub fn check(current: &str) -> Status { opt_in_hint: opt_in_would_help(kind, caps).then(opt_in_hint), notes_url: String::new(), error: None, + not_published: false, }; let (apply, applier) = apply_route(kind, caps); status.apply = apply; @@ -320,7 +333,8 @@ pub fn check(current: &str) -> Status { ) { Ok(m) => m, Err(e) => { - status.error = Some(e); + status.not_published = e.is_not_published(); + status.error = Some(e.to_string()); return status; } }; diff --git a/crates/pf-encode/src/enc/nvenc_core.rs b/crates/pf-encode/src/enc/nvenc_core.rs index 77abf136..b591f754 100644 --- a/crates/pf-encode/src/enc/nvenc_core.rs +++ b/crates/pf-encode/src/enc/nvenc_core.rs @@ -117,10 +117,11 @@ pub(super) fn resolve_split_mode(bit_depth: u8, pixel_rate: u64) -> u32 { /// deserves a `warn`, a default being tuned an `info`. Callers LATCH this once next to their /// resolved subframe state (an env re-read at reconfigure would violate the "open and /// reconfigure present identical init params" invariant). -/// Linux-cfg'd like its ONLY caller (the `nvenc_cuda` query_caps latch) — Windows sessions have -/// `subframe == forced` by construction (env opt-in only) and never consult this; without the -/// cfg it is dead code on every Windows leg (item-level dead_code, the recurring trap). -#[cfg(target_os = "linux")] +/// Both direct-SDK backends latch it now: Linux at the `nvenc_cuda` query_caps latch, Windows at +/// session init since sub-frame defaults on there too (it used to be env opt-in only, so +/// `subframe == forced` held by construction and the item was Linux-cfg'd to avoid being dead +/// code on the Windows leg — the recurring item-level `dead_code` trap). +#[cfg(any(target_os = "linux", windows))] pub(super) fn subframe_env_forced() -> bool { matches!( std::env::var("PUNKTFUNK_NVENC_SUBFRAME").as_deref(), diff --git a/crates/pf-encode/src/enc/windows/nvenc.rs b/crates/pf-encode/src/enc/windows/nvenc.rs index cc9cec1c..c960080b 100644 --- a/crates/pf-encode/src/enc/windows/nvenc.rs +++ b/crates/pf-encode/src/enc/windows/nvenc.rs @@ -45,7 +45,7 @@ use super::nvenc_core::{ apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery, resolve_slices, resolve_split_mode, resolve_split_subframe, resolve_subframe, store_ceiling, - CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan, + subframe_env_forced, CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan, }; use super::nvenc_status; use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps}; @@ -588,6 +588,10 @@ pub struct NvencD3d11Encoder { input_ring_depth: Option, /// `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` from the caps probe — gates the async retrieve mode. async_supported: bool, + /// `NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK` from the caps probe — gates the DEFAULT-on + /// sub-frame readback (the Linux backend's rule since its Phase 3; Windows joined after the + /// 2026-07-31 on-glass A/B), so a GPU without it never has sub-frame forced by default. + subframe_cap: bool, /// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per /// in-flight encode. The fourth field tags the first frame encoded after a successful /// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) — the clean re-anchor P-frame the @@ -748,6 +752,7 @@ impl NvencD3d11Encoder { async_rt: None, input_ring_depth: None, async_supported: false, + subframe_cap: false, pending: VecDeque::new(), frame_idx: 0, force_kf: false, @@ -922,6 +927,7 @@ impl NvencD3d11Encoder { nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE, ); let async_enc = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT); + let subframe = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK); let _ = (api().destroy_encoder)(enc); // Reject an over-range mode with a clear message instead of an opaque InvalidParam. @@ -955,10 +961,12 @@ impl NvencD3d11Encoder { self.rfi_supported = rfi != 0; self.custom_vbv = custom_vbv != 0; self.async_supported = async_enc != 0; + self.subframe_cap = subframe != 0; tracing::info!( rfi = self.rfi_supported, custom_vbv = self.custom_vbv, async_encode = self.async_supported, + subframe_readback = self.subframe_cap, max = %format!("{wmax}x{hmax}"), ten_bit = ten_bit != 0, "NVENC capabilities probed" @@ -1152,11 +1160,14 @@ impl NvencD3d11Encoder { // VIDEO_CAP_MULTI_SLICE / Moonlight slices-per-frame client gets real slices. // `PUNKTFUNK_NVENC_SLICES` stays the operator override in both directions. self.slices = resolve_slices(self.codec, 4.min(self.max_slices)); - // Split × sub-frame arbitration (Phase 8) before the ladder/ceiling key. On Windows - // sub-frame is env-opt-in only, so resolved == forced by construction. - let subframe_req = resolve_subframe(false); + // Split × sub-frame arbitration (Phase 8) before the ladder/ceiling key. Sub-frame + // defaults ON where the GPU advertises SUBFRAME_READBACK (Linux parity; validated by + // the 2026-07-31 .173 on-glass A/B — no regression, and slice-progressive clients + // gain the encode/wire overlap); `PUNKTFUNK_NVENC_SUBFRAME` stays the tri-state + // operator escape in both directions. + let subframe_req = resolve_subframe(self.subframe_cap); let (split_mode, subframe_req) = - resolve_split_subframe(self.codec, split_mode, subframe_req, subframe_req); + resolve_split_subframe(self.codec, split_mode, subframe_req, subframe_env_forced()); // Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects // `initialize_encoder` (InvalidParam) when the bitrate exceeds the level ceiling (e.g. a // 1 Gbps request on HEVC). Strategy: try the requested rate; if the only problem is a forced @@ -1318,8 +1329,7 @@ impl NvencD3d11Encoder { self.session_async = use_async; // Sub-frame chunked poll (P2f, the Windows leg of the slice pipeline): sync // retrieve only — chunked poll is a depth-1 sync feature; the async retrieve's - // thread owns the bitstream lock. Sub-frame write itself stays env-gated - // (`PUNKTFUNK_NVENC_SUBFRAME=1`) until the Windows on-glass A/B validates it. + // thread owns the bitstream lock. self.subframe_chunks = self.slices >= 2 && subframe_req && !use_async; if self.subframe_chunks { tracing::info!( @@ -1659,8 +1669,11 @@ impl Encoder for NvencD3d11Encoder { let anchor = std::mem::take(&mut self.pending_anchor) && flags == 0; // Submit-time IDR intent: chunked poll must flag an AU's EARLY chunks before the // driver reports `pictureType` (only the finishing lock sees it). Exact under - // P-only + infinite GOP: IDRs happen only when forced. - let idr_hint = flags != 0; + // P-only + infinite GOP: IDRs happen only when forced — or on the session-opening + // frame, which NVENC emits as an IDR regardless of pic flags (the Linux twin's + // `is_idr`; without the `opening` term frame 1's early chunks went out unflagged + // and the divergence WARN fired at every session start). + let idr_hint = flags != 0 || opening; let mut pic = nv::NV_ENC_PIC_PARAMS { version: nv::NV_ENC_PIC_PARAMS_VER, inputWidth: self.width, diff --git a/crates/pf-update-check/src/feed.rs b/crates/pf-update-check/src/feed.rs index a44bd84d..002b0daa 100644 --- a/crates/pf-update-check/src/feed.rs +++ b/crates/pf-update-check/src/feed.rs @@ -19,6 +19,43 @@ pub const DEFAULT_FEED_BASE: &str = /// One fetch's wall-clock budget. const FETCH_TIMEOUT: Duration = Duration::from_secs(15); +/// Why a fetch didn't produce a manifest. +/// +/// Exactly one failure mode is an *expected* steady state: a channel nobody has published to +/// answers `manifest.json` with a 404. Collapsing that into the same string as a transport or +/// signature failure is what made an empty stable feed render as "last check failed: feed +/// returned HTTP 404" — telling operators their box is broken when the feed is merely empty. +/// Everything else stays a real failure, loudly. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FeedError { + /// This channel has no manifest at all. Note the deliberate narrowness: only a 404 on + /// `manifest.json` itself counts. A 404 on the *signature* means the manifest exists + /// without its proof — a half-published pair (the publisher's manifest-then-signature + /// window, or a botched upload), which must stay fail-closed and noisy. + NotPublished, + /// A real failure: transport, an HTTP status that isn't the empty-channel 404, the size + /// cap, or signature/schema rejection. + Failed(String), +} + +impl FeedError { + /// Is this the benign "nothing published on this channel yet" state? + pub fn is_not_published(&self) -> bool { + matches!(self, Self::NotPublished) + } +} + +impl std::fmt::Display for FeedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotPublished => f.write_str("no release has been published on this channel yet"), + Self::Failed(msg) => f.write_str(msg), + } + } +} + +impl std::error::Error for FeedError {} + /// The feed base, with a `PUNKTFUNK_UPDATE_FEED` override for tests and dev feeds. This is /// operator config (an env var on the process), never request-time input; the `https://` (or /// loopback) requirement keeps a stray value from silently downgrading the transport. @@ -36,9 +73,11 @@ pub fn fetch_manifest_blocking( channel: &str, keys: &[PublicKey], user_agent: &str, -) -> Result { +) -> Result { if keys.is_empty() { - return Err("no update key is pinned in this build".into()); + return Err(FeedError::Failed( + "no update key is pinned in this build".into(), + )); } let agent = ureq::AgentBuilder::new() .timeout(FETCH_TIMEOUT) @@ -48,29 +87,42 @@ pub fn fetch_manifest_blocking( let url = format!("{base}/{channel}/manifest.json"); let sig_url = format!("{url}.sig"); - let body = read_capped(agent.get(&url).call().map_err(fetch_err)?)?; + // Only the MANIFEST leg can report an empty channel; see [`FeedError::NotPublished`]. + let body = read_capped(agent.get(&url).call().map_err(manifest_err)?)?; let sig = read_capped(agent.get(&sig_url).call().map_err(fetch_err)?)?; - let sig_text = String::from_utf8(sig).map_err(|_| "signature file is not text".to_string())?; + let sig_text = String::from_utf8(sig) + .map_err(|_| FeedError::Failed("signature file is not text".into()))?; - manifest::verify_and_parse(&body, &sig_text, keys, channel).map_err(|e| format!("{e:#}")) + manifest::verify_and_parse(&body, &sig_text, keys, channel) + .map_err(|e| FeedError::Failed(format!("{e:#}"))) } -fn fetch_err(e: ureq::Error) -> String { +/// The manifest leg: a 404 here means the channel is empty, not broken. +fn manifest_err(e: ureq::Error) -> FeedError { match e { - ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"), - other => format!("feed fetch failed: {other}"), + ureq::Error::Status(404, _) => FeedError::NotPublished, + other => fetch_err(other), } } -fn read_capped(resp: ureq::Response) -> Result, String> { +fn fetch_err(e: ureq::Error) -> FeedError { + FeedError::Failed(match e { + ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"), + other => format!("feed fetch failed: {other}"), + }) +} + +fn read_capped(resp: ureq::Response) -> Result, FeedError> { use std::io::Read as _; let mut buf = Vec::new(); let mut reader = resp.into_reader().take(MAX_MANIFEST_BYTES as u64 + 1); reader .read_to_end(&mut buf) - .map_err(|e| format!("read failed: {e}"))?; + .map_err(|e| FeedError::Failed(format!("read failed: {e}")))?; if buf.len() > MAX_MANIFEST_BYTES { - return Err("response exceeds the manifest size cap".into()); + return Err(FeedError::Failed( + "response exceeds the manifest size cap".into(), + )); } Ok(buf) } @@ -85,7 +137,42 @@ mod tests { // licence to trust whatever the feed serves. let err = fetch_manifest_blocking("https://127.0.0.1:1", "stable", &[], "test").unwrap_err(); - assert!(err.contains("no update key"), "{err}"); + assert!(err.to_string().contains("no update key"), "{err}"); + // And it is a real failure — never the benign empty-channel state. + assert!(!err.is_not_published()); + } + + fn status(code: u16) -> ureq::Error { + ureq::Error::Status(code, ureq::Response::new(code, "status", "").unwrap()) + } + + /// The whole point of the split: an empty channel is not a broken feed. + #[test] + fn manifest_404_is_not_published_but_other_statuses_are_failures() { + assert_eq!(manifest_err(status(404)), FeedError::NotPublished); + for code in [403, 500, 502] { + let e = manifest_err(status(code)); + assert!(!e.is_not_published(), "HTTP {code} must stay a failure"); + assert!(e.to_string().contains(&code.to_string()), "{e}"); + } + } + + /// A missing SIGNATURE is a half-published pair, not an empty channel — the manifest leg + /// already answered 200. Treating it as "nothing published yet" would quietly excuse the + /// one window where a manifest exists without its proof. + #[test] + fn signature_404_stays_a_failure() { + let e = fetch_err(status(404)); + assert!(!e.is_not_published()); + assert_eq!(e.to_string(), "feed returned HTTP 404"); + } + + #[test] + fn not_published_reads_as_plain_english() { + assert_eq!( + FeedError::NotPublished.to_string(), + "no release has been published on this channel yet" + ); } #[test] diff --git a/crates/pf-update-check/src/lib.rs b/crates/pf-update-check/src/lib.rs index 000d8c12..5fdf6cfb 100644 --- a/crates/pf-update-check/src/lib.rs +++ b/crates/pf-update-check/src/lib.rs @@ -35,6 +35,7 @@ pub mod sig; pub mod version; pub use detect::{InstallKind, Product}; +pub use feed::FeedError; pub use manifest::{Manifest, MAX_MANIFEST_BYTES, SCHEMA}; pub use sig::{verify_signature, PublicKey}; pub use version::{canary_run, is_newer, triple, Channel}; diff --git a/crates/punktfunk-host/src/mgmt/update.rs b/crates/punktfunk-host/src/mgmt/update.rs index ba263af9..7b9e2d39 100644 --- a/crates/punktfunk-host/src/mgmt/update.rs +++ b/crates/punktfunk-host/src/mgmt/update.rs @@ -83,6 +83,12 @@ pub(crate) struct UpdateStatus { pub last_checked_unix: Option, /// Why the last check failed, verbatim, if it did. pub last_error: Option, + /// The check reached the feed and found this channel has **no release published yet** — + /// an expected state (a channel nobody has announced to answers with a 404), not a + /// failure. Mutually exclusive with `last_error`, so a UI can say "nothing published yet" + /// instead of painting an empty feed as a broken host. Never set once a manifest has been + /// seen for this channel: a feed that loses a document it used to serve stays an error. + pub not_published: bool, /// This install could one-click apply, but the operator hasn't opted in yet — the /// command to run (Linux: join the `punktfunk-update` group). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -142,6 +148,7 @@ fn status_from(snap: update::Snapshot) -> UpdateStatus { }), last_checked_unix: snap.checked.as_ref().map(|c| c.fetched_unix), last_error: snap.last_error, + not_published: snap.not_published, opt_in_hint: update::opt_in_hint(), job, last_result: snap.last_result.as_ref().map(|r| UpdateResultInfo { @@ -186,7 +193,7 @@ pub(crate) async fn get_update_status() -> Json { tag = "update", operation_id = "forceUpdateCheck", responses( - (status = OK, description = "Refreshed update-check state (`last_error` carries a failed check)", body = UpdateStatus), + (status = OK, description = "Refreshed update-check state (`last_error` carries a failed check; `not_published` an empty channel, which is not one)", body = UpdateStatus), (status = CONFLICT, description = "Update checks are disabled on this host", body = ApiError), (status = TOO_MANY_REQUESTS, description = "A forced check ran less than 30 s ago", body = ApiError), (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), diff --git a/crates/punktfunk-host/src/update.rs b/crates/punktfunk-host/src/update.rs index 86398fd9..095af660 100644 --- a/crates/punktfunk-host/src/update.rs +++ b/crates/punktfunk-host/src/update.rs @@ -27,7 +27,7 @@ pub(crate) use pf_update_check::manifest; pub(crate) mod windows; use manifest::Manifest; -use pf_update_check::PublicKey; +use pf_update_check::{FeedError, PublicKey}; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -141,6 +141,9 @@ pub(crate) struct Checked { struct Runtime { checked: Option, last_error: Option, + /// The channel has nothing published (and never had, for us) — an expected state, kept + /// out of `last_error` so a console never paints an empty feed as a broken host. + not_published: bool, /// Refresh in flight (status kicks at most one). refreshing: bool, /// Wall-clock guard for the forced-check rate limit. @@ -214,7 +217,7 @@ fn store_floor(path: &Path, channel: &str, serial: u64) { /// Fetch + verify the channel manifest through the shared checker. Blocking — call from a /// blocking thread. -fn fetch_manifest_blocking(channel: &str) -> Result { +fn fetch_manifest_blocking(channel: &str) -> Result { pf_update_check::feed::fetch_manifest_blocking( &pf_update_check::feed::feed_base(), channel, @@ -227,18 +230,18 @@ fn fetch_manifest_blocking(channel: &str) -> Result { } /// One full refresh: fetch, verify, enforce + raise the serial floor, update the cache, -/// announce a newly available release on the event bus. Returns the user-facing error string -/// on failure (also cached for status). -pub(crate) fn refresh_blocking() -> Result { +/// announce a newly available release on the event bus. Returns the user-facing error on +/// failure (also cached for status). +pub(crate) fn refresh_blocking() -> Result { let (kind, channel) = detect::detect(); let result = fetch_manifest_blocking(channel.as_str()).and_then(|m| { let path = state_path(); let floor = load_floor(&path, channel.as_str()); if m.serial < floor { - return Err(format!( + return Err(FeedError::Failed(format!( "manifest serial {} is older than the last accepted {} — refusing rollback", m.serial, floor - )); + ))); } store_floor(&path, channel.as_str(), m.serial); Ok(m) @@ -268,16 +271,32 @@ pub(crate) fn refresh_blocking() -> Result { }); } rt.last_error = None; + rt.not_published = false; rt.checked = Some(checked.clone()); Ok(checked) } Err(e) => { - rt.last_error = Some(e.clone()); + let (last_error, not_published) = classify_failure(&e, rt.checked.is_some()); + rt.last_error = last_error; + rt.not_published = not_published; Err(e) } } } +/// Split a failed refresh into `(last_error, not_published)` — the two are never both set. +/// +/// An empty channel is only benign while we have never seen a manifest for it. Once a check +/// has succeeded, the same 404 means the feed LOST a document it used to serve, which is a +/// regression that must stay a loud error rather than being excused as "nothing yet". +fn classify_failure(e: &FeedError, had_manifest: bool) -> (Option, bool) { + if e.is_not_published() && !had_manifest { + (None, true) + } else { + (Some(e.to_string()), false) + } +} + /// The status handler's read: current cache + errors, kicking a background refresh when the /// cache is cold and checks are enabled. pub(crate) fn snapshot_and_maybe_refresh() -> Snapshot { @@ -296,6 +315,7 @@ pub(crate) fn snapshot_and_maybe_refresh() -> Snapshot { Snapshot { checked: rt.checked.clone(), last_error: rt.last_error.clone(), + not_published: rt.not_published, job: rt.job.clone(), last_result: jobs::read_result(&jobs::result_path()), } @@ -329,6 +349,7 @@ pub(crate) async fn force_check() -> Result { Ok(Snapshot { checked: rt.checked.clone(), last_error: rt.last_error.clone(), + not_published: rt.not_published, job: rt.job.clone(), last_result: jobs::read_result(&jobs::result_path()), }) @@ -570,6 +591,8 @@ pub(crate) fn reconcile_at_boot() { pub(crate) struct Snapshot { pub checked: Option, pub last_error: Option, + /// The channel simply has no release yet — mutually exclusive with `last_error`. + pub not_published: bool, /// The live apply job, when one runs. When the process was restarted mid-apply this is /// `None` but a fresh intent still reads as in-flight — the API layer surfaces that via /// [`Snapshot::applying_from_intent`]. @@ -640,6 +663,34 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The console paints `last_error` as a failure and `not_published` as a plain sentence, + /// so the two must never arrive together — and the benign reading must not survive a + /// channel that has already served us a manifest. + #[test] + fn empty_channel_is_benign_only_until_a_manifest_has_been_seen() { + let (err, not_published) = classify_failure(&FeedError::NotPublished, false); + assert_eq!(err, None); + assert!(not_published); + + // Same 404, but the feed used to answer — that is a regression, not "nothing yet". + let (err, not_published) = classify_failure(&FeedError::NotPublished, true); + assert_eq!( + err.as_deref(), + Some("no release has been published on this channel yet") + ); + assert!(!not_published); + + // Every real failure stays an error whether or not we have a cached manifest. + for had_manifest in [false, true] { + let (err, not_published) = classify_failure( + &FeedError::Failed("feed returned HTTP 500".into()), + had_manifest, + ); + assert_eq!(err.as_deref(), Some("feed returned HTTP 500")); + assert!(!not_published); + } + } + #[test] fn pinned_keys_skip_empty_rotation_slot() { let keys = pinned_keys(); @@ -664,6 +715,7 @@ mod tests { fetched_unix: now_unix(), }), last_error: None, + not_published: false, job: None, last_result: None, }; diff --git a/web/messages/de.json b/web/messages/de.json index 3a51bf70..0f78fabe 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -543,6 +543,8 @@ "update_checking": "Prüfe…", "update_last_checked": "Zuletzt geprüft", "update_never_checked": "Noch nicht geprüft", + "update_none_published": "Noch keine veröffentlicht", + "update_not_published": "Auf dem Kanal {channel} wurde noch nichts veröffentlicht. Die Prüfung funktioniert — es gibt nur noch keine Version zum Vergleichen.", "update_stale": "Der Update-Feed hat sich seit über 45 Tagen nicht geändert — Prüfungen gelingen, aber es kommt nichts Neues an. Falls das nicht stimmen kann, prüfe die Verbindung dieses Hosts zu git.unom.io.", "update_disabled": "Update-Prüfungen sind auf diesem Host deaktiviert (PUNKTFUNK_UPDATE_CHECK=0).", "update_error": "Letzte Prüfung fehlgeschlagen:", diff --git a/web/messages/en.json b/web/messages/en.json index 6b095454..7104f83a 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -543,6 +543,8 @@ "update_checking": "Checking…", "update_last_checked": "Last checked", "update_never_checked": "Not checked yet", + "update_none_published": "None published yet", + "update_not_published": "Nothing has been published to the {channel} channel yet. The check itself is working — there's just no release to compare against.", "update_stale": "The update feed hasn't changed in over 45 days — checks succeed but nothing new arrives. If that seems wrong, check this host's connectivity to git.unom.io.", "update_disabled": "Update checks are disabled on this host (PUNKTFUNK_UPDATE_CHECK=0).", "update_error": "Last check failed:", diff --git a/web/src/sections/Host/UpdateCard.tsx b/web/src/sections/Host/UpdateCard.tsx index 0e1978ef..710ea30c 100644 --- a/web/src/sections/Host/UpdateCard.tsx +++ b/web/src/sections/Host/UpdateCard.tsx @@ -162,7 +162,9 @@ export const UpdateCard: FC<{ ) : ( - {m.update_never_checked()} + {s.not_published + ? m.update_none_published() + : m.update_never_checked()} ) } @@ -211,6 +213,17 @@ export const UpdateCard: FC<{ {m.update_stale()}

)} + {/* An empty channel is a normal state, not a fault: it looks like a + 404 down at the transport, but it means "nobody has announced a + release here yet". Rendering it in the failure style told + operators their host was broken when nothing was. The host keeps + the two apart (`not_published` is never set alongside + `last_error`), so this stays a straight either/or. */} + {!inFlight && s.not_published && ( +

+ {m.update_not_published({ channel: s.channel })} +

+ )} {!inFlight && s.last_error && (

{m.update_error()} {s.last_error}