diff --git a/it0_app/lib/core/network/websocket_client.dart b/it0_app/lib/core/network/websocket_client.dart index 3596e79..552bf8f 100644 --- a/it0_app/lib/core/network/websocket_client.dart +++ b/it0_app/lib/core/network/websocket_client.dart @@ -3,11 +3,13 @@ import 'dart:convert'; import 'dart:math'; import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:web_socket_channel/status.dart' as status; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../config/app_config.dart'; class WebSocketClient { WebSocketChannel? _channel; + StreamSubscription? _streamSubscription; final String baseUrl; Timer? _heartbeatTimer; Timer? _reconnectTimer; @@ -30,16 +32,13 @@ class WebSocketClient { _lastPath = path; _lastToken = token; - // Close previous connection to prevent duplicate event delivery. - // Fire-and-forget: don't await close() as it can hang waiting for - // the server to acknowledge the close handshake. - if (_channel != null) { - _heartbeatTimer?.cancel(); - final old = _channel!; - _channel = null; - _isConnected = false; - unawaited(old.sink.close().catchError((_) {})); - } + // Tear down previous connection to prevent duplicate event delivery. + // 1) Cancel the stream subscription first — stops events from reaching + // _messageController immediately (no more duplicates). + // 2) Fire-and-forget sink.close() — don't await because + // IOWebSocketChannel.sink.close() can hang indefinitely + // (known issue: https://github.com/dart-lang/web_socket_channel/issues/185). + await _closeCurrentConnection(); final uri = Uri.parse('$baseUrl$path'); try { @@ -50,7 +49,7 @@ class WebSocketClient { _isConnected = true; _reconnectAttempts = 0; - _channel!.stream.listen( + _streamSubscription = _channel!.stream.listen( (data) { final decoded = jsonDecode(data as String) as Map; _messageController.add(decoded); @@ -122,9 +121,28 @@ class WebSocketClient { Future disconnect() async { _heartbeatTimer?.cancel(); _reconnectTimer?.cancel(); - _isConnected = false; _reconnectAttempts = _maxReconnectAttempts; // Prevent auto-reconnect - await _channel?.sink.close(); + await _closeCurrentConnection(); + } + + /// Cancel stream subscription, then fire-and-forget sink.close(). + Future _closeCurrentConnection() async { + _heartbeatTimer?.cancel(); + _isConnected = false; + + // Cancel subscription first — this immediately stops events from + // the old channel reaching _messageController. + if (_streamSubscription != null) { + await _streamSubscription!.cancel(); + _streamSubscription = null; + } + + // Close the underlying WebSocket. Don't await — sink.close() can hang. + if (_channel != null) { + final old = _channel!; + _channel = null; + unawaited(old.sink.close(status.goingAway).catchError((_) {})); + } } void dispose() {