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
+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);