fix(network): use default ports for websocket connections

Watch Together could not reach relays when their URLs omitted a port because Dart passes port 0 to custom WebSocket connection factories.

Resolve port 0 to the HTTP/HTTPS default in the Happy Eyeballs factory while preserving explicit destination and proxy ports. Cover portless ws/wss connections with isolated regression tests.

close #2263
This commit is contained in:
edde746
2026-09-07 01:30:08 +02:00
parent 8870852a9e
commit b904c11750
2 changed files with 62 additions and 1 deletions
+5 -1
View File
@@ -13,7 +13,11 @@ import 'dart:io';
/// those on its own.
Future<ConnectionTask<Socket>> happyEyeballsConnectionFactory(Uri url, String? proxyHost, int? proxyPort) {
if (proxyHost != null) return Socket.startConnect(proxyHost, proxyPort!);
return Future.value(startHappyEyeballsConnect(url.host, url.port, secure: url.isScheme('https')));
final secure = url.isScheme('https');
// WebSocket.connect supplies port 0 when a ws/wss URL omits its port.
// Match HttpClient's default-port substitution for direct connections.
final port = url.port == 0 ? (secure ? HttpClient.defaultHttpsPort : HttpClient.defaultHttpPort) : url.port;
return Future.value(startHappyEyeballsConnect(url.host, port, secure: secure));
}
const Duration defaultAttemptDelay = Duration(milliseconds: 250);
+57
View File
@@ -268,6 +268,63 @@ void main() {
socket.add('after redirect and cancellation');
expect(await socket.first.timeout(const Duration(seconds: 5)), 'after redirect and cancellation');
});
test('a portless ws URL exchanges frames on port 80', () async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => server.close(force: true));
server.listen((request) async {
final socket = await WebSocketTransformer.upgrade(request);
addTearDown(socket.close);
socket.listen(socket.add, onDone: socket.close);
});
final parentZone = Zone.current;
await IOOverrides.runZoned(
() async {
final attempt = WebSocketConnectAttempt(
Uri.parse('ws://${server.address.address}/relay'),
connectTimeout: const Duration(seconds: 5),
);
addTearDown(attempt.cancel);
final socket = await attempt.socket;
addTearDown(socket.close);
socket.add('portless relay');
expect(await socket.first.timeout(const Duration(seconds: 5)), 'portless relay');
},
socketStartConnect: (host, port, {sourceAddress, sourcePort = 0}) {
// Route the default port to an isolated listener without binding port 80.
if (port != 80) return Future(() => throw SocketException('No listener on port $port'));
return parentZone.run(() => Socket.startConnect(host, server.port));
},
);
});
test('a portless wss URL negotiates TLS on port 443', () async {
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(server.close);
final accepted = server.listen((socket) {
addTearDown(socket.destroy);
// A plaintext endpoint must fail TLS, not become an insecure WebSocket.
socket.add('HTTP/1.1 400 Bad Request\r\n\r\n'.codeUnits);
});
addTearDown(accepted.cancel);
final parentZone = Zone.current;
await IOOverrides.runZoned(
() async {
final attempt = WebSocketConnectAttempt(
Uri.parse('wss://${server.address.address}/relay'),
connectTimeout: const Duration(seconds: 5),
);
addTearDown(attempt.cancel);
await expectLater(attempt.socket, throwsA(isA<TlsException>()));
},
socketStartConnect: (host, port, {sourceAddress, sourcePort = 0}) {
// Only the HTTPS listener is reachable; port 0 fails before TLS.
if (port != 443) return Future(() => throw SocketException('No listener on port $port'));
return parentZone.run(() => Socket.startConnect(host, server.port));
},
);
});
});
group('connectWebSocketChannel', () {