diff --git a/lib/main.dart b/lib/main.dart index 4c24acb98..5cb26fc9f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1268,6 +1268,20 @@ class _MainAppState extends State with WidgetsBindingObserver { if (_shutdownStarted) return; _shutdownStarted = true; + // Hide the window before anything else so the exit reads as an instant + // close: the teardown below runs against a still-mounted tree and its + // state churn must never be user-visible. Cmd+Q and OS-initiated exits + // arrive here directly, so the close-button path is not the only entry. + // Bounded — hide() is a platform-channel round trip and a stalled + // platform thread must not hold the already-accepted exit open. + if (PlatformDetector.isDesktopOS()) { + try { + await windowManager.hide().timeout(const Duration(seconds: 1)); + } catch (e, st) { + appLogger.w('Failed to hide window before exit teardown', error: e, stackTrace: st); + } + } + _syncDebounce?.cancel(); await _watchStateSubscription?.cancel(); _removeConnectivitySyncListener(); @@ -1280,7 +1294,7 @@ class _MainAppState extends State with WidgetsBindingObserver { await TrackerCoordinator.instance.stopPlayback().timeout(const Duration(seconds: 3), onTimeout: () {}); TrackerCoordinator.instance.cancelInFlight(); - await _serverManager.disconnectAllGracefully(); + await _serverManager.shutdown(); await Future.wait([ httpClient.closeGracefully(drainTimeout: const Duration(seconds: 5)), closeArtworkHttpClientGracefully(), diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 0f7dfb205..8b161b0ff 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -78,8 +78,13 @@ class MultiServerManager { Stream> get statusStream => _statusController.stream; /// Publish a snapshot of the per-server online map — subscribers must never - /// receive the live [_serverStatus] instance. - void _emitStatus() => _statusController.add(Map.from(_serverStatus)); + /// receive the live [_serverStatus] instance. A no-op once [shutdown] or + /// [dispose] closed the controller, so a health probe or reconnect landing + /// mid-exit cannot throw into a closed stream. + void _emitStatus() { + if (_statusController.isClosed) return; + _statusController.add(Map.from(_serverStatus)); + } /// Per-server connect progress during a bind. Unlike [statusStream] — whose /// first emission means "the binder's first connect pass finished" and which @@ -1447,6 +1452,20 @@ class MultiServerManager { return clients; } + /// Terminal app-exit teardown: closes the status streams first, then drains + /// every client connection. + /// + /// Unlike [disconnectAllGracefully] — whose empty snapshot profile-switch + /// and disconnect flows must observe — exit teardown must stay invisible: + /// the widget tree is still mounted while the exit request is serviced, and + /// an empty snapshot reads as "all servers gone", flipping the app into + /// offline UI and dismantling screens mid-shutdown. + Future shutdown({Duration drainTimeout = const Duration(seconds: 5)}) async { + if (!_statusController.isClosed) unawaited(_statusController.close()); + if (!_connectProgressController.isClosed) unawaited(_connectProgressController.close()); + await disconnectAllGracefully(drainTimeout: drainTimeout); + } + /// Dispose resources void dispose() { disconnectAll(); diff --git a/test/services/multi_server_manager_test.dart b/test/services/multi_server_manager_test.dart index 1972a361c..1cd45534f 100644 --- a/test/services/multi_server_manager_test.dart +++ b/test/services/multi_server_manager_test.dart @@ -1785,6 +1785,44 @@ void main() { }); }); + group('shutdown', () { + test('clears all state without emitting a snapshot and closes the stream', () async { + final m = MultiServerManager(); + addTearDown(m.dispose); + + m.updateServerStatus(ServerId('a'), true); + m.updateServerStatus(ServerId('b'), false); + + final emitted = >[]; + var done = false; + final sub = m.statusStream.listen(emitted.add, onDone: () => done = true); + addTearDown(sub.cancel); + + await m.shutdown(); + await Future.delayed(Duration.zero); + + // Exit teardown must stay invisible: an empty snapshot would flip the + // still-mounted UI into offline mode while the app shuts down. + expect(m.serverIds, isEmpty); + expect(m.onlineServerIds, isEmpty); + expect(m.offlineServerIds, isEmpty); + expect(emitted, isEmpty); + expect(done, isTrue); + }); + + test('a health result landing after shutdown is a silent no-op', () async { + final m = MultiServerManager(); + addTearDown(m.dispose); + + m.updateServerStatus(ServerId('a'), true); + await m.shutdown(); + + // A probe that was in flight when the exit began must not throw into + // the closed status controller. + expect(() => m.updateServerStatus(ServerId('a'), false), returnsNormally); + }); + }); + group('dispose', () { test('disposing without connectivity monitoring does not throw', () { final m = MultiServerManager();