fix: address texture watchdog/probe review findings

- watchdog: compare the plugin's cumulative consumed counter against a
  snapshot taken when arming (re-arm was a no-op before), and judge on
  cumulative pushes + elapsed time so sparse damage-driven streams are
  still detected
- failure handling is idempotent (one record per breakage) and updates
  every render session like main_set_local_option does, not only the
  failing one; the fallback toast is not claimed for multi-display
  windows the soft path cannot rescue
- probe: skip when the consumed API is missing (old plugin) or a
  raster-stall is recorded (compositing could hang the main window);
  a fail verdict requires fresh frame timings and a non-minimized
  window; a pass never clears failed-raster-stall; toast only when
  texture rendering was effectively on; probe pixel is transparent
- raster-stall monitor: judge on frame-timing staleness (a mid-episode
  hang was undetectable before), gate on lifecycle/minimized/idle with
  a moving quiet anchor, threshold 30s, shared with the camera page;
  clear stateGlobal minimized flag on plain window restore
- adopt mismatched frame sizes only in multi-ui-session mode (legacy
  mode pairs frames loosely and could ping-pong between displays)
- drop the now-dead closeSession parameter from texture destroy();
  trim comments to repo style

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rustdesk
2026-08-13 11:22:13 +08:00
parent c5adac828b
commit 7da2bbe6ac
10 changed files with 201 additions and 111 deletions

View File

