fix(client/android): a session that receives no video at all now asks, and says so

The keyframe backstop added for the black LG TV only arms while AUs are
actually going into the decoder (`fed > fed_at_output`) — deliberately, so
an idle stream never asks for anything. That leaves its mirror image
uncovered: a session that receives NOTHING. 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.

That state is what a user just reported as "the stats are all basically 0":
fps and Mb/s are counted at AU receipt (`note_received`), so all-zero stats
with a drawn overlay means the decode thread started and received nothing.
Same bug as the black screen, seen from the HUD.

Both loops now watch for it: nothing received 1.5 s into a session ⇒ request
a keyframe and log it, re-asking every 2 s while it lasts. Where it can help
it does — the host encoding fine while every picture references an IDR this
client never saw is precisely a keyframe request away. Where it can't, the
log line is the point: "no video received N ms into the session" separates
"the host never sent a picture" from "we received AUs and lost them", which
no previous black-screen report could distinguish.

Not a root cause. The remaining occurrences are still unattributed — this
makes the next report diagnosable and recovers the case that is ours to
recover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 00:30:59 +02:00
co-authored by Claude Opus 5
parent bb7baef20b
commit 6a6be17ce7
3 changed files with 70 additions and 2 deletions
@@ -21,7 +21,10 @@ use super::setup::{
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime, android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
configure_low_latency, create_codec, try_set_frame_rate, 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 /// 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 /// (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. // first frame — the missed opening IDR — is caught by the same window.
let mut last_output = Instant::now(); let mut last_output = Instant::now();
let mut fed_at_output: u64 = 0; 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<Instant> = None;
while !shutdown.load(Ordering::Relaxed) && !fatal { while !shutdown.load(Ordering::Relaxed) && !fatal {
// Block for the next event (idle wait — excluded from the work tally). The short timeout // 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 last_output = now; // one request per patience window, not per iteration
fed_at_output = fed; 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) 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.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
{ {
+20
View File
@@ -62,6 +62,26 @@ const RENDERED_CAP: usize = 64;
/// costs half a second before it self-heals is not a bug the user reports. /// 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); 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 /// 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 /// 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 /// decoded frame the instant it's ready instead of waiting out a poll interval. Only consulted when
+25 -1
View File
@@ -24,7 +24,10 @@ use super::setup::{
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime, android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
configure_low_latency, create_codec, try_set_frame_rate, 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, /// 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 /// 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. // missed opening IDR — is caught by the same window.
let mut last_output = Instant::now(); let mut last_output = Instant::now();
let mut fed_at_output: u64 = 0; 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<Instant> = None;
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`). // AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
let mut oversized_dropped: u64 = 0; let mut oversized_dropped: u64 = 0;
// The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input // 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 last_output = now; // one request per patience window, not per iteration
fed_at_output = fed; 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) 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.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
{ {