From 7b71a4f2fc11dea0bf792bfc902cde0ae44bfc4d Mon Sep 17 00:00:00 2001 From: hailin Date: Wed, 25 Feb 2026 03:45:43 -0800 Subject: [PATCH] fix: properly close WebSocket with subscription cancel + fire-and-forget Root cause: IOWebSocketChannel.sink.close() can hang indefinitely (dart-lang/web_socket_channel#185). Previous fix used unawaited close but didn't cancel the stream subscription, so the old listener could still push events to _messageController. Fix: Extract _closeCurrentConnection() that: 1. Cancels StreamSubscription first (stops duplicate events immediately) 2. Fire-and-forget sink.close(goingAway) (frees underlying socket) This follows the workaround recommended in the official issue tracker. Co-Authored-By: Claude Opus 4.6 --- .../lib/core/network/websocket_client.dart | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) 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() {