Files
rustdesk/flutter/lib/desktop/widgets/texture_render_probe.dart
rustdesk 24a16d9a30 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>
2026-08-13 12:30:15 +08:00

173 lines
5.6 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
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
/// 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);
@override
State<TextureRenderProbe> createState() => _TextureRenderProbeState();
}
class _TextureRenderProbeState extends State<TextureRenderProbe> {
static bool _ranThisLaunch = false;
final _renderer = TextureRgbaRenderer();
int _textureId = -1;
int _textureKey = -1;
int _ptr = 0;
Timer? _timer;
int _ticks = 0;
bool _sawTimings = false;
bool _wasEffectiveOn = false;
DateTime? _lastTimings;
DateTime? _firstPush;
@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);
Future.delayed(const Duration(seconds: 5), () {
if (!_sawTimings) {
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
_finish(null);
}
});
}
void _onTimings(List<FrameTiming> timings) {
_lastTimings = DateTime.now();
if (_sawTimings) return;
_sawTimings = true;
_start();
}
void _start() async {
if (!mounted) return;
_textureKey = bind.getNextTextureKey();
final id = await _renderer.createTexture(_textureKey);
if (!mounted || id == -1) {
_finish(!mounted ? null : false);
return;
}
_ptr = await _renderer.getTexturePtr(_textureKey);
if (!mounted || _ptr <= 0) {
_finish(!mounted ? null : false);
return;
}
setState(() => _textureId = id);
_timer = Timer.periodic(const Duration(milliseconds: 100), (_) {
_ticks += 1;
_firstPush ??= DateTime.now();
bind.mainPushTextureProbeFrame(ptr: _ptr);
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 && active && timingsFresh
? false
: null);
}
});
}
void _finish(bool? ok) {
_timer?.cancel();
_timer = null;
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
if (ok != null) {
final old = bind.mainGetLocalOption(key: kOptionTextureRenderHealth);
if (ok) {
// 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')) {
debugPrint('texture render probe failed, disabling texture rendering');
bind.mainSetLocalOption(
key: kOptionTextureRenderHealth, value: 'failed-probe');
if (_wasEffectiveOn) {
showToast(translate('texture-render-fallback-tip'));
}
}
}
if (_textureKey != -1) {
_renderer.closeTexture(_textureKey);
_textureKey = -1;
}
_ptr = 0;
if (mounted && _textureId != -1) {
setState(() => _textureId = -1);
} else {
_textureId = -1;
}
}
@override
void dispose() {
_timer?.cancel();
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
if (_textureKey != -1) {
_renderer.closeTexture(_textureKey);
_textureKey = -1;
}
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_textureId == -1) return const SizedBox.shrink();
// 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)),
);
}
}