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

@@ -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)),