From 23fab05c8a5d559639de396ab8bf670d59afade5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Apr 2026 20:26:49 +0000 Subject: [PATCH] fix(net): prefer IPv4 over IPv6 for OkHttp + QUIC dial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android emulator (and a number of dual-stack networks where the IPv6 path is broken) advertises working v6 connectivity but silently drops outbound packets. With the JDK / RFC 6724 default of AAAA-first, any host that publishes a stale or unreachable AAAA record — e.g. moq.nostrnests.com, whose Linode v6 currently doesn't accept connections — causes every HTTPS call and every QUIC handshake to fast-fail with ConnectException, parking the Nests room screen on "Reconnecting" indefinitely. Plug a small Dns wrapper into both OkHttp factories that puts Inet4Address entries ahead of v6 in the resolved list (v6 stays as fallback for genuinely v6-only hosts), and switch UdpSocket.connect from getByName to getAllByName + IPv4-first selection so the QUIC audio path makes the same choice as the HTTPS auth POST. --- .../amethyst/service/okhttp/Ipv4FirstDns.kt | 52 +++++++++++++++++++ .../service/okhttp/OkHttpClientFactory.kt | 1 + .../okhttp/OkHttpClientFactoryForRelays.kt | 1 + .../vitorpamplona/quic/transport/UdpSocket.kt | 12 ++++- 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/Ipv4FirstDns.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/Ipv4FirstDns.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/Ipv4FirstDns.kt new file mode 100644 index 0000000000..074589096c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/Ipv4FirstDns.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import okhttp3.Dns +import java.net.Inet4Address +import java.net.InetAddress + +/** + * System-DNS wrapper that puts IPv4 addresses ahead of IPv6 in the + * resolved list. OkHttp's [okhttp3.internal.connection.RouteSelector] + * iterates routes in order, so v4 is tried first; v6 is still kept + * as a fallback for genuinely v6-only hosts. + * + * Mitigates two real-world cases that surface as a "Reconnecting" + * loop on the Nests auth POST (and on plain media loads): + * - Android emulator: the emulator's userspace networking advertises + * IPv6 connectivity but its NAT consistently drops outbound v6 + * packets, so any host with an AAAA record times out / fast-fails. + * - Hosts whose AAAA record points at a stale or otherwise + * unreachable address while v4 still works (e.g. Linode VMs that + * lost v6 routing). System resolver may return AAAA-only on some + * networks; we can't recover that case here, but we can stop + * v6-first from breaking the dual-stack case. + */ +class Ipv4FirstDns : Dns { + override fun lookup(hostname: String): List { + val all = Dns.SYSTEM.lookup(hostname) + if (all.size <= 1) return all + val v4 = all.filterIsInstance() + if (v4.isEmpty() || v4.size == all.size) return all + return v4 + all.filterNot { it is Inet4Address } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index 61f3255f62..3567dcbd96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -63,6 +63,7 @@ class OkHttpClientFactory( .Builder() .dispatcher(dispatcher) .connectionPool(connectionPool) + .dns(Ipv4FirstDns()) .eventListenerFactory(MediaCallEventListenerFactory(dispatcher, connectionPool)) .followRedirects(true) .followSslRedirects(true) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt index e4b81b31fb..aba24457f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt @@ -55,6 +55,7 @@ class OkHttpClientFactoryForRelays( OkHttpClient .Builder() .dispatcher(myDispatcher) + .dns(Ipv4FirstDns()) .followRedirects(true) .followSslRedirects(true) .addInterceptor(DefaultContentTypeInterceptor(userAgent)) diff --git a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/transport/UdpSocket.kt b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/transport/UdpSocket.kt index fdd6420d55..cefa1bf785 100644 --- a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/transport/UdpSocket.kt +++ b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/transport/UdpSocket.kt @@ -92,7 +92,17 @@ actual class UdpSocket private constructor( port: Int, ): UdpSocket = withContext(Dispatchers.IO) { - val address = InetAddress.getByName(host) + // Prefer IPv4 over IPv6 when both are present. RFC 6724 / the JDK + // default put AAAA first, but on the Android emulator and many + // dual-stack networks the IPv6 path silently drops packets, so a + // QUIC handshake against the AAAA times out indefinitely while + // the A would succeed. Fall back to whatever the resolver gave + // when no IPv4 entry exists (genuinely v6-only host). + val all = InetAddress.getAllByName(host) + val address = + all.firstOrNull { it is java.net.Inet4Address } + ?: all.firstOrNull() + ?: throw java.net.UnknownHostException(host) val remote = InetSocketAddress(address, port) val channel = DatagramChannel.open() channel.configureBlocking(true)