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 <noreply@anthropic.com>
This commit is contained in:
rustdesk
2026-08-13 12:30:15 +08:00
parent 8fc82d04ac
commit 24a16d9a30
7 changed files with 140 additions and 24 deletions

View File

@@ -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<DesktopTab>
void onWindowMinimize() {
stateGlobal.setMinimized(true);
stateGlobal.setMaximized(false);
_updateSessionsRenderVisible(false);
super.onWindowMinimize();
}
@@ -395,6 +397,7 @@ class _DesktopTabState extends State<DesktopTab>
void onWindowMaximize() {
stateGlobal.setMinimized(false);
_setMaximized(true);
_updateSessionsRenderVisible(true);
super.onWindowMaximize();
}
@@ -402,6 +405,7 @@ class _DesktopTabState extends State<DesktopTab>
void onWindowUnmaximize() {
stateGlobal.setMinimized(false);
_setMaximized(false);
_updateSessionsRenderVisible(true);
super.onWindowUnmaximize();
}
@@ -409,9 +413,26 @@ class _DesktopTabState extends State<DesktopTab>
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<FFI>(tag: tab.key);
bind.sessionSetRenderVisible(
sessionId: ffi.sessionId, visible: visible);
} catch (_) {}
}
}
_saveFrame({bool? flush}) async {
try {
if (tabType == DesktopTabType.main) {

View File

@@ -32,6 +32,7 @@ class _TextureRenderProbeState extends State<TextureRenderProbe> {
bool _sawTimings = false;
bool _wasEffectiveOn = false;
DateTime? _lastTimings;
DateTime? _firstPush;
@override
void initState() {
@@ -82,15 +83,31 @@ class _TextureRenderProbeState extends State<TextureRenderProbe> {
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<TextureRenderProbe> {
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')) {

View File

@@ -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;
}

View File

@@ -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"

View File

@@ -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:

View File

@@ -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<u64>,
watchdog_pushed_base: u64,
watchdog_since: Option<Instant>,
watchdog_last_sample: Option<Instant>,
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<RwLock<HashMap<usize, Arc<Mutex<DisplaySessionInfo>>>>>,
// Latched by the watchdog; consumed once by the pushing caller to trigger
// the software-render fallback for this ui session.
texture_render_failed: Arc<AtomicBool>,
// 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<AtomicU8>,
// Hidden windows legitimately composite nothing; the watchdog pauses.
render_visible: Arc<AtomicBool>,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
on_rgba_func: Option<Symbol<'static, FlutterRgbaRendererPluginOnRgba>>,
#[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())

View File

@@ -1222,10 +1222,24 @@ pub fn main_set_env(key: String, value: Option<String>) -> 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<bool> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
return SyncReturn(super::flutter::texture_render_probe_supported());