@@ -21,6 +21,7 @@ import '../../common/shared_state.dart';
import '../../utils/image.dart';
import '../widgets/remote_toolbar.dart';
import '../widgets/kb_layout_type_chooser.dart';
import '../widgets/raster_stall_monitor.dart';
import '../widgets/tabbar_widget.dart';
import 'macos_full_screen_focus_recovery.dart';
@@ -156,7 +157,7 @@ class _RemotePageState extends State<RemotePage>
widget.tabController?.state.listen(_onMacOSTabStateChanged);
}
Get.put<FFI>(_ffi, tag: widget.id);
_RasterStallMonitor.start();
RasterStallMonitor.start();
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
_ffi.canvasModel.activateLocalCursor();
showKBLayoutTypeChooserIfNeeded(
@@ -648,7 +649,7 @@ class _RemotePageState extends State<RemotePage>
// Clear callback reference to prevent memory leaks and stale references
_ffi.inputModel.onRelativeMouseModeDisabled = null;
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
_ffi.textureModel.onRemotePageDispose(closeSession);
_ffi.textureModel.onRemotePageDispose();
if (closeSession && !isMacOS) {
// ensure we leave this session, this is a double check
// enterOrLeave() is already called previously in _releaseMacOSRemoteInput() for macOS.
@@ -1404,44 +1405,3 @@ class CursorPaint extends StatelessWidget {
}
}
/// Detects a hung raster thread: the UI keeps scheduling frames but the
/// engine never reports completed frame timings. Rendering cannot be rescued
/// in-process (software rendering needs the same raster thread), so this only
/// records the breakage — the next launch then defaults texture rendering to
/// off and the startup probe re-validates the environment.
class _RasterStallMonitor {
static bool _started = false;
static bool _reported = false;
static DateTime? _lastTimings;
static DateTime? _scheduledSince;
static void start() {
if (_started || isWeb) return;
_started = true;
SchedulerBinding.instance.addTimingsCallback((_) {
_lastTimings = DateTime.now();
});
Timer.periodic(const Duration(seconds: 2), (_) {
if (_reported) return;
// A minimized window legitimately stops producing frame timings.
if (stateGlobal.isMinimized ||
!SchedulerBinding.instance.hasScheduledFrame) {
_scheduledSince = null;
return;
}
final now = DateTime.now();
_scheduledSince ??= now;
final lastTimings = _lastTimings;
final stalled =
now.difference(_scheduledSince!) > const Duration(seconds: 10) &&
(lastTimings == null || lastTimings.isBefore(_scheduledSince!));
if (stalled) {
_reported = true;
bind.mainSetLocalOption(
key: kOptionTextureRenderHealth, value: 'failed-raster-stall');
debugPrint(
'raster thread stall detected, texture rendering disabled for next launch');
}
});
}
}

View File

@@ -19,6 +19,7 @@ import '../../common/shared_state.dart';
import '../../utils/image.dart';
import '../widgets/remote_toolbar.dart';
import '../widgets/kb_layout_type_chooser.dart';
import '../widgets/raster_stall_monitor.dart';
import '../widgets/tabbar_widget.dart';
import 'package:flutter_hbb/native/custom_cursor.dart'
@@ -102,6 +103,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
super.initState();
_ffi = FFI(widget.sessionId);
Get.put<FFI>(_ffi, tag: widget.id);
RasterStallMonitor.start();
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
showKBLayoutTypeChooserIfNeeded(
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
@@ -222,7 +224,7 @@ class _ViewCameraPageState extends State<ViewCameraPage>
// https://github.com/flutter/flutter/issues/64935
super.dispose();
debugPrint("VIEW CAMERA PAGE dispose session $sessionId ${widget.id}");
_ffi.textureModel.onViewCameraPageDispose(closeSession);
_ffi.textureModel.onViewCameraPageDispose();
if (closeSession) {
// ensure we leave this session, this is a double check
_ffi.inputModel.enterOrLeave(false);

View File

@@ -0,0 +1,52 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import '../../common.dart';
import '../../consts.dart';
import '../../models/platform_model.dart';
import '../../models/state_model.dart';
/// Records a hung raster thread (frames continuously scheduled but no frame
/// timings delivered for 30s) in `texture-render-health`; a hang cannot be
/// rescued in-process, so the next launch defaults texture rendering off.
class RasterStallMonitor {
static bool _started = false;
static bool _reported = false;
static DateTime? _lastTimings;
static DateTime _lastQuiet = DateTime.now();
static void start() {
if (_started || isWeb) return;
_started = true;
SchedulerBinding.instance.addTimingsCallback((_) {
_lastTimings = DateTime.now();
});
Timer.periodic(const Duration(seconds: 2), (_) {
if (_reported) return;
final now = DateTime.now();
final lifecycle = SchedulerBinding.instance.lifecycleState;
// Minimized/inactive (incl. screen lock) or idle (nothing scheduled):
// no timings is legitimate, keep moving the quiet anchor forward.
if (stateGlobal.isMinimized ||
(lifecycle != null && lifecycle != AppLifecycleState.resumed) ||
!SchedulerBinding.instance.hasScheduledFrame) {
_lastQuiet = now;
return;
}
var ref = _lastQuiet;
final lastTimings = _lastTimings;
if (lastTimings != null && lastTimings.isAfter(ref)) {
ref = lastTimings;
}
if (now.difference(ref) > const Duration(seconds: 30)) {
_reported = true;
bind.mainSetLocalOption(
key: kOptionTextureRenderHealth, value: 'failed-raster-stall');
debugPrint(
'raster thread stall detected, texture rendering disabled for next launch');
}
});
}
}

View File

@@ -405,6 +405,13 @@ class _DesktopTabState extends State<DesktopTab>
super.onWindowUnmaximize();
}
@override
void onWindowRestore() {
// A plain restore (no maximize involved) must clear the minimized flag.
stateGlobal.setMinimized(false);
super.onWindowRestore();
}
_saveFrame({bool? flush}) async {
try {
if (tabType == DesktopTabType.main) {

View File

@@ -6,15 +6,14 @@ import 'package:flutter/scheduler.dart';
import '../../common.dart';
import '../../consts.dart';
import '../../models/platform_model.dart';
import '../../models/state_model.dart';
import 'package:texture_rgba_renderer/texture_rgba_renderer.dart'
if (dart.library.html) 'package:flutter_hbb/web/texture_rgba_renderer.dart';
/// Startup probe: renders one frame through a 1x1 external texture and
/// verifies the engine consumed it. The verdict is recorded in
/// `texture-render-health` — a failure turns texture rendering off before the
/// first session goes black, a pass clears a stale failure (self-healing
/// after a driver/OS fix). Mounted once, in the main window.
/// records in `texture-render-health` whether the engine consumed it, so a
/// broken environment is detected before the first session goes black.
class TextureRenderProbe extends StatefulWidget {
const TextureRenderProbe({Key? key}) : super(key: key);
@@ -31,12 +30,24 @@ class _TextureRenderProbeState extends State<TextureRenderProbe> {
Timer? _timer;
int _ticks = 0;
bool _sawTimings = false;
bool _wasEffectiveOn = false;
DateTime? _lastTimings;
@override
void initState() {
super.initState();
if (_ranThisLaunch || isWeb || !isDesktop) return;
_ranThisLaunch = true;
if (bind.isIncomingOnly()) return;
// An old plugin without the consumed counter cannot be judged, and a
// recorded raster-stall means compositing a texture may hang this window.
if (!bind.mainTextureRenderProbeSupported()) return;
if (bind
.mainGetLocalOption(key: kOptionTextureRenderHealth)
.startsWith('failed-raster-stall')) {
return;
}
_wasEffectiveOn = bind.mainGetUseTextureRender();
// Only probe after the window has really rendered a frame: a hidden
// window (silent/tray start) must not record a false failure.
SchedulerBinding.instance.addTimingsCallback(_onTimings);
@@ -49,9 +60,9 @@ class _TextureRenderProbeState extends State<TextureRenderProbe> {
}
void _onTimings(List<FrameTiming> timings) {
_lastTimings = DateTime.now();
if (_sawTimings) return;
_sawTimings = true;
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
_start();
}
@@ -75,7 +86,11 @@ class _TextureRenderProbeState extends State<TextureRenderProbe> {
if (bind.mainGetTextureProbeConsumed(ptr: _ptr) > 0) {
_finish(true);
} else if (_ticks >= 10) {
_finish(false);
// Only a window that is visibly compositing can prove a failure.
final timingsFresh = _lastTimings != null &&
DateTime.now().difference(_lastTimings!) <
const Duration(milliseconds: 1500);
_finish(!stateGlobal.isMinimized && timingsFresh ? false : null);
}
});
}
@@ -83,17 +98,22 @@ class _TextureRenderProbeState extends State<TextureRenderProbe> {
void _finish(bool? ok) {
_timer?.cancel();
_timer = null;
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
if (ok != null) {
final old = bind.mainGetLocalOption(key: kOptionTextureRenderHealth);
if (ok) {
if (old != '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')) {
bind.mainSetLocalOption(key: kOptionTextureRenderHealth, value: 'ok');
}
} else if (!old.startsWith('failed')) {
debugPrint('texture render probe failed, disabling texture rendering');
bind.mainSetLocalOption(
key: kOptionTextureRenderHealth, value: 'failed-probe');
showToast(translate('texture-render-fallback-tip'));
if (_wasEffectiveOn) {
showToast(translate('texture-render-fallback-tip'));
}
}
}
if (_textureKey != -1) {
@@ -111,6 +131,7 @@ class _TextureRenderProbeState extends State<TextureRenderProbe> {
@override
void dispose() {
_timer?.cancel();
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
if (_textureKey != -1) {
_renderer.closeTexture(_textureKey);
_textureKey = -1;
@@ -121,8 +142,8 @@ class _TextureRenderProbeState extends State<TextureRenderProbe> {
@override
Widget build(BuildContext context) {
if (_textureId == -1) return const SizedBox.shrink();
// Must actually composite for the engine to sample the texture; 1x1 in a
// corner is imperceptible.
// Must actually composite for the engine to sample the texture; the
// pushed pixel is fully transparent.
return IgnorePointer(
child: SizedBox(
width: 1, height: 1, child: Texture(textureId: _textureId)),

View File

@@ -52,15 +52,14 @@ class _PixelbufferTexture {
});
}
destroy(bool closeSession, FFI ffi) async {
destroy(FFI ffi) async {
_closed = true;
if (!_destroying && _textureKey != -1 && _sessionId != null) {
_destroying = true;
if (_ptr != 0) {
// Compare-and-clear: clears only if Rust still holds this pointer, so
// a registration a new window has already made stays intact (#8016).
// Returning from this synchronous call also guarantees no push
// through the old pointer is still in flight.
// Compare-and-clear: only clears if Rust still holds this pointer
// (#8016-safe); returning from this synchronous call also means no
// push through the old pointer is still in flight.
platformFFI.unregisterPixelbufferTexture(_sessionId!, display, _ptr);
_ptr = 0;
}
@@ -122,7 +121,7 @@ class _GpuTexture {
}
}
destroy(bool closeSession, FFI ffi) async {
destroy(FFI ffi) async {
// must stop texture render, render unregistered texture cause crash
_closed = true;
if (!_destroying && support && _sessionId != null && _textureId != -1) {
@@ -229,11 +228,11 @@ class TextureModel {
tryRemoveTexture(int idx) {
_control.remove(idx);
if (_pixelbufferRenderTextures.containsKey(idx)) {
_pixelbufferRenderTextures[idx]!.destroy(true, ffi);
_pixelbufferRenderTextures[idx]!.destroy(ffi);
_pixelbufferRenderTextures.remove(idx);
}
if (_gpuRenderTextures.containsKey(idx)) {
_gpuRenderTextures[idx]!.destroy(true, ffi);
_gpuRenderTextures[idx]!.destroy(ffi);
_gpuRenderTextures.remove(idx);
}
}
@@ -253,25 +252,25 @@ class TextureModel {
}
}
onRemotePageDispose(bool closeSession) async {
onRemotePageDispose() async {
final ffi = parent.target;
if (ffi == null) return;
for (final texture in _pixelbufferRenderTextures.values) {
await texture.destroy(closeSession, ffi);
await texture.destroy(ffi);
}
for (final texture in _gpuRenderTextures.values) {
await texture.destroy(closeSession, ffi);
await texture.destroy(ffi);
}
}
onViewCameraPageDispose(bool closeSession) async {
onViewCameraPageDispose() async {
final ffi = parent.target;
if (ffi == null) return;
for (final texture in _pixelbufferRenderTextures.values) {
await texture.destroy(closeSession, ffi);
await texture.destroy(ffi);
}
for (final texture in _gpuRenderTextures.values) {
await texture.destroy(closeSession, ffi);
await texture.destroy(ffi);
}
}

View File

@@ -1462,6 +1462,10 @@ class RustdeskImpl {
required int ptr,
dynamic hint}) {}
bool mainTextureRenderProbeSupported({dynamic hint}) {
return false;
}
void mainPushTextureProbeFrame({required int ptr, dynamic hint}) {}
int mainGetTextureProbeConsumed({required int ptr, dynamic hint}) {

View File

@@ -288,38 +288,44 @@ struct DisplaySessionInfo {
gpu_output_ptr: usize,
notify_render_type: Option<RenderType>,
// Watchdog: frames pushed to a texture the engine never consumes mean
// texture rendering is broken on this machine (black view while the
// connection works). Armed until the first consumption is observed, so a
// later minimized window cannot false-positive.
// texture rendering is broken (black view on a live connection). Armed
// until a consumption is observed since arming.
pushed_count: u64,
watchdog_pushed_at_sample: u64,
watchdog_consumed_base: Option<u64>,
watchdog_since: Option<Instant>,
watchdog_last_sample: Option<Instant>,
watchdog_stall_windows: u32,
watchdog_armed: bool,
}
impl DisplaySessionInfo {
fn reset_watchdog(&mut self) {
self.pushed_count = 0;
self.watchdog_pushed_at_sample = 0;
self.watchdog_consumed_base = None;
self.watchdog_since = None;
self.watchdog_last_sample = None;
self.watchdog_stall_windows = 0;
self.watchdog_armed = true;
}
// Returns true when texture rendering is deemed broken: >=3 sampled
// seconds in which frames kept being pushed but none was ever consumed.
// 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.
fn check_watchdog(&mut self, consumed: u64) -> bool {
if consumed > 0 {
let now = Instant::now();
let Some(base) = self.watchdog_consumed_base else {
self.watchdog_consumed_base = Some(consumed);
self.watchdog_since = Some(now);
return false;
};
if consumed > base {
self.watchdog_armed = false;
return false;
}
let pushed_in_window = self.pushed_count - self.watchdog_pushed_at_sample;
self.watchdog_pushed_at_sample = self.pushed_count;
if pushed_in_window >= 10 {
self.watchdog_stall_windows += 1;
}
if self.watchdog_stall_windows >= 3 {
if self.pushed_count >= 30
&& self
.watchdog_since
.map(|t| now.duration_since(t) >= Duration::from_secs(3))
.unwrap_or(false)
{
self.watchdog_armed = false;
return true;
}
@@ -342,9 +348,8 @@ impl DisplaySessionInfo {
}
// Video Texture Renderer in Flutter
// Each display entry has its own mutex so the per-frame plugin call only ever
// holds that display's lock; a stalled plugin/driver call must not back up
// the session-level locks (which would freeze every window's UI thread).
// Per-display mutexes: the per-frame plugin call must not hold session-level
// locks, or a stalled plugin/driver call freezes every window's UI thread.
#[derive(Clone)]
struct VideoRenderer {
is_support_multi_ui_session: bool,
@@ -503,11 +508,9 @@ impl VideoRenderer {
}
}
// Clear the pointer only if it still holds `ptr`. An unconditional clear
// could wipe out the registration a new window just made while a tab
// moves between windows (#8016); skipping the clear (the old behavior on
// move) left Rust pushing into a freed native texture. Waiting on the
// display mutex also drains an in-flight push through the old pointer.
// Compare-and-clear: an unconditional clear could wipe the registration a
// new window just made when a tab moves between windows (#8016); waiting
// on the display mutex also drains an in-flight push via the old pointer.
fn unregister_pixelbuffer_texture(&self, display: usize, ptr: usize) {
if ptr == 0 {
return;
@@ -622,10 +625,11 @@ impl VideoRenderer {
rgba.h
);
}
// The stream is the source of truth; if the sizes still
// disagree after this many frames the peer info is not
// coming, and dropping forever leaves a live session black.
if info.size_mismatch_count < 30 {
// If sizes still disagree after this many frames the peer info
// is not coming and dropping forever leaves a live session
// black. Legacy single-ui-session pairs frames loosely
// (values().next()), so only adopt where pairing is exact.
if !self.is_support_multi_ui_session || info.size_mismatch_count < 30 {
return false;
}
log::warn!(
@@ -2046,6 +2050,19 @@ pub fn session_unregister_gpu_texture(_session_id: SessionID, _display: usize, _
// Startup-probe plumbing: the main window pushes one frame into a throwaway
// 1x1 texture and polls whether the engine consumed it, validating the
// texture pipeline before any session depends on it.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub fn texture_render_probe_supported() -> bool {
match &*TEXTURE_RGBA_RENDERER_PLUGIN {
Ok(lib) => unsafe {
lib.symbol::<FlutterRgbaRendererPluginGetConsumed>(
"FlutterRgbaRendererPluginGetConsumed",
)
.is_ok()
},
Err(_) => false,
}
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub fn push_texture_probe_frame(ptr: usize) {
if ptr == 0 {
@@ -2059,7 +2076,8 @@ pub fn push_texture_probe_frame(ptr: usize) {
}) else {
return;
};
let frame: [u8; 4] = [255, 255, 255, 255];
// Fully transparent so the 1x1 probe pixel is invisible on any theme.
let frame: [u8; 4] = [0, 0, 0, 0];
unsafe { func(ptr as _, frame.as_ptr(), 4, 1, 1, 1) };
}
@@ -2079,12 +2097,15 @@ pub fn get_texture_probe_consumed(ptr: usize) -> u64 {
unsafe { func(ptr as _) }
}
// Frames are being pushed but the engine never consumes them: texture
// rendering does not work in this environment (GPU/driver/engine breakage).
// Fall back to software rendering for the live session and record it so the
// default flips to opt-in; the startup probe re-validates on later launches.
// Frames are being pushed but the engine never consumes them: fall back to
// 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) {
// One record per breakage; later fires (other displays/sessions) no-op.
if crate::ui_interface::texture_render_health_failed() {
return;
}
log::error!(
"texture rendering failed for session {}, falling back to software rendering",
session_id
@@ -2099,12 +2120,30 @@ fn on_texture_render_failed(session_id: SessionID) {
.unwrap_or(0)
),
);
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
session.push_event(
"use_texture_render",
&[("v", "N"), ("reason", "fallback")],
&[],
);
// Mirror main_set_local_option: every render session must observe the
// new effective value, not only the failing one.
for session in sessions::get_sessions() {
if !(session.is_default() || session.is_view_camera()) {
continue;
}
// The soft path cannot rescue multi-display windows; don't claim it.
let fallback_rescues = session
.ui_handler
.session_handlers
.read()
.unwrap()
.get(&session_id)
.map(|h| h.displays.len() <= 1)
.unwrap_or(false);
if fallback_rescues {
session.push_event(
"use_texture_render",
&[("v", "N"), ("reason", "fallback")],
&[],
);
} else {
session.push_event("use_texture_render", &[("v", "N")], &[]);
}
session.use_texture_render_changed();
session.ui_handler.update_use_texture_render();
}

View File

@@ -2322,6 +2322,13 @@ pub fn session_unregister_gpu_texture(
))
}
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());
#[cfg(any(target_os = "android", target_os = "ios"))]
SyncReturn(false)
}
pub fn main_push_texture_probe_frame(ptr: usize) -> SyncReturn<()> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
super::flutter::push_texture_probe_frame(ptr);

View File

@@ -176,10 +176,9 @@ pub fn get_option<T: AsRef<str>>(key: T) -> String {
}
}
// The watchdog/startup probe records texture-render breakage (frames pushed
// but never consumed by the engine). A failed record forces texture render
// off on every desktop platform — even an explicit "Y" — because the live
// fallback relies on it; toggling the option (or a passing probe) clears it.
// Watchdog/probe breakage record: a failure forces texture render off on all
// desktop platforms (even an explicit "Y") — the live fallback relies on it;
// toggling the option or a passing probe clears it.
#[inline]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub fn texture_render_health_failed() -> bool {