fix(client/android): a decoder fed nothing it can decode must ask for a new anchor
android / android (push) Canceled after 33s
apple / swift (push) Canceled after 27s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 34s
ci / rust (push) Canceled after 8s
ci / rust-arm64 (push) Canceled after 3s
ci / web (push) Canceled after 2s
ci / docs-site (push) Canceled after 3s
ci / bench (push) Canceled after 2s
deb / build-publish (push) Canceled after 6s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 15s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 6s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6s

An Android TV box reported a black screen with a perfectly healthy HUD: AUs
arriving at 40 fps, ~312 bytes each. That size is all-P-frames — no IDR anywhere
in the window, and nothing asking for one. The stats being readable is the rest
of the story: the overlay is a layer over the SurfaceView in the same window, so
the panel was fine and the surface simply never received a frame.

The decode thread only starts at `surfaceCreated`, so a slow box can be handed
the stream mid-GOP. A hardware decoder does not error on references it never
had; it emits nothing at all. Under infinite GOP the host sends no further IDR
unless asked, and neither Android loop ever asked: every recovery trigger they
have keys off a drop, a gap or a decode error, and a decoder that quietly
produces nothing trips none of them. The session stayed black for its whole life.

The shared gate has this case (`on_no_output`, which pf-client-core and the Apple
client both feed) but its per-AU streak counts one-in/one-out decodes, and
MediaCodec is pipelined — "this AU produced no output" is not something these
loops can observe. A wall-clock silence window is the same signal in the shape
Android can measure: fed for 500 ms with nothing coming back arms the freeze and
requests a re-anchor keyframe, and the gate's deadline keeps re-asking until one
lands. 500 ms so it can never fire on a decoder that is merely slow to spin up.

