From caa74a6712072b15abcfdf530cab30247cdcdd9c Mon Sep 17 00:00:00 2001 From: Godnight1006 Date: Fri, 2 Jan 2026 14:50:20 +0800 Subject: [PATCH] Replace server-side first-frame watchdog with client-side implementation This change addresses robustness issues by moving the responsibility of detecting missing first video frames to the controlling side (client). A new ClientFirstFrameWatchdog triggers a refresh if the first frame is not received within 3 seconds of connection, ensuring recovery even if the server thinks it sent a frame but it was lost or not rendered. --- src/client/io_loop.rs | 80 +++++++++++++++++++++++++++++++++++++ src/server/video_service.rs | 50 ----------------------- 2 files changed, 80 insertions(+), 50 deletions(-) diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index e0b3fcd6d..02ef740df 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -68,6 +68,7 @@ pub struct Remote { last_update_jobs_status: (Instant, HashMap), is_connected: bool, first_frame: bool, + watchdog: ClientFirstFrameWatchdog, #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] client_conn_id: i32, // used for file clipboard data_count: Arc, @@ -115,6 +116,7 @@ impl Remote { last_update_jobs_status: (Instant::now(), Default::default()), is_connected: false, first_frame: false, + watchdog: ClientFirstFrameWatchdog::new(Duration::from_secs(3)), #[cfg(any(target_os = "windows", feature = "unix-file-copy-paste"))] client_conn_id: 0, data_count: Arc::new(AtomicUsize::new(0)), @@ -279,6 +281,15 @@ impl Remote { } } _ = status_timer.tick() => { + if self.watchdog.check(self.is_connected, self.first_frame, self.handler.is_default(), Instant::now()) { + log::warn!("No first video frame received, sending refresh"); + let mut misc = Misc::new(); + misc.set_refresh_video(true); + let mut msg = Message::new(); + msg.set_misc(misc); + allow_err!(peer.send(&msg).await); + } + let elapsed = fps_instant.elapsed().as_millis(); if elapsed < 1000 { continue; @@ -2439,3 +2450,72 @@ impl Drop for VideoThread { *self.discard_queue.write().unwrap() = true; } } + +struct ClientFirstFrameWatchdog { + deadline: Option, + timeout: Duration, +} + +impl ClientFirstFrameWatchdog { + fn new(timeout: Duration) -> Self { + Self { deadline: None, timeout } + } + + // Returns true if refresh should be triggered + fn check(&mut self, is_connected: bool, first_frame_received: bool, is_default: bool, now: Instant) -> bool { + if is_connected && !first_frame_received && is_default { + if let Some(d) = self.deadline { + if now > d { + self.deadline = Some(now + self.timeout); + return true; + } + } else { + self.deadline = Some(now + self.timeout); + } + } else { + self.deadline = None; + } + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn test_client_first_frame_watchdog() { + let timeout = Duration::from_secs(3); + let mut watchdog = ClientFirstFrameWatchdog::new(timeout); + let start = Instant::now(); + + // Not connected: no trigger, no deadline set + assert!(!watchdog.check(false, false, true, start)); + assert!(watchdog.deadline.is_none()); + + // Connected, default, no frame: deadline set to start + 3s + assert!(!watchdog.check(true, false, true, start)); + assert!(watchdog.deadline.is_some()); + assert_eq!(watchdog.deadline.unwrap(), start + timeout); + + // Advance 2s: no trigger + assert!(!watchdog.check(true, false, true, start + Duration::from_secs(2))); + + // Advance 4s: trigger! + assert!(watchdog.check(true, false, true, start + Duration::from_secs(4))); + // Deadline reset to +3s from now (start+4s) -> start+7s + assert_eq!(watchdog.deadline.unwrap(), start + Duration::from_secs(4) + timeout); + + // Frame received: reset + assert!(!watchdog.check(true, true, true, start + Duration::from_secs(5))); + assert!(watchdog.deadline.is_none()); + + // Not default: reset + watchdog.check(true, false, true, start); // set deadline + assert!(watchdog.deadline.is_some()); + watchdog.check(true, false, false, start); // reset + assert!(watchdog.deadline.is_none()); + } +} + diff --git a/src/server/video_service.rs b/src/server/video_service.rs index 45f2bedbc..99d53833b 100644 --- a/src/server/video_service.rs +++ b/src/server/video_service.rs @@ -192,27 +192,7 @@ impl VideoFrameController { } } -struct FirstFrameWatchdog { - start: Instant, - timeout: Duration, -} -impl FirstFrameWatchdog { - fn new(timeout: Duration) -> Self { - Self { - start: Instant::now(), - timeout, - } - } - - fn has_timed_out(&self) -> bool { - self.has_timed_out_at(Instant::now()) - } - - fn has_timed_out_at(&self, now: Instant) -> bool { - now.saturating_duration_since(self.start) >= self.timeout - } -} #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum VideoSource { @@ -670,9 +650,6 @@ fn run(vs: VideoService) -> ResultType<()> { let repeat_encode_max = 10; let mut encode_fail_counter = 0; let mut first_frame = true; - let first_frame_timeout = Duration::from_secs(3); - let first_frame_watchdog = FirstFrameWatchdog::new(first_frame_timeout); - let mut sent_first_frame = false; let capture_width = c.width; let capture_height = c.height; let (mut second_instant, mut send_counter) = (Instant::now(), 0); @@ -901,17 +878,6 @@ fn run(vs: VideoService) -> ResultType<()> { } } - if !sent_first_frame { - if produced_frame { - sent_first_frame = true; - } else if first_frame_watchdog.has_timed_out() { - log::warn!( - "No first video frame for {first_frame_timeout:?}, restarting video service" - ); - bail!("SWITCH"); - } - } - let mut fetched_conn_ids = HashSet::new(); let timeout_millis = 3_000u64; let wait_begin = Instant::now(); @@ -1466,20 +1432,4 @@ fn handle_screenshot(screenshot: Screenshot, msg: String, w: usize, h: usize, da } } -#[cfg(test)] -mod first_frame_watchdog_tests { - use super::FirstFrameWatchdog; - use std::time::{Duration, Instant}; - #[test] - fn triggers_after_timeout() { - let t0 = Instant::now(); - let w = FirstFrameWatchdog { - start: t0, - timeout: Duration::from_secs(3), - }; - assert!(!w.has_timed_out_at(t0 + Duration::from_secs(1))); - assert!(!w.has_timed_out_at(t0 + Duration::from_secs(2))); - assert!(w.has_timed_out_at(t0 + Duration::from_secs(3))); - } -}