fix: texture render lifetime protocol, watchdog fallback, startup probe

Root cause of #15848 (and the long-standing macOS #6296 / Linux #3343
class): raw native texture pointers are shared across the platform
thread, the engine raster thread and the video thread, with teardown
ordered by a 100 ms sleep - or, when moving a tab to a new window, by
nothing at all. A lost race frees the texture while it is still in use:
the raster thread parks on a destroyed lock (frozen/black view, a
never-presented 'transparent' hole, every later session black) and the
video thread hangs while holding session locks (app half-dead until
restart, still reported as Responding).

- unregister textures with compare-and-clear (new session_unregister_*
  FFI): a late clear can no longer wipe a new window's registration
  (#8016) and Rust never keeps pushing into a freed texture; the 100 ms
  sleeps are gone (the plugins now drain in-flight pushes and defer
  object deletion until the raster thread is done)
- guard the async texture create path against destroy racing it (#13596)
- per-display locks: the per-frame plugin call no longer holds
  session-level locks, so a stalled plugin or driver call cannot freeze
  every window's UI thread
- adopt the frame size after 30 consecutive mismatches instead of
  dropping frames forever (silent black screen on a live connection)
- watchdog: frames pushed but never consumed by the engine fall the
  session back to software rendering live, record texture-render-health,
  and flip the effective default off; toggling the option clears the
  record and re-arms validation
- Dart raster-stall monitor records a hung raster thread for the next
  launch (rendering cannot be rescued in-process in that state)
- startup probe: render one frame through a 1x1 texture in the main
  window each launch; failure disables texture rendering before the
  first session goes black, a pass self-heals a stale failure record

Platform defaults are unchanged (macOS off, Win10+ on, Linux on).
Requires flutter_texture_rgba_renderer ad4c37e and
flutter_gpu_texture_renderer 767bb9f (pinned in pubspec).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rustdesk
2026-08-13 09:54:17 +08:00
parent c4fd7d692d
commit 7c23e1f4b9
66 changed files with 780 additions and 114 deletions

View File

@@ -88,6 +88,7 @@ const String kOptionEdgeScrollEdgeThickness = "edge-scroll-edge-thickness";
const String kOptionImageQuality = "image_quality";
const String kOptionOpenNewConnInTabs = "enable-open-new-connections-in-tabs";
const String kOptionTextureRender = "use-texture-render";
const String kOptionTextureRenderHealth = "texture-render-health";
const String kOptionD3DRender = "allow-d3d-render";
const String kOptionOpenInTabs = "allow-open-in-tabs";
const String kOptionOpenInWindows = "allow-open-in-windows";

View File

@@ -25,6 +25,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:window_manager/window_manager.dart';
import 'package:window_size/window_size.dart' as window_size;
import '../widgets/button.dart';
import '../widgets/texture_render_probe.dart';
class DesktopHomePage extends StatefulWidget {
const DesktopHomePage({Key? key}) : super(key: key);
@@ -60,15 +61,20 @@ class _DesktopHomePageState extends State<DesktopHomePage>
Widget build(BuildContext context) {
super.build(context);
final isIncomingOnly = bind.isIncomingOnly();
return _buildBlock(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
return Stack(
children: [
buildLeftPane(context),
if (!isIncomingOnly) const VerticalDivider(width: 1),
if (!isIncomingOnly) Expanded(child: buildRightPane(context)),
_buildBlock(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildLeftPane(context),
if (!isIncomingOnly) const VerticalDivider(width: 1),
if (!isIncomingOnly) Expanded(child: buildRightPane(context)),
],
)),
const Positioned(left: 0, top: 0, child: TextureRenderProbe()),
],
));
);
}
Widget _buildBlock({required Widget child}) {

View File

@@ -156,6 +156,7 @@ class _RemotePageState extends State<RemotePage>
widget.tabController?.state.listen(_onMacOSTabStateChanged);
}
Get.put<FFI>(_ffi, tag: widget.id);
_RasterStallMonitor.start();
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
_ffi.canvasModel.activateLocalCursor();
showKBLayoutTypeChooserIfNeeded(
@@ -1402,3 +1403,45 @@ 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

@@ -0,0 +1,131 @@
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 '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.
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;
@override
void initState() {
super.initState();
if (_ranThisLaunch || isWeb || !isDesktop) return;
_ranThisLaunch = true;
// 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) {
if (_sawTimings) return;
_sawTimings = true;
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
_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;
bind.mainPushTextureProbeFrame(ptr: _ptr);
if (bind.mainGetTextureProbeConsumed(ptr: _ptr) > 0) {
_finish(true);
} else if (_ticks >= 10) {
_finish(false);
}
});
}
void _finish(bool? ok) {
_timer?.cancel();
_timer = null;
if (ok != null) {
final old = bind.mainGetLocalOption(key: kOptionTextureRenderHealth);
if (ok) {
if (old != 'ok') {
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 (_textureKey != -1) {
_renderer.closeTexture(_textureKey);
_textureKey = -1;
}
_ptr = 0;
if (mounted && _textureId != -1) {
setState(() => _textureId = -1);
} else {
_textureId = -1;
}
}
@override
void dispose() {
_timer?.cancel();
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; 1x1 in a
// corner is imperceptible.
return IgnorePointer(
child: SizedBox(
width: 1, height: 1, child: Texture(textureId: _textureId)),
);
}
}

View File

@@ -16,6 +16,8 @@ class _PixelbufferTexture {
int _display = 0;
SessionID? _sessionId;
bool _destroying = false;
bool _closed = false;
int _ptr = 0;
int? _id;
final textureRenderer = TextureRgbaRenderer();
@@ -27,11 +29,22 @@ class _PixelbufferTexture {
_textureKey = bind.getNextTextureKey();
_sessionId = sessionId;
textureRenderer.createTexture(_textureKey).then((id) async {
final textureKey = _textureKey;
textureRenderer.createTexture(textureKey).then((id) async {
_id = id;
if (id != -1) {
if (_closed) {
// Destroyed while creation was still in flight (rapid
// connect/disconnect); nobody else will close this texture.
await textureRenderer.closeTexture(textureKey);
return;
}
ffi.textureModel.setRgbaTextureId(display: d, id: id);
final ptr = await textureRenderer.getTexturePtr(_textureKey);
final ptr = await textureRenderer.getTexturePtr(textureKey);
if (_closed) {
return;
}
_ptr = ptr;
platformFFI.registerPixelbufferTexture(sessionId, display, ptr);
debugPrint(
"create pixelbuffer texture: peerId: ${ffi.id} display:$_display, textureId:$id, texturePtr:$ptr");
@@ -39,13 +52,17 @@ class _PixelbufferTexture {
});
}
destroy(bool unregisterTexture, FFI ffi) async {
destroy(bool closeSession, FFI ffi) async {
_closed = true;
if (!_destroying && _textureKey != -1 && _sessionId != null) {
_destroying = true;
if (unregisterTexture) {
platformFFI.registerPixelbufferTexture(_sessionId!, display, 0);
// sleep for a while to avoid the texture is used after it's unregistered.
await Future.delayed(Duration(milliseconds: 100));
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.
platformFFI.unregisterPixelbufferTexture(_sessionId!, display, _ptr);
_ptr = 0;
}
await textureRenderer.closeTexture(_textureKey);
_textureKey = -1;
@@ -61,6 +78,7 @@ class _GpuTexture {
SessionID? _sessionId;
final support = bind.mainHasGpuTextureRender();
bool _destroying = false;
bool _closed = false;
int _display = 0;
int? _id;
int? _output;
@@ -79,9 +97,18 @@ class _GpuTexture {
gpuTextureRenderer.registerTexture().then((id) async {
_id = id;
if (id != null) {
if (_closed) {
// Destroyed while creation was still in flight (rapid
// connect/disconnect); nobody else will unregister this texture.
await gpuTextureRenderer.unregisterTexture(id);
return;
}
_textureId = id;
ffi.textureModel.setGpuTextureId(display: d, id: id);
final output = await gpuTextureRenderer.output(id);
if (_closed) {
return;
}
_output = output;
if (output != null) {
platformFFI.registerGpuTexture(sessionId, d, output);
@@ -95,20 +122,22 @@ class _GpuTexture {
}
}
destroy(bool unregisterTexture, FFI ffi) async {
destroy(bool closeSession, FFI ffi) async {
// must stop texture render, render unregistered texture cause crash
_closed = true;
if (!_destroying && support && _sessionId != null && _textureId != -1) {
_destroying = true;
if (unregisterTexture) {
platformFFI.registerGpuTexture(_sessionId!, _display, 0);
// sleep for a while to avoid the texture is used after it's unregistered.
await Future.delayed(Duration(milliseconds: 100));
final output = _output;
if (output != null) {
// Compare-and-clear, see _PixelbufferTexture.destroy.
platformFFI.unregisterGpuTexture(_sessionId!, _display, output);
_output = null;
}
await gpuTextureRenderer.unregisterTexture(_textureId);
_textureId = -1;
_destroying = false;
debugPrint(
"destroy gpu texture: peerId: ${ffi.id} display:$_display, textureId:$_id, output:$_output");
"destroy gpu texture: peerId: ${ffi.id} display:$_display, textureId:$_id, output:$output");
}
}
}

View File

@@ -735,6 +735,11 @@ class FfiModel with ChangeNotifier {
_handleUseTextureRender(
Map<String, dynamic> evt, SessionID sessionId, String peerId) {
parent.target?.imageModel.setUseTextureRender(evt['v'] == 'Y');
if (evt['reason'] == 'fallback') {
// The Rust watchdog detected that pushed frames were never rendered
// and switched this session to software rendering.
showToast(translate('texture-render-fallback-tip'));
}
waitForFirstImage.value = true;
isRefreshing = true;
showConnectedWaitingForImage(parent.target!.dialogManager, sessionId,

View File

@@ -131,6 +131,12 @@ class PlatformFFI {
void registerGpuTexture(SessionID sessionId, int display, int ptr) =>
_ffiBind.sessionRegisterGpuTexture(
sessionId: sessionId, display: display, ptr: ptr);
void unregisterPixelbufferTexture(SessionID sessionId, int display, int ptr) =>
_ffiBind.sessionUnregisterPixelbufferTexture(
sessionId: sessionId, display: display, ptr: ptr);
void unregisterGpuTexture(SessionID sessionId, int display, int ptr) =>
_ffiBind.sessionUnregisterGpuTexture(
sessionId: sessionId, display: display, ptr: ptr);
/// Init the FFI class, loads the native Rust core library.
Future<void> init(String appType) async {

View File

@@ -136,6 +136,12 @@ class PlatformFFI {
void registerGpuTexture(SessionID sessionId, int display, int ptr) =>
_ffiBind.sessionRegisterGpuTexture(
sessionId: sessionId, display: display, ptr: ptr);
void unregisterPixelbufferTexture(SessionID sessionId, int display, int ptr) =>
_ffiBind.sessionUnregisterPixelbufferTexture(
sessionId: sessionId, display: display, ptr: ptr);
void unregisterGpuTexture(SessionID sessionId, int display, int ptr) =>
_ffiBind.sessionUnregisterGpuTexture(
sessionId: sessionId, display: display, ptr: ptr);
Future<void> init(String appType) async {
Completer completer = Completer();

View File

@@ -1450,6 +1450,24 @@ class RustdeskImpl {
required int ptr,
dynamic hint}) {}
void sessionUnregisterPixelbufferTexture(
{required UuidValue sessionId,
required int display,
required int ptr,
dynamic hint}) {}
void sessionUnregisterGpuTexture(
{required UuidValue sessionId,
required int display,
required int ptr,
dynamic hint}) {}
void mainPushTextureProbeFrame({required int ptr, dynamic hint}) {}
int mainGetTextureProbeConsumed({required int ptr, dynamic hint}) {
return 0;
}
Future<void> queryOnlines({required List<String> ids, dynamic hint}) {
return Future(() =>
js.context.callMethod('setByName', ['query_onlines', jsonEncode(ids)]));

View File

@@ -538,8 +538,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "08a471bb8ceccdd50483c81cdfa8b81b07b14b87"
resolved-ref: "08a471bb8ceccdd50483c81cdfa8b81b07b14b87"
ref: "767bb9fe9dcd2c23e2664114bf33760842e872e7"
resolved-ref: "767bb9fe9dcd2c23e2664114bf33760842e872e7"
url: "https://github.com/rustdesk-org/flutter_gpu_texture_renderer"
source: git
version: "0.0.1"
@@ -1298,8 +1298,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "42797e0f03141dc2b585f76c64a13974508058b4"
resolved-ref: "42797e0f03141dc2b585f76c64a13974508058b4"
ref: "ad4c37e414ee40853c0e9503b2da6486d4c1768d"
resolved-ref: "ad4c37e414ee40853c0e9503b2da6486d4c1768d"
url: "https://github.com/rustdesk-org/flutter_texture_rgba_renderer"
source: git
version: "0.0.16"

View File

@@ -88,13 +88,13 @@ dependencies:
texture_rgba_renderer:
git:
url: https://github.com/rustdesk-org/flutter_texture_rgba_renderer
ref: 42797e0f03141dc2b585f76c64a13974508058b4
ref: ad4c37e414ee40853c0e9503b2da6486d4c1768d
percent_indicator: ^4.2.2
dropdown_button2: ^2.0.0
flutter_gpu_texture_renderer:
git:
url: https://github.com/rustdesk-org/flutter_gpu_texture_renderer
ref: 08a471bb8ceccdd50483c81cdfa8b81b07b14b87
ref: 767bb9fe9dcd2c23e2664114bf33760842e872e7
uuid: ^3.0.7
auto_size_text_field: ^2.2.1
flex_color_picker: ^3.3.0