diff --git a/clients/android/native/src/decode/async_loop.rs b/clients/android/native/src/decode/async_loop.rs index 2a5b44a2..9164b299 100644 --- a/clients/android/native/src/decode/async_loop.rs +++ b/clients/android/native/src/decode/async_loop.rs @@ -21,7 +21,10 @@ 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, NO_OUTPUT_PATIENCE, PENDING_SPLIT_CAP}; +use super::{ + DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE, + NO_VIDEO_RETRY, 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 @@ -259,6 +262,10 @@ pub(super) fn run_async( // 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; + // Nothing-ever-arrived backstop (see [`NO_VIDEO_PATIENCE`]) — the mirror of the one above, for a + // session whose video plane delivers no AU at all. + let started = Instant::now(); + let mut last_no_video_req: Option = None; while !shutdown.load(Ordering::Relaxed) && !fatal { // Block for the next event (idle wait — excluded from the work tally). The short timeout @@ -388,6 +395,23 @@ pub(super) fn run_async( last_output = now; // one request per patience window, not per iteration fed_at_output = fed; } + // Nothing has EVER arrived: not an idle stream but a session that never got a picture — the + // `starved` test above cannot see it, because it needs `fed` to have moved. Evaluated after + // `feed_ready`, so an AU that arrived this pass has either been fed or is parked in + // `pending_aus`; both mean video IS flowing. + let no_video_yet = fed == 0 && pending_aus.is_empty(); + if no_video_yet + && now.duration_since(started) >= NO_VIDEO_PATIENCE + && last_no_video_req.is_none_or(|t| now.duration_since(t) >= NO_VIDEO_RETRY) + { + log::warn!( + "decode: no video received {} ms into the session — requesting a keyframe", + now.duration_since(started).as_millis() + ); + last_no_video_req = Some(now); + let _ = client.request_keyframe(); + last_kf_req = Some(now); // share the throttle with the loss-recovery path below + } 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)) { diff --git a/clients/android/native/src/decode/mod.rs b/clients/android/native/src/decode/mod.rs index fdab3e4c..55cf2bce 100644 --- a/clients/android/native/src/decode/mod.rs +++ b/clients/android/native/src/decode/mod.rs @@ -62,6 +62,26 @@ const RENDERED_CAP: usize = 64; /// 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); +/// How long a session may deliver NO access unit at all before we ask for a keyframe and say so. +/// +/// [`NO_OUTPUT_PATIENCE`] covers "fed but silent", and it deliberately requires `fed` to have moved +/// so an idle stream never asks for anything. That leaves its mirror image uncovered: a session that +/// receives nothing whatsoever. A decoder cannot be starved of output when it was handed no input, +/// so no signal in either loop fires, and the session sits connected — audio, input and the control +/// plane all alive — behind a black surface with a HUD reading `0 fps · 0.0 Mb/s`, which is exactly +/// how it comes back in reports (2026-07-30). +/// +/// Asking costs one small control message, and it is the right ask in the case we can actually fix: +/// the host is encoding, but under infinite GOP every picture it sends references an IDR this client +/// never saw. When the host is sending nothing at all, the request changes nothing — but the log line +/// beside it is what separates that from "we received AUs and lost them", which no previous black +/// screen report could tell us. +const NO_VIDEO_PATIENCE: std::time::Duration = std::time::Duration::from_millis(1500); + +/// Re-ask cadence once [`NO_VIDEO_PATIENCE`] has elapsed with still nothing received. Slow, because +/// this state is either self-healing on the first ask or not ours to heal — and each pass logs. +const NO_VIDEO_RETRY: std::time::Duration = std::time::Duration::from_millis(2000); + /// 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 387668e4..d6bc130e 100644 --- a/clients/android/native/src/decode/sync_loop.rs +++ b/clients/android/native/src/decode/sync_loop.rs @@ -24,7 +24,10 @@ 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, NO_OUTPUT_PATIENCE, PENDING_SPLIT_CAP}; +use super::{ + DecodeOptions, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE, NO_VIDEO_RETRY, + 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 @@ -150,6 +153,10 @@ pub(super) fn run_sync( // missed opening IDR — is caught by the same window. let mut last_output = Instant::now(); let mut fed_at_output: u64 = 0; + // Nothing-ever-arrived backstop (see [`NO_VIDEO_PATIENCE`]) — the mirror of the one above, for a + // session whose video plane delivers no AU at all. + let started = Instant::now(); + let mut last_no_video_req: Option = None; // 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 @@ -388,6 +395,23 @@ pub(super) fn run_sync( last_output = now; // one request per patience window, not per iteration fed_at_output = fed; } + // Nothing has EVER arrived: not an idle stream but a session that never got a picture — the + // `starved` test above cannot see it, because it needs `fed` to have moved. `pending` holds + // an AU waiting for a free input buffer, so an empty one alongside `fed == 0` means the video + // plane has delivered nothing at all. + let no_video_yet = fed == 0 && pending.is_none(); + if no_video_yet + && now.duration_since(started) >= NO_VIDEO_PATIENCE + && last_no_video_req.is_none_or(|t| now.duration_since(t) >= NO_VIDEO_RETRY) + { + log::warn!( + "decode: no video received {} ms into the session — requesting a keyframe", + now.duration_since(started).as_millis() + ); + last_no_video_req = Some(now); + let _ = client.request_keyframe(); + last_kf_req = Some(now); // share the throttle with the loss-recovery path below + } if (gate.poll(client.frames_dropped(), now) || starved) && last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {