From b904c1175010ea3ba7ac52d2afda2a4d7a225b03 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:30:08 +0200 Subject: [PATCH] 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 --- lib/utils/happy_eyeballs.dart | 6 ++- test/utils/web_socket_connect_test.dart | 57 +++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/lib/utils/happy_eyeballs.dart b/lib/utils/happy_eyeballs.dart index 8fab28e74..a624b96a1 100644 --- a/lib/utils/happy_eyeballs.dart +++ b/lib/utils/happy_eyeballs.dart @@ -13,7 +13,11 @@ import 'dart:io'; /// those on its own. Future> 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); diff --git a/test/utils/web_socket_connect_test.dart b/test/utils/web_socket_connect_test.dart index 9d9392778..70b3923f1 100644 --- a/test/utils/web_socket_connect_test.dart +++ b/test/utils/web_socket_connect_test.dart @@ -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())); + }, + 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', () {