diff --git a/flutter/lib/desktop/pages/remote_page.dart b/flutter/lib/desktop/pages/remote_page.dart index bed8b76e2..69983deed 100644 --- a/flutter/lib/desktop/pages/remote_page.dart +++ b/flutter/lib/desktop/pages/remote_page.dart @@ -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 widget.tabController?.state.listen(_onMacOSTabStateChanged); } Get.put(_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 // 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'); - } - }); - } -} diff --git a/flutter/lib/desktop/pages/view_camera_page.dart b/flutter/lib/desktop/pages/view_camera_page.dart index c45ec4d86..9c70e2879 100644 --- a/flutter/lib/desktop/pages/view_camera_page.dart +++ b/flutter/lib/desktop/pages/view_camera_page.dart @@ -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 super.initState(); _ffi = FFI(widget.sessionId); Get.put(_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 // 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); diff --git a/flutter/lib/desktop/widgets/raster_stall_monitor.dart b/flutter/lib/desktop/widgets/raster_stall_monitor.dart new file mode 100644 index 000000000..62535b65d --- /dev/null +++ b/flutter/lib/desktop/widgets/raster_stall_monitor.dart @@ -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'); + } + }); + } +} diff --git a/flutter/lib/desktop/widgets/tabbar_widget.dart b/flutter/lib/desktop/widgets/tabbar_widget.dart index 9ef7d38d9..adb24e371 100644 --- a/flutter/lib/desktop/widgets/tabbar_widget.dart +++ b/flutter/lib/desktop/widgets/tabbar_widget.dart @@ -405,6 +405,13 @@ class _DesktopTabState extends State 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) { diff --git a/flutter/lib/desktop/widgets/texture_render_probe.dart b/flutter/lib/desktop/widgets/texture_render_probe.dart index ba98d25e3..c76b22aac 100644 --- a/flutter/lib/desktop/widgets/texture_render_probe.dart +++ b/flutter/lib/desktop/widgets/texture_render_probe.dart @@ -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 { 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 { } void _onTimings(List timings) { + _lastTimings = DateTime.now(); if (_sawTimings) return; _sawTimings = true; - SchedulerBinding.instance.removeTimingsCallback(_onTimings); _start(); } @@ -75,7 +86,11 @@ class _TextureRenderProbeState extends State { 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 { 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 { @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 { @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)), diff --git a/flutter/lib/models/desktop_render_texture.dart b/flutter/lib/models/desktop_render_texture.dart index 289403e5e..2002da9cc 100644 --- a/flutter/lib/models/desktop_render_texture.dart +++ b/flutter/lib/models/desktop_render_texture.dart @@ -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); } } diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index f56319324..2769788b3 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -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}) { diff --git a/src/flutter.rs b/src/flutter.rs index 67723951d..1b48edcd8 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -288,38 +288,44 @@ struct DisplaySessionInfo { gpu_output_ptr: usize, notify_render_type: Option, // 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, + watchdog_since: Option, watchdog_last_sample: Option, - 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", + ) + .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(); } diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 47a999efb..28ab62069 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2322,6 +2322,13 @@ pub fn session_unregister_gpu_texture( )) } +pub fn main_texture_render_probe_supported() -> SyncReturn { + #[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); diff --git a/src/ui_interface.rs b/src/ui_interface.rs index edb5ee62f..232e3fb6a 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -176,10 +176,9 @@ pub fn get_option>(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 {