Skip to content

Commit db754cf

Browse files
authored
fix(realtime_client): detect dropped connections on iOS via WebSocket ping (#1451)
## What Enable `pingInterval` on the native (`dart:io`) WebSocket so dropped connections are detected consistently across platforms. ## Why Fixes #1071. When the internet drops, Android emits a `RealtimeSubscribeException` (close code 1002) but iOS emits nothing, leaving the stream silently stale. **Root cause:** `createWebSocketClient` created the native WebSocket without a `pingInterval`, so a *silently* dropped connection was never detected at the socket layer: - On **Android**, the OS surfaces the dead TCP connection promptly (RST → close code 1002), so `onDone`/`onError` fires and `_triggerChanError` propagates a `channelError`. - On **iOS**, the OS buffers writes instead of surfacing a reset. The only fallback was the app-level Phoenix heartbeat, whose recovery path calls `conn.sink.close()`, which does not reliably fire `onDone` on a dead iOS socket. So nothing propagated to the channel. ## How Set `pingInterval` (new `Constants.defaultWebSocketPingInterval`, 25s, aligned with the existing heartbeat cadence) on the native WebSocket. `dart:io` now actively probes the peer and closes the connection (`goingAway`) when a pong is not received, which flows through the same `onDone → _onConnClose → _triggerChanError → reconnect` path Android already uses. ## Scope / safety - Only the default native transport changes. Web (`websocket_web.dart`) is untouched, since browsers manage ping/pong internally. - Custom transports injected via `RealtimeClientOptions.transport` are unaffected. - No public API change: the `WebSocketTransport` typedef is unchanged. - A 25s pong window is generous for mobile round-trips. ## Testing - `dart analyze` clean on the changed files. - Realtime unit suite passes (`socket_test`, `mock_test`, `channel_test`, including the existing CHANNEL_ERROR-on-heartbeat-timeout test). - Files formatted. No unit test added: `pingInterval` is a property of the underlying `dart:io` socket and is not observable through `IOWebSocketChannel` without a live connection, and the codebase does not test the default transport (tests inject mocks).
1 parent c7b2598 commit db754cf

2 files changed

Lines changed: 79 additions & 3 deletions

File tree

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
import 'package:web_socket_channel/io.dart';
22
import 'package:web_socket_channel/web_socket_channel.dart';
33

4+
/// Interval for the native WebSocket to send protocol-level ping frames.
5+
///
6+
/// When a pong is not received within this interval the connection is assumed
7+
/// dead and closed, which makes silently dropped connections (common on iOS,
8+
/// where the OS buffers writes instead of surfacing a TCP reset) detectable and
9+
/// keeps disconnect behavior consistent across platforms. Aligned with the
10+
/// app-level heartbeat cadence of 25 seconds.
11+
const _defaultWebSocketPingInterval = Duration(seconds: 25);
12+
413
WebSocketChannel createWebSocketClient(
514
String url,
6-
Map<String, String> headers,
7-
) {
8-
return IOWebSocketChannel.connect(url, headers: headers);
15+
Map<String, String> headers, {
16+
Duration? pingInterval = _defaultWebSocketPingInterval,
17+
}) {
18+
return IOWebSocketChannel.connect(
19+
url,
20+
headers: headers,
21+
pingInterval: pingInterval,
22+
);
923
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
@TestOn('vm')
2+
library;
3+
4+
import 'dart:async';
5+
import 'dart:convert';
6+
import 'dart:io';
7+
8+
import 'package:crypto/crypto.dart';
9+
import 'package:realtime_client/src/websocket/websocket_io.dart';
10+
import 'package:test/test.dart';
11+
12+
/// Minimal WebSocket server that completes the opening handshake but never
13+
/// replies to control frames, so it can simulate a peer that has silently gone
14+
/// away (for example a mobile device that lost connectivity).
15+
Future<ServerSocket> _startUnresponsiveServer() async {
16+
final server = await ServerSocket.bind('localhost', 0);
17+
server.listen((socket) {
18+
final buffer = StringBuffer();
19+
late StreamSubscription subscription;
20+
subscription = socket.listen((data) {
21+
buffer.write(String.fromCharCodes(data));
22+
final request = buffer.toString();
23+
if (!request.contains('\r\n\r\n')) {
24+
return;
25+
}
26+
final keyLine = request.split('\r\n').firstWhere(
27+
(line) => line.toLowerCase().startsWith('sec-websocket-key:'));
28+
final key = keyLine.split(':').last.trim();
29+
final accept = base64.encode(
30+
sha1
31+
.convert(utf8.encode('${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11'))
32+
.bytes,
33+
);
34+
socket.write('HTTP/1.1 101 Switching Protocols\r\n'
35+
'Upgrade: websocket\r\n'
36+
'Connection: Upgrade\r\n'
37+
'Sec-WebSocket-Accept: $accept\r\n\r\n');
38+
// From here on, deliberately ignore everything (including ping frames).
39+
subscription.onData((_) {});
40+
});
41+
});
42+
return server;
43+
}
44+
45+
void main() {
46+
test('default transport closes the connection when the peer stops responding',
47+
() async {
48+
final server = await _startUnresponsiveServer();
49+
addTearDown(() => server.close());
50+
51+
final channel = createWebSocketClient(
52+
'ws://localhost:${server.port}',
53+
const {},
54+
pingInterval: const Duration(milliseconds: 200),
55+
);
56+
await channel.ready;
57+
58+
// Without a transport level ping interval this would never complete, since
59+
// the dead peer sends neither data nor a close frame.
60+
await channel.stream.drain().timeout(const Duration(seconds: 5));
61+
});
62+
}

0 commit comments

Comments
 (0)