Also log the first presented frame. The periodic tally starts at 300 rendered
frames, which is no help whatsoever on a session that renders none — its absence
is what separates "never reached glass" from "reached glass and looked wrong".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 17:57:13 +02:00
co-authored by Claude Opus 5
parent 1862010003
commit e44baf6768
3 changed files with 89 additions and 4 deletions
@@ -21,7 +21,7 @@ use super::setup::{
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
configure_low_latency, create_codec, try_set_frame_rate,
};
use super::{DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, PENDING_SPLIT_CAP};
use super::{DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, PENDING_SPLIT_CAP};
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
@@ -253,6 +253,12 @@ pub(super) fn run_async(
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
let mut work_accum_ns: i64 = 0;
let mut fatal = false;
// No-output backstop (see [`NO_OUTPUT_PATIENCE`]): the last time the decoder handed us a frame,
// and how many AUs it had been fed by then. Silence only counts while AUs are actually going in,
// so an idle stream never asks for anything. Seeded at start so a decoder that never produces a
// first frame — the missed opening IDR — is caught by the same window.
let mut last_output = Instant::now();
let mut fed_at_output: u64 = 0;
while !shutdown.load(Ordering::Relaxed) && !fatal {
// Block for the next event (idle wait — excluded from the work tally). The short timeout
@@ -344,6 +350,12 @@ pub(super) fn run_async(
h.report_actual(work_accum_ns);
}
work_accum_ns = 0;
// The one line that separates "the stream never reached glass" from "it reached glass
// and looked wrong" — the periodic tally below only starts at 300 frames, which is no
// help at all on a session that renders none.
if rendered == 1 {
log::info!("decode: first frame presented (fed={fed} discarded={discarded})");
}
if rendered > 0 && rendered % 300 == 0 {
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
}
@@ -356,7 +368,27 @@ pub(super) fn run_async(
if aus_dropped > 0 {
gate.arm(now);
}
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0)
// Fed but silent: the decoder is holding nothing it can decode — the opening IDR never
// reached it, or its reference chain is gone. Ask for a fresh one and arm the freeze, so the
// concealment it may start emitting on the way back is withheld until a clean re-anchor
// (`gate.poll` keeps re-asking on the deadline until one arrives).
let starved = !had_output
&& fed > fed_at_output
&& now.duration_since(last_output) >= NO_OUTPUT_PATIENCE;
if had_output {
last_output = now;
fed_at_output = fed;
} else if starved {
log::warn!(
"decode: no output for {} ms with {} AU(s) fed — requesting a re-anchor keyframe",
now.duration_since(last_output).as_millis(),
fed - fed_at_output
);
gate.arm(now);
last_output = now; // one request per patience window, not per iteration
fed_at_output = fed;
}
if (gate.poll(client.frames_dropped(), now) || aus_dropped > 0 || starved)
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
{
last_kf_req = Some(now);
+21
View File
@@ -41,6 +41,27 @@ const PENDING_SPLIT_CAP: usize = 256;
/// gets evicted.
const RENDERED_CAP: usize = 64;
/// How long the decoder may be FED while producing nothing before we treat it as un-anchored and
/// ask the host for a fresh IDR.
///
/// The shared gate's per-AU streak ([`punktfunk_core::reanchor::ReanchorGate::on_no_output`]) can't
/// be used verbatim here: it counts one-in/one-out decodes (the desktop clients' `LOW_DELAY`
/// libavcodec path and Apple's VideoToolbox), while MediaCodec is pipelined — inputs and outputs
/// don't pair up, so "this AU produced no output" isn't a thing this loop can observe. A wall-clock
/// silence window is the same signal in the shape Android can measure.
///
/// Why it matters: the host opens a stream with an IDR and, under infinite GOP, sends no other one
/// unless asked. Miss that one — the decode thread only starts at `surfaceCreated`, so a slow TV box
/// can be handed the stream mid-GOP — and every later AU references a picture the decoder never had.
/// A hardware decoder doesn't error on that; it simply emits nothing. Without this backstop the
/// session sat there forever: AUs arriving, a healthy HUD, and a black surface, because nothing in
/// the Android loops ever asked for the keyframe that would re-anchor it.
///
/// 500 ms because it must never fire on a decoder that is merely slow to spin up: even the pokiest
/// hardware decoder emits its first frame within a couple of frame periods, and a wedge that only
/// costs half a second before it self-heals is not a bug the user reports.
const NO_OUTPUT_PATIENCE: std::time::Duration = std::time::Duration::from_millis(500);
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
/// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
+34 -2
View File
@@ -24,7 +24,7 @@ use super::setup::{
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
configure_low_latency, create_codec, try_set_frame_rate,
};
use super::{DecodeOptions, IN_FLIGHT_CAP, PENDING_SPLIT_CAP};
use super::{DecodeOptions, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, PENDING_SPLIT_CAP};
/// The synchronous poll loop — the original decode path: the only one when low-latency mode is off,
/// and the [`USE_ASYNC_DECODE`] A/B fallback when it's on. Feeds and drains on this one thread; the
@@ -144,6 +144,12 @@ pub(super) fn run_sync(
let mut fed: u64 = 0;
let mut rendered: u64 = 0;
let mut discarded: u64 = 0;
// No-output backstop (see [`NO_OUTPUT_PATIENCE`]): when the decoder last handed us a frame, and
// how many AUs it had been fed by then. Silence only counts while AUs are going in, so an idle
// stream asks for nothing; seeded at start so a decoder that never produces a FIRST frame — the
// missed opening IDR — is caught by the same window.
let mut last_output = Instant::now();
let mut fed_at_output: u64 = 0;
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
let mut oversized_dropped: u64 = 0;
// The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input
@@ -313,6 +319,11 @@ pub(super) fn run_sync(
);
rendered += r;
discarded += d;
// The one line that separates "the stream never reached glass" from "it reached glass and
// looked wrong"; the tally above counts AUs FED, which a black session racks up happily.
if r > 0 && rendered == r {
log::info!("decode: first frame presented (fed={fed} discarded={discarded})");
}
// ADPF: attribute this iteration's feed+drain time to the frame being produced, and report
// the accumulated per-frame work once one is actually presented (r > 0). Under back-pressure
@@ -355,8 +366,29 @@ pub(super) fn run_sync(
// a decode-error trigger rarely fires — the gate arms the freeze on the drop-count climb
// instead. An overdue freeze (held REANCHOR_FREEZE_MAX with no clean re-anchor) re-asks while it
// keeps holding: never resume to gray — a dead stream is the QUIC idle-timeout watchdog's job.
//
// Fed but silent is its own recovery trigger (see [`NO_OUTPUT_PATIENCE`]): a decoder that
// never got the opening IDR emits nothing at all and errors on nothing, so none of the
// signals above ever fire and the surface stays black for the life of the session.
let now = Instant::now();
if gate.poll(client.frames_dropped(), now)
let had_output = r + d > 0;
let starved = !had_output
&& fed > fed_at_output
&& now.duration_since(last_output) >= NO_OUTPUT_PATIENCE;
if had_output {
last_output = now;
fed_at_output = fed;
} else if starved {
log::warn!(
"decode: no output for {} ms with {} AU(s) fed — requesting a re-anchor keyframe",
now.duration_since(last_output).as_millis(),
fed - fed_at_output
);
gate.arm(now);
last_output = now; // one request per patience window, not per iteration
fed_at_output = fed;
}
if (gate.poll(client.frames_dropped(), now) || starved)
&& last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
{
last_kf_req = Some(now);