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 <noreply@anthropic.com>
This commit is contained in:
parent
45eb6bc453
commit
7b71a4f2fc
|
|
@ -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<String, dynamic>;
|
||||
_messageController.add(decoded);
|
||||
|
|
@ -122,9 +121,28 @@ class WebSocketClient {
|
|||
Future<void> 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<void> _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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue