fix(startup): recover failed server bind
This commit is contained in:
+26
-2
@@ -385,6 +385,11 @@ void _registerShaderLicenses() {
|
|||||||
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
||||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
bool shouldEnterOfflineModeAfterStartupBind({required bool bindingSucceeded, required bool hasOnlineServers}) {
|
||||||
|
return !bindingSucceeded && !hasOnlineServers;
|
||||||
|
}
|
||||||
|
|
||||||
/// Top-level PIN prompt used by [ActiveProfileBinder] when it runs above the
|
/// Top-level PIN prompt used by [ActiveProfileBinder] when it runs above the
|
||||||
/// per-screen widget tree. Routes through [rootNavigatorKey] so the dialog
|
/// per-screen widget tree. Routes through [rootNavigatorKey] so the dialog
|
||||||
/// renders correctly whether the binder fires from the splash, MainScreen,
|
/// renders correctly whether the binder fires from the splash, MainScreen,
|
||||||
@@ -1127,7 +1132,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
|||||||
// Reading the binder here is enough — the Provider is `lazy: false` so
|
// Reading the binder here is enough — the Provider is `lazy: false` so
|
||||||
// it has already constructed the binder and called `start()` during
|
// it has already constructed the binder and called `start()` during
|
||||||
// MultiProvider build. We just need to wait for it.
|
// MultiProvider build. We just need to wait for it.
|
||||||
context.read<ActiveProfileBinder>();
|
final binder = context.read<ActiveProfileBinder>();
|
||||||
final downloadProvider = context.read<DownloadProvider>();
|
final downloadProvider = context.read<DownloadProvider>();
|
||||||
|
|
||||||
// Wait for the active profile to load from disk so the binder has a
|
// Wait for the active profile to load from disk so the binder has a
|
||||||
@@ -1160,19 +1165,38 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
|||||||
settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles;
|
settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles;
|
||||||
final shouldPrompt = hasNoActive || requireOnOpen;
|
final shouldPrompt = hasNoActive || requireOnOpen;
|
||||||
|
|
||||||
|
var bindingSucceeded = activeProfile.lastBindingSucceeded;
|
||||||
if (shouldPrompt) {
|
if (shouldPrompt) {
|
||||||
await Navigator.of(
|
await Navigator.of(
|
||||||
context,
|
context,
|
||||||
).push(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen(requireSelection: true)));
|
).push(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen(requireSelection: true)));
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
bindingSucceeded = activeProfile.active != null && activeProfile.lastBindingSucceeded;
|
||||||
} else {
|
} else {
|
||||||
// Now wait for the binder to settle. This is the Plex/Jellyfin server
|
// Now wait for the binder to settle. This is the Plex/Jellyfin server
|
||||||
// race: per-server status flips on the splash list as each client comes
|
// race: per-server status flips on the splash list as each client comes
|
||||||
// online, and we don't push MainScreen until they're all done (success
|
// online, and we don't push MainScreen until they're all done (success
|
||||||
// or fail). Eliminates the "Failed to load discover content: No servers
|
// or fail). Eliminates the "Failed to load discover content: No servers
|
||||||
// available" race the old eager-navigate flow caused.
|
// available" race the old eager-navigate flow caused.
|
||||||
await activeProfile.awaitBindingSettle();
|
bindingSucceeded = await activeProfile.awaitBindingSettle();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
if (!bindingSucceeded) {
|
||||||
|
appLogger.w('Setup: initial profile bind failed; retrying once before entering main screen');
|
||||||
|
await binder.rebindActive();
|
||||||
|
if (!mounted) return;
|
||||||
|
bindingSucceeded = activeProfile.lastBindingSucceeded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldEnterOfflineModeAfterStartupBind(
|
||||||
|
bindingSucceeded: bindingSucceeded,
|
||||||
|
hasOnlineServers: _serverManagerFromContext().onlineServerIds.isNotEmpty,
|
||||||
|
)) {
|
||||||
|
appLogger.w('Setup: no servers online after startup bind; starting offline mode');
|
||||||
|
await downloadProvider.ensureInitialized();
|
||||||
|
if (!mounted) return;
|
||||||
|
unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true))));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repopulate metadata for downloaded items now that per-backend caches
|
// Repopulate metadata for downloaded items now that per-backend caches
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ class MainScreenFocusScope extends InheritedWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
bool shouldRetryActiveProfileBindAfterReconnect({
|
||||||
|
required bool hasActiveProfile,
|
||||||
|
required bool hasVisibleConnectedServers,
|
||||||
|
required bool hasManagerOnlineServers,
|
||||||
|
required bool hasKnownOfflineServers,
|
||||||
|
}) {
|
||||||
|
return hasActiveProfile && !hasVisibleConnectedServers && (hasManagerOnlineServers || !hasKnownOfflineServers);
|
||||||
|
}
|
||||||
|
|
||||||
class MainScreen extends StatefulWidget {
|
class MainScreen extends StatefulWidget {
|
||||||
final bool isOfflineMode;
|
final bool isOfflineMode;
|
||||||
|
|
||||||
@@ -576,16 +586,20 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
|
|
||||||
// Listen for offline/online transitions to refresh navigation & screens
|
// Listen for offline/online transitions to refresh navigation & screens.
|
||||||
// Note: We don't call _handleOfflineStatusChanged() immediately because
|
// `widget.isOfflineMode` stays authoritative when SetupScreen explicitly
|
||||||
// widget.isOfflineMode (from SetupScreen navigation) is authoritative for
|
// routed here offline, but if the provider already observed a failed bind
|
||||||
// initial state. The provider may not yet have received the server status
|
// before this listener attached, mirror that missed state after build.
|
||||||
// update due to initialization timing. The listener handles runtime changes.
|
|
||||||
final provider = context.read<OfflineModeProvider?>();
|
final provider = context.read<OfflineModeProvider?>();
|
||||||
if (provider != null && provider != _offlineModeProvider) {
|
if (provider != null && provider != _offlineModeProvider) {
|
||||||
_offlineModeProvider?.removeListener(_handleOfflineStatusChanged);
|
_offlineModeProvider?.removeListener(_handleOfflineStatusChanged);
|
||||||
_offlineModeProvider = provider;
|
_offlineModeProvider = provider;
|
||||||
_offlineModeProvider!.addListener(_handleOfflineStatusChanged);
|
_offlineModeProvider!.addListener(_handleOfflineStatusChanged);
|
||||||
|
if (!widget.isOfflineMode && !_isOffline && provider.isOffline) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _handleOfflineStatusChanged();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for Live TV / DVR availability changes
|
// Listen for Live TV / DVR availability changes
|
||||||
@@ -793,13 +807,25 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
if (_isReconnecting) return;
|
if (_isReconnecting) return;
|
||||||
setState(() => _isReconnecting = true);
|
setState(() => _isReconnecting = true);
|
||||||
|
|
||||||
final serverManager = context.read<MultiServerProvider>().serverManager;
|
final multiServerProvider = context.read<MultiServerProvider>();
|
||||||
|
final serverManager = multiServerProvider.serverManager;
|
||||||
|
final activeProfile = context.read<ActiveProfileProvider>();
|
||||||
|
final binder = context.read<ActiveProfileBinder>();
|
||||||
unawaited(() async {
|
unawaited(() async {
|
||||||
try {
|
try {
|
||||||
// Health check first so stale "online" servers get marked offline before
|
// Health check first so stale "online" servers get marked offline before
|
||||||
// we snapshot the offline list for reconnection.
|
// we snapshot the offline list for reconnection.
|
||||||
await serverManager.checkServerHealth();
|
await serverManager.checkServerHealth();
|
||||||
await serverManager.reconnectOfflineServers(forceRediscovery: true);
|
await serverManager.reconnectOfflineServers(forceRediscovery: true);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (shouldRetryActiveProfileBindAfterReconnect(
|
||||||
|
hasActiveProfile: activeProfile.active != null,
|
||||||
|
hasVisibleConnectedServers: multiServerProvider.hasConnectedServers,
|
||||||
|
hasManagerOnlineServers: serverManager.onlineServerIds.isNotEmpty,
|
||||||
|
hasKnownOfflineServers: serverManager.offlineServerIds.isNotEmpty,
|
||||||
|
)) {
|
||||||
|
await binder.rebindActive();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setStateIfMounted(() => _isReconnecting = false);
|
setStateIfMounted(() => _isReconnecting = false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/main.dart';
|
||||||
|
import 'package:plezy/screens/main_screen.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('startup bind recovery', () {
|
||||||
|
test('enters offline mode only when initial bind failed with no online servers', () {
|
||||||
|
expect(shouldEnterOfflineModeAfterStartupBind(bindingSucceeded: false, hasOnlineServers: false), isTrue);
|
||||||
|
expect(shouldEnterOfflineModeAfterStartupBind(bindingSucceeded: true, hasOnlineServers: false), isFalse);
|
||||||
|
expect(shouldEnterOfflineModeAfterStartupBind(bindingSucceeded: false, hasOnlineServers: true), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retries active profile bind when reconnect has no visible servers', () {
|
||||||
|
expect(
|
||||||
|
shouldRetryActiveProfileBindAfterReconnect(
|
||||||
|
hasActiveProfile: true,
|
||||||
|
hasVisibleConnectedServers: false,
|
||||||
|
hasManagerOnlineServers: true,
|
||||||
|
hasKnownOfflineServers: false,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldRetryActiveProfileBindAfterReconnect(
|
||||||
|
hasActiveProfile: true,
|
||||||
|
hasVisibleConnectedServers: false,
|
||||||
|
hasManagerOnlineServers: false,
|
||||||
|
hasKnownOfflineServers: false,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldRetryActiveProfileBindAfterReconnect(
|
||||||
|
hasActiveProfile: true,
|
||||||
|
hasVisibleConnectedServers: true,
|
||||||
|
hasManagerOnlineServers: true,
|
||||||
|
hasKnownOfflineServers: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldRetryActiveProfileBindAfterReconnect(
|
||||||
|
hasActiveProfile: false,
|
||||||
|
hasVisibleConnectedServers: false,
|
||||||
|
hasManagerOnlineServers: true,
|
||||||
|
hasKnownOfflineServers: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldRetryActiveProfileBindAfterReconnect(
|
||||||
|
hasActiveProfile: true,
|
||||||
|
hasVisibleConnectedServers: false,
|
||||||
|
hasManagerOnlineServers: false,
|
||||||
|
hasKnownOfflineServers: true,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user