From 24a16d9a300e0e0384a4d4de27d36ede9b9a1f87 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Thu, 13 Aug 2026 12:30:15 +0800 Subject: [PATCH] fix: address codex review findings on probe/watchdog fallback - a probe or raster-stall failure record now also downgrades sessions that are already running: main_set_local_option broadcasts the fallback for failed-* health writes, which also closes the hole where the watchdog's idempotence guard no-oped after the probe had already written the record - probe success requires a frame timing newer than the first push: 'consumed' advances inside the plugin callback before the GL/Metal upload, so a raster thread hanging in the driver no longer counts as a pass (and cannot clear a previous failure) - watchdog failures are tagged with the failing backend (failed-watchdog-rgba/gpu); the rgba-only probe clears only the rgba class, so a working pixel-buffer path can no longer re-enable a broken D3D shared-handle path every launch - the watchdog pauses while the session's window is hidden (new session_set_render_visible FFI wired to the window minimize/restore events): a display registered in a minimized window no longer records a false global failure; observation now counts pushes within the window rather than since registration - probe failure verdicts additionally require a resumed lifecycle, matching the raster-stall monitor - linux plugin: deferred-unref grace lengthened to 10s (no raster-side completion barrier exists; documented as heuristic), ref bumped Co-Authored-By: Claude Fable 5 --- .../lib/desktop/widgets/tabbar_widget.dart | 21 +++++ .../desktop/widgets/texture_render_probe.dart | 30 +++++-- flutter/lib/web/bridge.dart | 3 + flutter/pubspec.lock | 4 +- flutter/pubspec.yaml | 2 +- src/flutter.rs | 84 +++++++++++++++---- src/flutter_ffi.rs | 20 +++++ 7 files changed, 140 insertions(+), 24 deletions(-) diff --git a/flutter/lib/desktop/widgets/tabbar_widget.dart b/flutter/lib/desktop/widgets/tabbar_widget.dart index adb24e371..e3cd292a2 100644 --- a/flutter/lib/desktop/widgets/tabbar_widget.dart +++ b/flutter/lib/desktop/widgets/tabbar_widget.dart @@ -11,6 +11,7 @@ import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/pages/remote_page.dart'; import 'package:flutter_hbb/desktop/pages/view_camera_page.dart'; import 'package:flutter_hbb/main.dart'; +import 'package:flutter_hbb/models/model.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:get/get.dart'; @@ -388,6 +389,7 @@ class _DesktopTabState extends State void onWindowMinimize() { stateGlobal.setMinimized(true); stateGlobal.setMaximized(false); + _updateSessionsRenderVisible(false); super.onWindowMinimize(); } @@ -395,6 +397,7 @@ class _DesktopTabState extends State void onWindowMaximize() { stateGlobal.setMinimized(false); _setMaximized(true); + _updateSessionsRenderVisible(true); super.onWindowMaximize(); } @@ -402,6 +405,7 @@ class _DesktopTabState extends State void onWindowUnmaximize() { stateGlobal.setMinimized(false); _setMaximized(false); + _updateSessionsRenderVisible(true); super.onWindowUnmaximize(); } @@ -409,9 +413,26 @@ class _DesktopTabState extends State void onWindowRestore() { // A plain restore (no maximize involved) must clear the minimized flag. stateGlobal.setMinimized(false); + _updateSessionsRenderVisible(true); super.onWindowRestore(); } + // A hidden window composites nothing; pause the Rust-side texture watchdog + // for its sessions so it cannot record a false failure. + void _updateSessionsRenderVisible(bool visible) { + if (tabType != DesktopTabType.remoteScreen && + tabType != DesktopTabType.viewCamera) { + return; + } + for (final tab in controller.state.value.tabs) { + try { + final ffi = Get.find(tag: tab.key); + bind.sessionSetRenderVisible( + sessionId: ffi.sessionId, visible: visible); + } catch (_) {} + } + } + _saveFrame({bool? flush}) async { try { if (tabType == DesktopTabType.main) { diff --git a/flutter/lib/desktop/widgets/texture_render_probe.dart b/flutter/lib/desktop/widgets/texture_render_probe.dart index c76b22aac..97a3fd989 100644 --- a/flutter/lib/desktop/widgets/texture_render_probe.dart +++ b/flutter/lib/desktop/widgets/texture_render_probe.dart @@ -32,6 +32,7 @@ class _TextureRenderProbeState extends State { bool _sawTimings = false; bool _wasEffectiveOn = false; DateTime? _lastTimings; + DateTime? _firstPush; @override void initState() { @@ -82,15 +83,31 @@ class _TextureRenderProbeState extends State { setState(() => _textureId = id); _timer = Timer.periodic(const Duration(milliseconds: 100), (_) { _ticks += 1; + _firstPush ??= DateTime.now(); bind.mainPushTextureProbeFrame(ptr: _ptr); - if (bind.mainGetTextureProbeConsumed(ptr: _ptr) > 0) { + final consumed = bind.mainGetTextureProbeConsumed(ptr: _ptr) > 0; + // "Consumed" advances inside the plugin callback, before the GL/Metal + // upload; only a frame timing after the push proves a completed frame. + final frameCompleted = consumed && + _lastTimings != null && + _lastTimings!.isAfter(_firstPush!); + if (frameCompleted) { _finish(true); } else if (_ticks >= 10) { + if (consumed) { + _finish(null); + return; + } // Only a window that is visibly compositing can prove a failure. + final lifecycle = SchedulerBinding.instance.lifecycleState; + final active = + lifecycle == null || lifecycle == AppLifecycleState.resumed; final timingsFresh = _lastTimings != null && DateTime.now().difference(_lastTimings!) < const Duration(milliseconds: 1500); - _finish(!stateGlobal.isMinimized && timingsFresh ? false : null); + _finish(!stateGlobal.isMinimized && active && timingsFresh + ? false + : null); } }); } @@ -102,9 +119,12 @@ class _TextureRenderProbeState extends State { if (ok != null) { final old = bind.mainGetLocalOption(key: kOptionTextureRenderHealth); if (ok) { - // A 1x1 probe pass disproves the black-texture class, not a - // raster-stall under load; that record only clears via the toggle. - if (old != 'ok' && !old.startsWith('failed-raster-stall')) { + // This rgba probe disproves only the rgba black-texture class: gpu + // failures and raster stalls clear via the option toggle alone. + final clearable = old.isEmpty || + old.startsWith('failed-probe') || + old.startsWith('failed-watchdog-rgba'); + if (clearable) { bind.mainSetLocalOption(key: kOptionTextureRenderHealth, value: 'ok'); } } else if (!old.startsWith('failed')) { diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index 2769788b3..813e677c6 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -1462,6 +1462,9 @@ class RustdeskImpl { required int ptr, dynamic hint}) {} + void sessionSetRenderVisible( + {required UuidValue sessionId, required bool visible, dynamic hint}) {} + bool mainTextureRenderProbeSupported({dynamic hint}) { return false; } diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 046c8a616..150b8b97c 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -1298,8 +1298,8 @@ packages: dependency: "direct main" description: path: "." - ref: "7932bf9c7f64b34b3d136ff07acd7e30dd279c34" - resolved-ref: "7932bf9c7f64b34b3d136ff07acd7e30dd279c34" + ref: "883326ddd4fb2af1484bf873b4ea856a0ac440bc" + resolved-ref: "883326ddd4fb2af1484bf873b4ea856a0ac440bc" url: "https://github.com/rustdesk-org/flutter_texture_rgba_renderer" source: git version: "0.0.16" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 5c37fee32..d851c3f10 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -88,7 +88,7 @@ dependencies: texture_rgba_renderer: git: url: https://github.com/rustdesk-org/flutter_texture_rgba_renderer - ref: 7932bf9c7f64b34b3d136ff07acd7e30dd279c34 + ref: 883326ddd4fb2af1484bf873b4ea856a0ac440bc percent_indicator: ^4.2.2 dropdown_button2: ^2.0.0 flutter_gpu_texture_renderer: diff --git a/src/flutter.rs b/src/flutter.rs index 8cb88aaee..dfd611c35 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -23,7 +23,7 @@ use std::{ os::raw::{c_char, c_int, c_void}, str::FromStr, sync::{ - atomic::{AtomicBool, AtomicUsize, Ordering}, + atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering}, Arc, Mutex, RwLock, }, time::{Duration, Instant}, @@ -278,6 +278,12 @@ pub type FlutterGpuTextureRendererPluginCApiGetConsumed = pub(super) type TextureRgbaPtr = usize; +// Which texture backend the watchdog saw fail; the health record carries it +// so the rgba-only startup probe never clears a gpu-path failure. +pub(super) const WATCHDOG_FAILED_RGBA: u8 = 1; +#[cfg(feature = "vram")] +pub(super) const WATCHDOG_FAILED_GPU: u8 = 2; + #[derive(Default)] struct DisplaySessionInfo { // TextureRgba pointer in flutter native. @@ -292,6 +298,7 @@ struct DisplaySessionInfo { // until a consumption is observed since arming. pushed_count: u64, watchdog_consumed_base: Option, + watchdog_pushed_base: u64, watchdog_since: Option, watchdog_last_sample: Option, watchdog_armed: bool, @@ -301,18 +308,27 @@ impl DisplaySessionInfo { fn reset_watchdog(&mut self) { self.pushed_count = 0; self.watchdog_consumed_base = None; + self.watchdog_pushed_base = 0; self.watchdog_since = None; self.watchdog_last_sample = None; self.watchdog_armed = true; } + // Restart the observation window without disarming; used while the window + // is hidden, where the engine legitimately composites nothing. + fn pause_watchdog(&mut self) { + self.watchdog_consumed_base = None; + self.watchdog_since = None; + } + // The plugin counter is cumulative and never resets, so compare against a // snapshot taken when arming; damage-driven streams can be sparse, so - // judge on cumulative pushes plus elapsed time, not per-second rate. + // judge on pushes within the observation window plus elapsed time. fn check_watchdog(&mut self, consumed: u64) -> bool { let now = Instant::now(); let Some(base) = self.watchdog_consumed_base else { self.watchdog_consumed_base = Some(consumed); + self.watchdog_pushed_base = self.pushed_count; self.watchdog_since = Some(now); return false; }; @@ -320,7 +336,7 @@ impl DisplaySessionInfo { self.watchdog_armed = false; return false; } - if self.pushed_count >= 30 + if self.pushed_count - self.watchdog_pushed_base >= 30 && self .watchdog_since .map(|t| now.duration_since(t) >= Duration::from_secs(3)) @@ -354,9 +370,11 @@ impl DisplaySessionInfo { struct VideoRenderer { is_support_multi_ui_session: bool, map_display_sessions: Arc>>>>, - // Latched by the watchdog; consumed once by the pushing caller to trigger - // the software-render fallback for this ui session. - texture_render_failed: Arc, + // Latched by the watchdog (WATCHDOG_FAILED_*); consumed once by the + // pushing caller to trigger the software-render fallback for this session. + texture_render_failed: Arc, + // Hidden windows legitimately composite nothing; the watchdog pauses. + render_visible: Arc, #[cfg(not(any(target_os = "android", target_os = "ios")))] on_rgba_func: Option>, #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -435,6 +453,7 @@ impl Default for VideoRenderer { map_display_sessions: Default::default(), is_support_multi_ui_session: false, texture_render_failed: Default::default(), + render_visible: Arc::new(AtomicBool::new(true)), #[cfg(not(any(target_os = "android", target_os = "ios")))] on_rgba_func, #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -658,7 +677,9 @@ impl VideoRenderer { } info.pushed_count += 1; if let Some(get_consumed) = &self.get_consumed_func { - if info.watchdog_sample_due() { + if !self.render_visible.load(Ordering::Relaxed) { + info.pause_watchdog(); + } else if info.watchdog_sample_due() { let consumed = unsafe { get_consumed(info.texture_rgba_ptr as _) }; if info.check_watchdog(consumed) { log::error!( @@ -666,7 +687,8 @@ impl VideoRenderer { info.pushed_count, display ); - self.texture_render_failed.store(true, Ordering::SeqCst); + self.texture_render_failed + .store(WATCHDOG_FAILED_RGBA, Ordering::SeqCst); } } } @@ -695,7 +717,9 @@ impl VideoRenderer { // advances it), so this only detects never-composited outputs; the // rgba path and the startup probe cover bind-failure black screens. if let Some(get_consumed) = &self.get_gpu_consumed_func { - if info.watchdog_sample_due() { + if !self.render_visible.load(Ordering::Relaxed) { + info.pause_watchdog(); + } else if info.watchdog_sample_due() { let consumed = unsafe { get_consumed(info.gpu_output_ptr as _) }; if info.check_watchdog(consumed) { log::error!( @@ -703,7 +727,8 @@ impl VideoRenderer { info.pushed_count, display ); - self.texture_render_failed.store(true, Ordering::SeqCst); + self.texture_render_failed + .store(WATCHDOG_FAILED_GPU, Ordering::SeqCst); } } } @@ -1485,13 +1510,13 @@ impl FlutterHandler { // actual fallback (config write, decoder reset) runs on its own thread. #[cfg(not(any(target_os = "android", target_os = "ios")))] fn check_texture_render_failed(session_id: &SessionID, session: &SessionHandler) { - if session + let kind = session .renderer .texture_render_failed - .swap(false, Ordering::SeqCst) - { + .swap(0, Ordering::SeqCst); + if kind != 0 { let session_id = session_id.clone(); - std::thread::spawn(move || on_texture_render_failed(session_id)); + std::thread::spawn(move || on_texture_render_failed(session_id, kind)); } } } @@ -2033,6 +2058,26 @@ pub fn session_unregister_pixelbuffer_texture(session_id: SessionID, display: us } } +// Hidden windows legitimately composite nothing; pausing the watchdog there +// keeps a minimized/background window from recording a false failure. +#[inline] +pub fn session_set_render_visible(session_id: SessionID, visible: bool) { + for s in sessions::get_sessions() { + if let Some(h) = s + .ui_handler + .session_handlers + .read() + .unwrap() + .get(&session_id) + { + h.renderer + .render_visible + .store(visible, Ordering::Relaxed); + break; + } + } +} + #[inline] pub fn session_unregister_gpu_texture(_session_id: SessionID, _display: usize, _output_ptr: usize) { #[cfg(feature = "vram")] @@ -2104,8 +2149,9 @@ pub fn get_texture_probe_consumed(ptr: usize) -> u64 { // software rendering and record the breakage (health flips the default off; // a passing startup probe or an explicit option toggle clears it). #[cfg(not(any(target_os = "android", target_os = "ios")))] -fn on_texture_render_failed(session_id: SessionID) { +fn on_texture_render_failed(session_id: SessionID, kind: u8) { // One record per breakage; later fires (other displays/sessions) no-op. + // Sessions already running were downgraded when the record was written. if crate::ui_interface::texture_render_health_failed() { return; } @@ -2113,10 +2159,16 @@ fn on_texture_render_failed(session_id: SessionID) { "texture rendering failed for session {}, falling back to software rendering", session_id ); + let backend = if kind == WATCHDOG_FAILED_RGBA { + "rgba" + } else { + "gpu" + }; LocalConfig::set_option( hbb_common::config::keys::OPTION_TEXTURE_RENDER_HEALTH.to_owned(), format!( - "failed-watchdog@{}", + "failed-watchdog-{}@{}", + backend, std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 28ab62069..a502c9857 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -1222,10 +1222,24 @@ pub fn main_set_env(key: String, value: Option) -> SyncReturn<()> { pub fn main_set_local_option(key: String, value: String) { let is_texture_render_key = key.eq(config::keys::OPTION_TEXTURE_RENDER); + let is_texture_render_health_key = key.eq(config::keys::OPTION_TEXTURE_RENDER_HEALTH); let is_d3d_render_key = key.eq(config::keys::OPTION_ALLOW_D3D_RENDER); set_local_option(key, value.clone()); let is_render_target = |session: &crate::flutter::FlutterSession| session.is_default() || session.is_view_camera(); + if is_texture_render_health_key && value.starts_with("failed") { + // Probe/raster-stall failures must also downgrade sessions that are + // already running (they snapshotted the old effective value, and the + // watchdog's own fallback no-ops once a record exists). + for session in sessions::get_sessions() { + if !is_render_target(&session) { + continue; + } + session.push_event("use_texture_render", &[("v", "N")], &[]); + session.use_texture_render_changed(); + session.ui_handler.update_use_texture_render(); + } + } if is_texture_render_key { // An explicit user toggle gives texture rendering a fresh chance; a // stale failure record must not override it (the watchdog re-records @@ -2322,6 +2336,12 @@ pub fn session_unregister_gpu_texture( )) } +pub fn session_set_render_visible(session_id: SessionID, visible: bool) -> SyncReturn<()> { + SyncReturn(super::flutter::session_set_render_visible( + session_id, visible, + )) +} + pub fn main_texture_render_probe_supported() -> SyncReturn { #[cfg(not(any(target_os = "android", target_os = "ios")))] return SyncReturn(super::flutter::texture_render_probe_supported());