Merge remote-tracking branch 'origin/main' into audio/mic-latency-echo

This commit is contained in:
2026-08-01 00:47:50 +02:00
25 changed files with 683 additions and 88 deletions
+7 -2
View File
@@ -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",
@@ -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.
@@ -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<Int, Int, Int> {
// 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<Int, Int, Int> {
* 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
@@ -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
@@ -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(" · ")
}
@@ -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
@@ -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() }
@@ -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 }
+54 -16
View File
@@ -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<PartFeed> = 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<PartFeed>,
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<OutputReady>,
stats: &crate::stats::VideoStats,
in_flight: &Mutex<VecDeque<(u64, i128)>>,
queued_stamps: &mut VecDeque<(u64, i128)>,
meter: &PresentMeter,
clock_offset: i64,
tracker: &DisplayTracker,
presenter: &mut Option<Presenter>,
@@ -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 —
+21 -2
View File
@@ -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<i128> {
// 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<i128> {
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
+67 -5
View File
@@ -126,6 +126,15 @@ pub(super) struct PresentMeter {
struct PresentMeterInner {
latch_us: Vec<u64>,
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<u64>,
/// 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<u64>,
/// 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<u64>,
}
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>, 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<u64>,
codec_us: Option<u64>,
e2e_us: Option<u64>,
) {
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>, u64, Vec<u64>, Vec<u64>, Vec<u64>) {
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,
+44 -3
View File
@@ -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
+15 -4
View File
@@ -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 021 match the previous 22-double layout — 013 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,
+70
View File
@@ -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<u64>,
/// 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<u64>,
/// 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<u64>,
/// 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<u64>, 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
+17 -2
View File
@@ -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");
+15 -1
View File
@@ -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<String>,
/// 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;
}
};
+5 -4
View File
@@ -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(),
+22 -9
View File
@@ -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<usize>,
/// `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,
+99 -12
View File
@@ -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<Manifest, String> {
) -> Result<Manifest, FeedError> {
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<Vec<u8>, 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<Vec<u8>, 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]
+1
View File
@@ -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};
+8 -1
View File
@@ -83,6 +83,12 @@ pub(crate) struct UpdateStatus {
pub last_checked_unix: Option<u64>,
/// Why the last check failed, verbatim, if it did.
pub last_error: Option<String>,
/// 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<UpdateStatus> {
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),
+60 -8
View File
@@ -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<Checked>,
last_error: Option<String>,
/// 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<Manifest, String> {
fn fetch_manifest_blocking(channel: &str) -> Result<Manifest, FeedError> {
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<Manifest, String> {
}
/// 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<Checked, String> {
/// 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<Checked, FeedError> {
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<Checked, String> {
});
}
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<String>, 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<Snapshot, ForceError> {
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<Checked>,
pub last_error: Option<String>,
/// 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,
};
+2
View File
@@ -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:",
+2
View File
@@ -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:",
+14 -1
View File
@@ -162,7 +162,9 @@ export const UpdateCard: FC<{
</span>
) : (
<span className="text-sm text-muted-foreground">
{m.update_never_checked()}
{s.not_published
? m.update_none_published()
: m.update_never_checked()}
</span>
)
}
@@ -211,6 +213,17 @@ export const UpdateCard: FC<{
{m.update_stale()}
</p>
)}
{/* 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 && (
<p className="text-sm text-muted-foreground">
{m.update_not_published({ channel: s.channel })}
</p>
)}
{!inFlight && s.last_error && (
<p className="text-sm text-destructive">
{m.update_error()} {s.last_error}