Files
plezy/test/utils/web_socket_connect_test.dart
T
edde746 01750ad92b fix(network): own a WebSocket connection attempt through cancellation and TLS
Cancelling a WebSocket connect (deadline, stop, dispose, a newer attempt)
only dropped or timed out a future: the shared never-closed HttpClient kept
the address race and TLS handshake alive, and once SecureSocket.secure had
detached the raw socket even a real task cancel was a no-op, so after
ClientHello the peer stayed open and the connect future never settled.

The happy-eyeballs task now owns the transport through the TLS handoff:
cancel is idempotent, settles the task at once, destroys an undelivered raw
winner, and destroys whatever a handshake in flight eventually yields. Each
WebSocket upgrade runs on its own HttpClient inside a connection attempt
with completion plus cancellation; the library-event, companion-remote and
Watch Together relay sockets cancel it on stop, disposal, deadline and
supersession. dart:io offers no handle to abort an in-flight handshake, so
a black-holed ClientHello closes when the handshake settles, not at cancel.
2026-09-06 13:28:51 +02:00

146 lines
5.3 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/web_socket_connect.dart';
void main() {
/// Accepts TCP connections and never answers the upgrade.
late ServerSocket blackHole;
final stalled = <Socket>[];
late StreamSubscription<Socket> accepting;
setUp(() async {
blackHole = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
accepting = blackHole.listen(stalled.add);
});
tearDown(() async {
await accepting.cancel();
for (final socket in stalled) {
socket.destroy();
}
stalled.clear();
await blackHole.close();
});
Uri blackHoleUri() => Uri.parse('ws://${blackHole.address.address}:${blackHole.port}/ws');
Future<void> peerSawClose(Socket peer) => peer.drain<void>().timeout(const Duration(seconds: 5));
group('WebSocketConnectAttempt', () {
test('cancel settles the socket at once and closes the stalled peer', () async {
final attempt = WebSocketConnectAttempt(blackHoleUri(), connectTimeout: const Duration(seconds: 30));
await _waitFor(() => stalled.isNotEmpty);
final peerClosed = peerSawClose(stalled.single);
final result = expectLater(attempt.socket, throwsA(isA<SocketException>()));
attempt.cancel();
attempt.cancel();
await result.timeout(const Duration(seconds: 1));
await peerClosed;
});
test('the deadline fails the attempt with a TimeoutException and closes the peer', () async {
final attempt = WebSocketConnectAttempt(blackHoleUri(), connectTimeout: const Duration(milliseconds: 200));
await _waitFor(() => stalled.isNotEmpty);
final peerClosed = peerSawClose(stalled.single);
await expectLater(attempt.socket, throwsA(isA<TimeoutException>()));
await peerClosed;
});
test('an upgrade landing after cancel is closed, not leaked', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => server.close(force: true));
final releaseUpgrade = Completer<void>();
final lateSocketClosed = Completer<void>();
server.listen((request) async {
await releaseUpgrade.future;
final socket = await WebSocketTransformer.upgrade(request);
socket.listen((_) {}, onDone: socket.close);
unawaited(socket.done.then((_) => lateSocketClosed.complete()));
});
final attempt = WebSocketConnectAttempt(
Uri.parse('ws://${server.address.address}:${server.port}/ws'),
connectTimeout: const Duration(seconds: 30),
);
final result = expectLater(attempt.socket, throwsA(isA<SocketException>()));
await Future<void>.delayed(const Duration(milliseconds: 50));
attempt.cancel();
await result;
releaseUpgrade.complete();
await lateSocketClosed.future.timeout(
const Duration(seconds: 5),
onTimeout: () => fail('late websocket was never closed by the client'),
);
});
test('a delivered socket survives a later cancel', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => server.close(force: true));
final received = Completer<dynamic>();
server.listen((request) async {
final socket = await WebSocketTransformer.upgrade(request);
socket.listen((data) {
received.complete(data);
socket.close();
});
});
final attempt = WebSocketConnectAttempt(
Uri.parse('ws://${server.address.address}:${server.port}/ws'),
connectTimeout: const Duration(seconds: 30),
);
final webSocket = await attempt.socket;
addTearDown(webSocket.close);
attempt.cancel();
webSocket.add('still here');
expect(await received.future.timeout(const Duration(seconds: 5)), 'still here');
});
});
group('connectWebSocketChannel', () {
test('closing the sink before ready cancels the attempt', () async {
final channel = connectWebSocketChannel(blackHoleUri(), connectTimeout: const Duration(seconds: 30));
await _waitFor(() => stalled.isNotEmpty);
final peerClosed = peerSawClose(stalled.single);
final ready = expectLater(channel.ready, throwsA(anything));
unawaited(channel.sink.close());
await ready.timeout(const Duration(seconds: 1));
await peerClosed;
});
test('an established channel exchanges frames and closes normally', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => server.close(force: true));
server.listen((request) async {
final socket = await WebSocketTransformer.upgrade(request);
socket.listen(socket.add, onDone: socket.close);
});
final channel = connectWebSocketChannel(
Uri.parse('ws://${server.address.address}:${server.port}/ws'),
connectTimeout: const Duration(seconds: 30),
);
await channel.ready;
channel.sink.add('echo');
expect(await channel.stream.first.timeout(const Duration(seconds: 5)), 'echo');
await channel.sink.close();
});
});
}
Future<void> _waitFor(bool Function() condition) async {
final deadline = DateTime.now().add(const Duration(seconds: 5));
while (!condition()) {
if (DateTime.now().isAfter(deadline)) fail('condition not met in time');
await Future<void>.delayed(const Duration(milliseconds: 5));
}
}