diff --git a/clients/android/native/src/decode/async_loop.rs b/clients/android/native/src/decode/async_loop.rs index eede78c1..2a5b44a2 100644 --- a/clients/android/native/src/decode/async_loop.rs +++ b/clients/android/native/src/decode/async_loop.rs @@ -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); diff --git a/clients/android/native/src/decode/mod.rs b/clients/android/native/src/decode/mod.rs index 0edeaaad..fdab3e4c 100644 --- a/clients/android/native/src/decode/mod.rs +++ b/clients/android/native/src/decode/mod.rs @@ -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 diff --git a/clients/android/native/src/decode/sync_loop.rs b/clients/android/native/src/decode/sync_loop.rs index fd8e775b..387668e4 100644 --- a/clients/android/native/src/decode/sync_loop.rs +++ b/clients/android/native/src/decode/sync_loop.rs @@ -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);