diff --git a/lib/main.dart b/lib/main.dart index b0534e57..646f07e2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -301,6 +301,9 @@ class _MainAppState extends State with WidgetsBindingObserver { late final DownloadManagerService _downloadManager; late final OfflineWatchSyncService _offlineWatchSyncService; + /// Last time server health probes ran from a resume event (cooldown for desktop) + DateTime _lastResumeProbe = DateTime(0); + @override void initState() { super.initState(); @@ -335,9 +338,18 @@ class _MainAppState extends State with WidgetsBindingObserver { // App came back to foreground - trigger sync check and start new session _offlineWatchSyncService.onAppResumed(); InAppReviewService.instance.startSession(); - // Re-probe servers — mobile OS may have dropped TCP connections during doze/sleep - _serverManager.checkServerHealth(); - _serverManager.reconnectOfflineServers(); + // Re-probe servers — mobile OS may have dropped TCP connections during doze/sleep. + // On desktop, resumed fires on every window focus (alt-tab), so apply a cooldown + // to avoid piling up network probes from rapid alt-tabbing. + final now = DateTime.now(); + final cooldown = (Platform.isIOS || Platform.isAndroid) + ? const Duration(seconds: 10) + : const Duration(minutes: 2); + if (now.difference(_lastResumeProbe) >= cooldown) { + _lastResumeProbe = now; + _serverManager.checkServerHealth(); + _serverManager.reconnectOfflineServers(); + } case AppLifecycleState.paused: case AppLifecycleState.detached: // App went to background or is closing - end session diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 6a3bab30..7c637017 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -38,6 +38,15 @@ class MultiServerManager { /// Debounce timers for endpoint-exhaustion-triggered reconnection (per server) final Map _reconnectDebounce = {}; + /// Coalescing guard for checkServerHealth — prevents concurrent health checks + Future? _activeHealthCheck; + + /// Coalescing guard for reconnectOfflineServers — prevents concurrent reconnect sweeps + Future? _activeReconnect; + + /// Debounce timer for connectivity events — collapses rapid network flapping + Timer? _connectivityDebounce; + /// Get all registered server IDs List get serverIds => _servers.keys.toList(); @@ -292,6 +301,18 @@ class MultiServerManager { /// Uses [PlexClient.isHealthy] which checks for HTTP 200, so servers with /// invalid tokens (401) are correctly reported as offline. Future checkServerHealth() async { + // Coalesce concurrent calls — return the in-flight future if one exists + if (_activeHealthCheck != null) return _activeHealthCheck!; + + _activeHealthCheck = _doCheckServerHealth(); + try { + await _activeHealthCheck; + } finally { + _activeHealthCheck = null; + } + } + + Future _doCheckServerHealth() async { appLogger.d('Checking health for ${_clients.length} servers'); final healthChecks = _clients.entries.map((entry) async { @@ -327,18 +348,24 @@ class MultiServerManager { return; } - appLogger.d( - 'Connectivity change detected, re-optimizing all servers', - error: { - 'status': status.name, - 'interfaces': results.map((r) => r.name).toList(), - 'serverCount': _servers.length, - }, - ); + // Debounce rapid connectivity events (e.g. WiFi flapping) into a single trigger + _connectivityDebounce?.cancel(); + _connectivityDebounce = Timer(const Duration(seconds: 2), () { + _connectivityDebounce = null; - // Re-optimize all servers and re-probe offline ones - _reoptimizeAllServers(reason: 'connectivity:${status.name}'); - checkServerHealth(); + appLogger.d( + 'Connectivity change detected, re-optimizing all servers', + error: { + 'status': status.name, + 'interfaces': results.map((r) => r.name).toList(), + 'serverCount': _servers.length, + }, + ); + + // Re-optimize all servers and re-probe offline ones + _reoptimizeAllServers(reason: 'connectivity:${status.name}'); + checkServerHealth(); + }); }, onError: (error, stackTrace) { appLogger.w('Connectivity listener error', error: error, stackTrace: stackTrace); @@ -353,6 +380,8 @@ class MultiServerManager { void stopNetworkMonitoring() { _connectivitySubscription?.cancel(); _connectivitySubscription = null; + _connectivityDebounce?.cancel(); + _connectivityDebounce = null; appLogger.i('Stopped network monitoring'); } @@ -441,6 +470,18 @@ class MultiServerManager { /// Attempt reconnection for all offline servers Future reconnectOfflineServers() async { + // Coalesce concurrent calls — return the in-flight future if one exists + if (_activeReconnect != null) return _activeReconnect!; + + _activeReconnect = _doReconnectOfflineServers(); + try { + await _activeReconnect; + } finally { + _activeReconnect = null; + } + } + + Future _doReconnectOfflineServers() async { final offline = offlineServerIds; if (offline.isEmpty) return; @@ -501,6 +542,8 @@ class MultiServerManager { timer.cancel(); } _reconnectDebounce.clear(); + _activeHealthCheck = null; + _activeReconnect = null; _clients.clear(); _servers.clear(); _serverStatus.clear();