fix: cooldown resume health probes, debounce connectivity, coalesce concurrent checks

Add per-platform cooldown on resume health probes (10s mobile, 2min
desktop). Debounce connectivity listener by 2s. Coalesce concurrent
checkServerHealth and reconnectOfflineServers into single in-flight
futures.
This commit is contained in:
edde746
2026-03-12 02:52:14 +01:00
parent f04b49e260
commit 912ebc2f90
2 changed files with 69 additions and 14 deletions
+15 -3
View File
@@ -301,6 +301,9 @@ class _MainAppState extends State<MainApp> 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<MainApp> 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
+54 -11
View File
@@ -38,6 +38,15 @@ class MultiServerManager {
/// Debounce timers for endpoint-exhaustion-triggered reconnection (per server)
final Map<String, Timer> _reconnectDebounce = {};
/// Coalescing guard for checkServerHealth — prevents concurrent health checks
Future<void>? _activeHealthCheck;
/// Coalescing guard for reconnectOfflineServers — prevents concurrent reconnect sweeps
Future<void>? _activeReconnect;
/// Debounce timer for connectivity events — collapses rapid network flapping
Timer? _connectivityDebounce;
/// Get all registered server IDs
List<String> 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<void> 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<void> _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<void> 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<void> _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();