From 48dfcbf7c1373dd763d638966688540d89b90d6a Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Wed, 21 Oct 2026 11:37:38 +0300 Subject: [PATCH 1/6] fix: warn when speed tests cannot use split VPN routing --- .../java/net/megaproxy487/SpeedTestScreen.kt | 11 +++++++++++ app/src/main/res/values-ru/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../java/net/megaproxy487/NavigationUiTest.kt | 18 ++++++++++++++++++ 4 files changed, 31 insertions(+) diff --git a/app/src/main/java/net/megaproxy487/SpeedTestScreen.kt b/app/src/main/java/net/megaproxy487/SpeedTestScreen.kt index 0b201ebe..7aa29871 100644 --- a/app/src/main/java/net/megaproxy487/SpeedTestScreen.kt +++ b/app/src/main/java/net/megaproxy487/SpeedTestScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -65,6 +66,7 @@ internal fun SpeedTestScreen(activity: Activity, onBack: () -> Unit) { var megabytes by remember { mutableStateOf(SPEED_TEST_MEGABYTES.first()) } var log by remember { mutableStateOf>(emptyList()) } val scope = rememberCoroutineScope() + val store = remember(activity) { net.megaproxy487.data.ConfigStore(activity) } Scaffold( topBar = { TopAppBar( @@ -86,6 +88,15 @@ internal fun SpeedTestScreen(activity: Activity, onBack: () -> Unit) { stringResource(R.string.cloudflare_speed_explanation, megabytes, megabytes * 4), style = MaterialTheme.typography.bodyMedium, ) + val settings = readConfiguration(store) { store.globalConnectionSettings() } + if (settings != null && !settings.routeAllApps && activity.packageName !in settings.selectedPackages) { + Card(colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + )) { + Text(stringResource(R.string.speed_split_tunnel_warning), Modifier.padding(12.dp)) + } + } Row(Modifier.fillMaxWidth().selectableGroup(), horizontalArrangement = Arrangement.SpaceEvenly) { SPEED_TEST_MEGABYTES.forEach { size -> Row( diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ffaeef0a..628a6417 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -576,4 +576,5 @@ Допускает самоподписанные, просроченные сертификаты и несовпадение имени только для DoH. DNS-трафик может быть перехвачен. Проверка сертификата прокси не меняется. Настройки батареи недоступны на этом устройстве. Достигнут лимит хранилища конфигурации. Удалите профили или уменьшите их содержимое. + MegaProxy не выбран для раздельного туннелирования. Для измерения скорости VPN-туннеля включите «Направлять MegaProxy через VPN» в настройках раздельного туннелирования и переподключитесь. Основную сеть можно измерить. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 043675a0..27267a8f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -577,4 +577,5 @@ Allows self-signed, expired and mismatched certificates for DoH only. DNS traffic may be intercepted. Proxy certificate verification is unchanged. Battery settings are unavailable on this device. Configuration storage limit reached. Remove profiles or reduce their contents. + MegaProxy is not selected for split tunneling. To measure VPN tunnel speed, enable “Route MegaProxy through VPN” in split tunneling settings and reconnect. The underlying network can still be measured. diff --git a/app/src/test/java/net/megaproxy487/NavigationUiTest.kt b/app/src/test/java/net/megaproxy487/NavigationUiTest.kt index 43c37709..d9a5e48b 100644 --- a/app/src/test/java/net/megaproxy487/NavigationUiTest.kt +++ b/app/src/test/java/net/megaproxy487/NavigationUiTest.kt @@ -31,6 +31,8 @@ class NavigationUiTest : MainUiTestBase() { icon(R.string.main_actions).performClick() node(R.string.speed).performClick() compose.onNodeWithTag("screen-speed").assertIsDisplayed() + drainConfigIo() + node(R.string.speed_split_tunnel_warning).assertDoesNotExist() compose.onNodeWithText("h3").assertIsDisplayed() compose.onNodeWithText("h2").performClick() compose.onNodeWithText("h1").assertIsDisplayed() @@ -40,6 +42,22 @@ class NavigationUiTest : MainUiTestBase() { compose.onNodeWithTag("screen-main").assertIsDisplayed() } + @Test fun speedWarnsWhenMegaProxyIsExcludedFromSplitTunneling() { + store.saveGlobalConnectionSettings(net.megaproxy487.model.GlobalConnectionSettings( + routeAllApps = false, selectedPackages = setOf("other.package"), + )) + content { SpeedTestScreen(activity) {} } + node(R.string.speed_split_tunnel_warning).assertIsDisplayed() + } + + @Test fun speedDoesNotWarnWhenMegaProxyIsSelectedForSplitTunneling() { + store.saveGlobalConnectionSettings(net.megaproxy487.model.GlobalConnectionSettings( + routeAllApps = false, selectedPackages = setOf(activity.packageName), + )) + content { SpeedTestScreen(activity) {} } + node(R.string.speed_split_tunnel_warning).assertDoesNotExist() + } + @Test fun settingsDestinationsOpenAndBackReturnsToSettings() { content { MegaProxyNavHost(activity, readConnectionStats = { null }) } icon(R.string.main_actions).performClick() From 872d2ee7f2261e413e3f107237d95354350f33fb Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Wed, 21 Oct 2026 11:46:31 +0300 Subject: [PATCH 2/6] fix: select MegaProxy for split routing by default --- AGENTS.md | 3 +++ .../java/net/megaproxy487/data/ConfigStore.kt | 16 +++++++---- .../ConfigurationRecoveryRegressionTest.kt | 27 +++++++++++++++++++ docs/en/connection-metrics.md | 3 +++ docs/ru/connection-metrics.md | 4 +++ 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 64388e99..d16afcc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,9 @@ branch names, credentials, signing material, or other secrets. ## Product and UI conventions +- MegaProxy is selected for split tunneling by default. Existing lists gain it once on upgrade; + subsequent explicit exclusion must survive reloads, unrelated edits and profile changes. + - All user-visible UI text must use Android string resources and be supplied in both the default English resources and Russian resources. The app language selects UI words; number, date and other data formatting follows the system locale. Do not localize standardized data-unit symbols. diff --git a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt index 660d76db..7aede1d0 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt @@ -178,8 +178,10 @@ class ConfigStore(context: Context) { if (stored != null) { val decoded = decodeGlobalConnectionSettings(stored) migrateGlobalIpv6ToProfiles(stored) - if (!operationResult { JSONObject(stored).has("sshProfile") }.getOrDefault(false)) { - val upgraded = decoded.copy(sshProfile = activeProfile().config.sshProfile) + val fields = JSONObject(stored) + if (!fields.has("sshProfile") || !fields.optBoolean("megaProxyRoutingDefaultApplied", false)) { + val upgraded = if (fields.has("sshProfile")) decoded + else decoded.copy(sshProfile = activeProfile().config.sshProfile) saveGlobalConnectionSettings(upgraded) return@synchronized upgraded } @@ -190,7 +192,7 @@ class ConfigStore(context: Context) { tlsProfile = source.profile.takeIf { it.available } ?: TlsProfile.DEFAULT, sshProfile = source.sshProfile, customJa3 = source.customJa3, - selectedPackages = source.selectedPackages, + selectedPackages = source.selectedPackages + context.packageName, routeAllApps = source.routeAllApps, bypassLocalNetworks = source.bypassLocalNetworks, ) @@ -760,13 +762,14 @@ class ConfigStore(context: Context) { put("failoverProfileIds", JSONArray(settings.failoverProfileIds)) put("customJa3", settings.customJa3.trim()) put("packages", JSONArray(settings.selectedPackages.sorted())) + put("megaProxyRoutingDefaultApplied", true) put("routeAllApps", settings.routeAllApps) put("bypassLocalNetworks", settings.bypassLocalNetworks) }.toString() private fun decodeGlobalConnectionSettings(value: String): GlobalConnectionSettings = operationResult { val item = JSONObject(value) - for (key in listOf("routeAllApps", "bypassLocalNetworks", "allowIpv6")) { + for (key in listOf("routeAllApps", "bypassLocalNetworks", "allowIpv6", "megaProxyRoutingDefaultApplied")) { require(!item.has(key) || item.get(key) is Boolean) } for (key in listOf("packages", "failoverProfileIds")) { @@ -791,7 +794,10 @@ class ConfigStore(context: Context) { customJa3 = item.optString("customJa3"), selectedPackages = item.optJSONArray("packages")?.let { array -> (0 until array.length()).mapNotNull { array.optString(it).takeIf(String::isNotBlank) }.toSet() - }.orEmpty(), + }.orEmpty().let { packages -> + // Old lists did not record whether excluding MegaProxy was deliberate. + if (item.optBoolean("megaProxyRoutingDefaultApplied", false)) packages else packages + context.packageName + }, routeAllApps = item.optBoolean("routeAllApps", true), bypassLocalNetworks = item.optBoolean("bypassLocalNetworks", true), ) diff --git a/app/src/test/java/net/megaproxy487/ConfigurationRecoveryRegressionTest.kt b/app/src/test/java/net/megaproxy487/ConfigurationRecoveryRegressionTest.kt index a273066a..902925c6 100644 --- a/app/src/test/java/net/megaproxy487/ConfigurationRecoveryRegressionTest.kt +++ b/app/src/test/java/net/megaproxy487/ConfigurationRecoveryRegressionTest.kt @@ -26,6 +26,33 @@ class ConfigurationRecoveryRegressionTest { org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() } + @Test fun megaProxyRoutingDefaultsOnAndExplicitExclusionSurvivesReload() { + val initial = store.globalConnectionSettings() + assertEquals(setOf(context.packageName), initial.selectedPackages) + assertTrue(initial.routeAllApps) + store.editGlobalSettings { it.copy(routeAllApps = false, selectedPackages = setOf("other.package")) } + assertEquals(setOf("other.package"), ConfigStore(context).globalConnectionSettings().selectedPackages) + store.editGlobalSettings { it.copy(bypassLocalNetworks = false) } + val another = store.addProfile() + store.setActiveProfile(another.id) + store.setConnectionProfile(another.id) + assertEquals(setOf("other.package"), ConfigStore(context).connectionConfig().selectedPackages) + } + + @Test fun existingSplitListGainsMegaProxyOnlyOnce() { + store.activeProfile() + val prefs = context.getSharedPreferences("proxy_config", Context.MODE_PRIVATE) + prefs.edit().putString("global_connection_settings_v1", + """{"sshProfile":"DEFAULT","routeAllApps":false,"packages":["other.package"]}""").commit() + val migrated = store.globalConnectionSettings() + assertEquals(setOf("other.package", context.packageName), migrated.selectedPackages) + assertFalse(migrated.routeAllApps) + assertTrue(org.json.JSONObject(prefs.getString("global_connection_settings_v1", null)!!) + .getBoolean("megaProxyRoutingDefaultApplied")) + store.editGlobalSettings { it.copy(selectedPackages = it.selectedPackages - context.packageName) } + assertEquals(setOf("other.package"), ConfigStore(context).globalConnectionSettings().selectedPackages) + } + @Test fun failoverSkipsInvalidProfile() { val first = store.activeProfile().let { it.copy(config = ProxyConfig(type=ProxyType.SOCKS5,host="one.example",port=1080)) } store.saveProfile(first) diff --git a/docs/en/connection-metrics.md b/docs/en/connection-metrics.md index 747817c5..899984d0 100644 --- a/docs/en/connection-metrics.md +++ b/docs/en/connection-metrics.md @@ -96,6 +96,9 @@ These are separate entries in the main menu; they measure different things: | Speed | Active Cloudflare transfer over the underlying network and VPN separately; progressively filled table with IP, flag/code, HTTP and TLS on separate lines, latency, download and upload. | | VPN visibility | Local Android/interface signals plus ProxyCheck public-IP reputation per available route. Local heuristics and remote reputation are not proof that all apps can detect the VPN. | +With split tunneling, select MegaProxy to measure the VPN route. It is selected by default; +existing application lists gain it once on upgrade. Explicitly removing it afterwards is preserved. + Speed uses 10/25/50 decimal MB per direction per route (10,000,000/25,000,000/50,000,000 bytes). Two routes and both directions can consume up to four times the selected payload, plus overhead. Default h3 falls back to h2 then h1; h2 permits h1 fallback, h1 stays h1. Fallback applies only to diff --git a/docs/ru/connection-metrics.md b/docs/ru/connection-metrics.md index ab6041c0..bc0fd4f1 100644 --- a/docs/ru/connection-metrics.md +++ b/docs/ru/connection-metrics.md @@ -96,6 +96,10 @@ HTTP выводится числовой статус без произволь | Скорость | Активная передача Cloudflare отдельно через основную сеть и VPN; постепенная таблица IP, флаг/код, HTTP и TLS на отдельных строках, задержка, скачивание и отправка. | | Видимость VPN | Локальные Android/interface признаки и репутация публичного IP по ProxyCheck для каждого доступного маршрута. Эвристики и репутация не доказывают обнаружение VPN всеми приложениями. | +При раздельном туннелировании для замера VPN-маршрута выберите MegaProxy. По умолчанию +он выбран; при обновлении он однократно добавляется в существующие списки приложений. +Последующее явное исключение сохраняется. + Скорость использует 10/25/50 десятичных MB в каждом направлении каждого маршрута (10 000 000/25 000 000/50 000 000 байт). Два маршрута и оба направления могут расходовать до четырёх выбранных объёмов плюс overhead. По умолчанию h3 откатывается на h2, затем h1; From 95f1ba98c8538f185b4a5523d9898d25f88ae052 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Wed, 21 Oct 2026 12:25:18 +0300 Subject: [PATCH 3/6] fix: reclaim idle DNS flows and retain local resolver consent --- AGENTS.md | 4 +- .../java/net/megaproxy487/DnsDeviceTest.kt | 25 +++-- .../java/net/megaproxy487/VpnDeviceTest.kt | 16 +-- .../java/net/megaproxy487/data/ConfigIo.kt | 13 ++- .../megaproxy487/data/ConfigSubscription.kt | 5 + .../ConfigSubscriptionIntegrationTest.kt | 27 +++++ .../megaproxy487/ConfigurationReadUiTest.kt | 27 +++-- .../java/net/megaproxy487/MainUiTestBase.kt | 11 +-- docs/en/dns.md | 7 +- docs/ru/dns.md | 10 +- native/mobile/dialer.go | 2 +- native/mobile/dns_flows.go | 99 +++++++++++++++++-- native/mobile/dns_flows_test.go | 68 ++++++++++--- native/mobile/dns_udp.go | 73 ++++++++++++++ native/mobile/masque.go | 2 +- native/mobile/masque_test.go | 2 +- native/mobile/mobile.go | 2 +- native/mobile/socks5.go | 2 +- native/mobile/ssh_dialer.go | 2 +- 19 files changed, 330 insertions(+), 67 deletions(-) create mode 100644 native/mobile/dns_udp.go diff --git a/AGENTS.md b/AGENTS.md index d16afcc6..1ec05a02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,9 @@ branch names, credentials, signing material, or other secrets. - Compose interaction tests may run in `app/src/test` using Robolectric with a pinned SDK and plain test Application. Inject platform operations; do not load Go JNI or real Keystore in those tests. Gradle resolves pinned API 26/35 Robolectric SDK jars before tests; the test - runtime uses offline resolution. They run through Fastlane Android checks without an emulator. + runtime uses offline resolution. UI tests dispatch configuration work deterministically; + a controlled worker test verifies the background-read boundary. They run through Fastlane + Android checks without an emulator. - Exercise real screens and ConfigStore with a test-only Keystore provider. Before asserting service commands or their absence, drain ordered configuration writes and check both completion and failure state; `pending == 0` alone does not prove success. Recorded service intents do not diff --git a/app/src/androidTest/java/net/megaproxy487/DnsDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/DnsDeviceTest.kt index 2e98a54f..2225460b 100644 --- a/app/src/androidTest/java/net/megaproxy487/DnsDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/DnsDeviceTest.kt @@ -52,6 +52,14 @@ class DnsDeviceTest : DeviceTestBase() { connect() dnsQuery(true) dnsQuery(false) + if (type == ProxyType.HTTPS && mode == "https") { + DatagramSocket().use { first -> + dnsQuery(false, first) + repeat(80) { dnsQuery(false) } + // Reuse an application socket after its idle TUN mapping was evicted. + dnsQuery(false, first) + } + } click(R.string.disconnect) stopped() } @@ -80,7 +88,7 @@ class DnsDeviceTest : DeviceTestBase() { stopped() } - private fun dnsQuery(tcp: Boolean) { + private fun dnsQuery(tcp: Boolean, udpSocket: DatagramSocket? = null) { val query = byteArrayOf(0x48, 0x73, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 7) + "private".toByteArray(Charsets.US_ASCII) + byteArrayOf(4) + "test".toByteArray(Charsets.US_ASCII) + byteArrayOf(0, 0, 1, 0, 1) @@ -89,12 +97,15 @@ class DnsDeviceTest : DeviceTestBase() { socket.connect(InetSocketAddress("10.77.0.2", 53), 10_000) DataOutputStream(socket.getOutputStream()).apply { writeShort(query.size); write(query); flush() } DataInputStream(socket.getInputStream()).let { input -> ByteArray(input.readUnsignedShort()).also { input.readFully(it) } } - } else DatagramSocket().use { socket -> - socket.soTimeout = 5_000 - socket.send(DatagramPacket(query, query.size, InetAddress.getByName("10.77.0.2"), 53)) - val packet = DatagramPacket(ByteArray(512), 512) - socket.receive(packet) - packet.data.copyOf(packet.length) + } else { + fun exchange(socket: DatagramSocket): ByteArray { + socket.soTimeout = 5_000 + socket.send(DatagramPacket(query, query.size, InetAddress.getByName("10.77.0.2"), 53)) + val packet = DatagramPacket(ByteArray(512), 512) + socket.receive(packet) + return packet.data.copyOf(packet.length) + } + if (udpSocket != null) exchange(udpSocket) else DatagramSocket().use(::exchange) } assertEquals(0x48, response[0].toInt()) assertEquals(0x73, response[1].toInt()) diff --git a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt index b1c46ecc..8c2881db 100644 --- a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt @@ -368,13 +368,17 @@ class VpnDeviceTest : DeviceTestBase() { @Test fun notificationActionStopsRealService() { connect() roundTrip() - device.openNotification() - val action = device.wait(androidx.test.uiautomator.Until.findObject( - androidx.test.uiautomator.By.desc(text(R.string.disconnect))), 10_000) - assertNotNull("VPN notification must expose Disconnect", action) - action.click() + val manager = context.getSystemService(android.app.NotificationManager::class.java) + var action: android.app.Notification.Action? = null + await("VPN notification must expose Disconnect") { + action = manager.activeNotifications.asSequence().flatMap { + it.notification.actions.orEmpty().asSequence() + }.firstOrNull { it.title.toString() == text(R.string.disconnect) } + action != null + } + // Send the published PendingIntent through Android; shade expansion varies by API/device. + requireNotNull(action).actionIntent.send() stopped() - device.pressBack() directOriginUnavailable() } diff --git a/app/src/main/java/net/megaproxy487/data/ConfigIo.kt b/app/src/main/java/net/megaproxy487/data/ConfigIo.kt index d6d75b91..f0ae9592 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigIo.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigIo.kt @@ -3,8 +3,15 @@ package net.megaproxy487.data import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher import java.util.concurrent.Executors +import kotlin.coroutines.CoroutineContext /** Ordered background dispatcher for configuration encryption, JSON and persistence. */ -val ConfigIoDispatcher: CoroutineDispatcher = Executors.newSingleThreadExecutor { task -> - Thread(task, "megaproxy-config-io").apply { isDaemon = true } -}.asCoroutineDispatcher() +object ConfigIoDispatcher : CoroutineDispatcher() { + private val worker = Executors.newSingleThreadExecutor { task -> + Thread(task, "megaproxy-config-io").apply { isDaemon = true } + }.asCoroutineDispatcher() + @Volatile internal var delegate: CoroutineDispatcher = worker + internal fun reset() { delegate = worker } + override fun isDispatchNeeded(context: CoroutineContext) = delegate.isDispatchNeeded(context) + override fun dispatch(context: CoroutineContext, block: Runnable) = delegate.dispatch(context, block) +} diff --git a/app/src/main/java/net/megaproxy487/data/ConfigSubscription.kt b/app/src/main/java/net/megaproxy487/data/ConfigSubscription.kt index 82d60ab6..6ac5a949 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigSubscription.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigSubscription.kt @@ -113,7 +113,12 @@ internal fun retainProfileSecrets( presence: ProfileSecretPresence, ): net.megaproxy487.model.ProxyProfile { if (current == null) return source + val sameDns = source.config.dnsProvider == current.config.dnsProvider && + (source.config.dnsProvider != net.megaproxy487.model.DnsProvider.CUSTOM || + source.config.customDohUrl.trim() == current.config.customDohUrl.trim()) return source.copy(config = source.config.copy( + allowInsecureDns = sameDns && current.config.allowInsecureDns, + allowInvalidDohCertificate = sameDns && current.config.allowInvalidDohCertificate, password = source.config.password.takeIf { presence.password } ?: current.config.password, privateKey = source.config.privateKey.takeIf { presence.privateKey } ?: current.config.privateKey, jumpPassword = source.config.jumpPassword.takeIf { presence.jumpPassword } ?: current.config.jumpPassword, diff --git a/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt b/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt index 080ac7f6..945f8742 100644 --- a/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt +++ b/app/src/test/java/net/megaproxy487/ConfigSubscriptionIntegrationTest.kt @@ -51,6 +51,33 @@ class ConfigSubscriptionIntegrationTest { private fun apply(value: PortableConfiguration, running: Boolean = false) = store.applySubscriptionSnapshot( store.subscriptionState()!!.generation, value, 0, 1000, running) + @Test fun jsonRefreshPreservesLocalDnsConsentOnlyForUnchangedResolver() { + val original = profile("one").let { it.copy(config = it.config.copy( + dnsProvider = DnsProvider.CUSTOM, customDohUrl = "https://dns.example/dns-query")) } + bootstrap(original) + store.saveProfile(original.copy(config = original.config.copy( + allowInsecureDns = true, allowInvalidDohCertificate = true))) + val rotated = original.copy(config = original.config.copy(password = "rotated")) + assertTrue(apply(parseSubscriptionSnapshot(document(rotated).toString(), store.profiles()))) + val retained = ConfigStore(context).profile("one")!!.config + assertTrue(retained.allowInsecureDns) + assertTrue(retained.allowInvalidDohCertificate) + assertEquals("rotated", retained.password) + val changed = rotated.copy(config = rotated.config.copy(customDohUrl = "https://other.example/dns-query")) + assertTrue(apply(parseSubscriptionSnapshot(document(changed).toString(), store.profiles()))) + val reset = ConfigStore(context).profile("one")!!.config + assertFalse(reset.allowInsecureDns) + assertFalse(reset.allowInvalidDohCertificate) + } + + @Test fun plainDnsConsentSurvivesJsonImportForUnchangedResolver() { + val original = profile("one").let { it.copy(config = it.config.copy( + dnsProvider = DnsProvider.CUSTOM, customDohUrl = "tcp://1.1.1.1", allowInsecureDns = true)) } + store.saveProfile(original, createIfMissing = true) + store.importConfiguration(ConfigTransfer.importJson(document(original).toString())) + assertTrue(ConfigStore(context).profile("one")!!.config.allowInsecureDns) + } + @Test fun omittedAndPartialGlobalsPreserveLocalSettingsOnImportAndRefresh() { bootstrap(profile("one")) store.setConnectionProfile("one") diff --git a/app/src/test/java/net/megaproxy487/ConfigurationReadUiTest.kt b/app/src/test/java/net/megaproxy487/ConfigurationReadUiTest.kt index 6c3f90e4..4e9139a4 100644 --- a/app/src/test/java/net/megaproxy487/ConfigurationReadUiTest.kt +++ b/app/src/test/java/net/megaproxy487/ConfigurationReadUiTest.kt @@ -3,33 +3,40 @@ package net.megaproxy487 import android.os.Looper import androidx.compose.material3.Text import androidx.compose.ui.test.onNodeWithText +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import net.megaproxy487.data.ConfigIoDispatcher import org.junit.Assert.assertFalse import org.junit.Test -import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.CoroutineContext class ConfigurationReadUiTest : MainUiTestBase() { @Test fun slowConfigurationReadLeavesUiResponsiveAndDoesNotExposeDefaults() { - val entered = CountDownLatch(1) - val release = CountDownLatch(1) + val tasks = LinkedBlockingQueue() + ConfigIoDispatcher.delegate = object : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { tasks.add(block) } + } + val worker = Executors.newSingleThreadExecutor() val onMain = AtomicBoolean() try { compose.setContent { val configuration = readConfiguration("slow") { onMain.set(Looper.myLooper() == Looper.getMainLooper()) - entered.countDown() - release.await() // The test owns completion; finally always releases the worker. "Loaded configuration" } Text(configuration ?: "Loading configuration") } - compose.waitForIdle() - compose.waitUntil(5_000) { entered.count == 0L } compose.onNodeWithText("Loaded configuration").assertDoesNotExist() + worker.submit(checkNotNull(tasks.poll(5, TimeUnit.SECONDS))).get(5, TimeUnit.SECONDS) assertFalse(onMain.get()) - release.countDown() - drainConfigIo() waitForText("Loaded configuration") - } finally { release.countDown() } + } finally { + ConfigIoDispatcher.delegate = Dispatchers.Unconfined + worker.shutdownNow() + } } } diff --git a/app/src/test/java/net/megaproxy487/MainUiTestBase.kt b/app/src/test/java/net/megaproxy487/MainUiTestBase.kt index 0512a780..c5591641 100644 --- a/app/src/test/java/net/megaproxy487/MainUiTestBase.kt +++ b/app/src/test/java/net/megaproxy487/MainUiTestBase.kt @@ -23,7 +23,7 @@ import org.robolectric.annotation.LooperMode import org.robolectric.shadows.ShadowVpnService import java.security.Security import java.util.concurrent.atomic.AtomicBoolean -import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.launch import org.junit.Assert.assertFalse @RunWith(RobolectricTestRunner::class) @@ -39,15 +39,11 @@ abstract class MainUiTestBase { protected fun content(block: @Composable () -> Unit) { compose.setContent { MaterialTheme { block() } } drainConfigIo() - // Finishing IO does not guarantee that Compose has displayed its result. - compose.waitUntil(10_000) { - compose.onAllNodesWithText(text(R.string.loading_configuration)).fetchSemanticsNodes().isEmpty() - } } protected fun drainConfigIo() { compose.waitForIdle() val completed = AtomicBoolean() - ConfigIoDispatcher.dispatch(EmptyCoroutineContext, Runnable { completed.set(true) }) + kotlinx.coroutines.CoroutineScope(ConfigIoDispatcher).launch { completed.set(true) } compose.waitUntil(10_000) { completed.get() } compose.waitForIdle() } @@ -64,6 +60,8 @@ abstract class MainUiTestBase { }.also { store.saveProfile(it) } @Before fun preparePlatform() { + // Execute configuration work deterministically; async dispatch has a dedicated contract test. + ConfigIoDispatcher.delegate = kotlinx.coroutines.Dispatchers.Unconfined Security.removeProvider("AndroidKeyStore") UiTestKeyStore.keys.clear() Security.addProvider(UiTestKeyStoreProvider()) @@ -84,6 +82,7 @@ abstract class MainUiTestBase { } finally { Security.removeProvider("AndroidKeyStore") UiTestKeyStore.keys.clear() + ConfigIoDispatcher.reset() } } } diff --git a/docs/en/dns.md b/docs/en/dns.md index a4d17d2a..3f2fca37 100644 --- a/docs/en/dns.md +++ b/docs/en/dns.md @@ -27,8 +27,11 @@ apply to every mode. App UDP/TCP port 53 is intercepted; app-specific encrypted Portable profiles retain the endpoint in the legacy `dns.customDohUrl` string with an explicit `https://`, `tcp://` or `udp://` scheme. Existing schemas remain unchanged. Old clients reject plain endpoints. Security consent stays on this device: exports/imports and subscriptions cannot grant -permission for insecure DNS or invalid DoH certificates. Confirm locally before connecting. +permission for insecure DNS or invalid DoH certificates. On a new device, confirm locally before connecting. Refreshing/importing an unchanged resolver preserves its local +approvals; a changed resolver requires new approval. Proxy bootstrap DNS is unchanged and precedes the VPN; see the README endpoint inventory. -DNS admission is limited to 64 active flows per proxy, shared by intercepted TCP and UDP. +DNS admission is limited to 64 flows per proxy, shared by intercepted TCP and UDP. When full, +the oldest completed UDP flow is evicted together with its TUN mapping. Pending responses +and TCP DNS streams are retained. Excess flows fail immediately; stopping the VPN closes admitted flows and cancels their queries. diff --git a/docs/ru/dns.md b/docs/ru/dns.md index b6dc918c..05fe9d0a 100644 --- a/docs/ru/dns.md +++ b/docs/ru/dns.md @@ -28,8 +28,12 @@ UDP никогда не переключает DNS на прямой UDP. У о Переносимый профиль сохраняет адрес в прежнем строковом поле `dns.customDohUrl` с явной схемой `https://`, `tcp://` или `udp://`. Схемы конфигурации не меняются. Старые клиенты отклоняют обычные DNS-адреса. Разрешения небезопасных режимов и сертификатов остаются только на устройстве: -экспорт, импорт и подписки не дают такого согласия. Перед подключением подтвердите его локально. +экспорт, импорт и подписки не дают такого согласия. На новом устройстве подтвердите его локально перед подключением. +Обновление или импорт неизменившегося резолвера сохраняет локальные разрешения; +смена резолвера требует нового подтверждения. Начальное разрешение имени прокси до создания VPN не меняется; сервисы перечислены в README. -На прокси допускается до 64 активных DNS-потоков суммарно для перехваченного TCP и UDP. -Новые потоки сверх лимита сразу отклоняются; остановка VPN закрывает потоки и отменяет их запросы. +На прокси допускается до 64 DNS-потоков суммарно для перехваченного TCP и UDP. При заполнении +лимита удаляется самый старый завершённый UDP-поток вместе с его TUN-соединением. Потоки +с ожидающими ответами и TCP DNS сохраняются. +Если завершённый UDP-поток нельзя вытеснить, новые потоки сразу отклоняются; остановка VPN закрывает потоки и отменяет их запросы. diff --git a/native/mobile/dialer.go b/native/mobile/dialer.go index 148db5bb..d10b6d71 100644 --- a/native/mobile/dialer.go +++ b/native/mobile/dialer.go @@ -586,7 +586,7 @@ func (d *httpsConnectDialer) DialUDP(metadata *M.Metadata) (net.PacketConn, erro client := d.dohClient limiter := d.dohInFlight d.cacheMu.Unlock() - return d.dnsFlows.open(func() *dohPacketConn { + return d.dnsFlows.open(metadata.Network == M.UDP, func() *dohPacketConn { packet := newDoHPacketConnWithClient(d.config, d.reporter, d.connectTarget, d.config.DoHURL, client, limiter) packet.connectUDP = d.connectDNSUDP return packet diff --git a/native/mobile/dns_flows.go b/native/mobile/dns_flows.go index 4158cc14..ebb90286 100644 --- a/native/mobile/dns_flows.go +++ b/native/mobile/dns_flows.go @@ -2,6 +2,7 @@ package mobile import ( "errors" + "io" "net" "sync" ) @@ -10,37 +11,117 @@ const maxDNSFlows = 64 // TCP and UDP share admission before allocating pipes, queues or goroutines. type dnsFlows struct { - mu sync.Mutex - closed bool - packets map[*dohPacketConn]struct{} + mu sync.Mutex + closed bool + sequence uint64 + packets map[*dnsFlow]struct{} } -func (f *dnsFlows) open(create func() *dohPacketConn) (net.PacketConn, error) { +type dnsFlow struct { + *dohPacketConn + owner *dnsFlows + udp bool + pending int + completed bool + lastUsed uint64 + origin io.Closer +} + +func (f *dnsFlows) open(udp bool, create func() *dohPacketConn) (net.PacketConn, error) { f.mu.Lock() - defer f.mu.Unlock() if f.closed { + f.mu.Unlock() return nil, net.ErrClosed } + var evicted *dnsFlow if len(f.packets) >= maxDNSFlows { - return nil, errors.New("DNS flow limit reached") + // ponytail: scan at most 64 flows; a separate LRU is unnecessary at this bound. + for packet := range f.packets { + if packet.udp && packet.completed && packet.pending == 0 && (evicted == nil || packet.lastUsed < evicted.lastUsed) { + evicted = packet + } + } + if evicted == nil { + f.mu.Unlock() + return nil, errors.New("DNS flow limit reached") + } + delete(f.packets, evicted) } if f.packets == nil { - f.packets = make(map[*dohPacketConn]struct{}) + f.packets = make(map[*dnsFlow]struct{}) } - packet := create() + packet := &dnsFlow{dohPacketConn: create(), owner: f, udp: udp} packet.onClose = func() { f.mu.Lock() delete(f.packets, packet) + origin := packet.origin f.mu.Unlock() + if origin != nil { + _ = origin.Close() + } } f.packets[packet] = struct{}{} + f.mu.Unlock() + if evicted != nil { + _ = evicted.Close() + } return packet, nil } +func (p *dnsFlow) bindOrigin(origin io.Closer) { + p.owner.mu.Lock() + _, open := p.owner.packets[p] + if open { + p.origin = origin + } + p.owner.mu.Unlock() + if !open { + _ = origin.Close() + } +} + +func (p *dnsFlow) WriteTo(b []byte, addr net.Addr) (int, error) { + p.owner.mu.Lock() + if _, open := p.owner.packets[p]; !open { + p.owner.mu.Unlock() + return 0, net.ErrClosed + } + p.pending++ + p.owner.mu.Unlock() + n, err := p.dohPacketConn.WriteTo(b, addr) + if err != nil { + p.complete(false) + } + return n, err +} + +func (p *dnsFlow) ReadFrom(b []byte) (int, net.Addr, error) { + n, addr, err := p.dohPacketConn.ReadFrom(b) + p.owner.mu.Lock() + direct := p.origin == nil + p.owner.mu.Unlock() + // The TUN relay completes only after delivering the response to the origin. + if err == nil && direct { + p.complete(true) + } + return n, addr, err +} + +func (p *dnsFlow) complete(answered bool) { + p.owner.mu.Lock() + defer p.owner.mu.Unlock() + if p.pending > 0 { + p.pending-- + } + p.completed = p.completed || answered + p.owner.sequence++ + p.lastUsed = p.owner.sequence +} + func (f *dnsFlows) close() { f.mu.Lock() f.closed = true - packets := make([]*dohPacketConn, 0, len(f.packets)) + packets := make([]*dnsFlow, 0, len(f.packets)) for packet := range f.packets { packets = append(packets, packet) } diff --git a/native/mobile/dns_flows_test.go b/native/mobile/dns_flows_test.go index 96d4ab84..86cfd9a9 100644 --- a/native/mobile/dns_flows_test.go +++ b/native/mobile/dns_flows_test.go @@ -136,23 +136,16 @@ func (p *dnsLifecycleOrigin) WriteTo(b []byte, _ net.Addr) (int, error) { } func TestRealTunnelReleasesIdleDNSFlowsOnProxyClose(t *testing.T) { - const count = 64 + const count = maxDNSFlows * 2 proxy := &dnsLifecycleProxy{httpsConnectDialer: &httpsConnectDialer{config: config{DoHURL: "https://dns.example"}}, packets: make(chan net.PacketConn, count)} - transport := tunnel.New(proxy, statistic.DefaultManager) + transport := dnsTransport{tunnel.New(proxy, statistic.DefaultManager)} transport.ProcessAsync() defer transport.Close() ready := make(chan struct{}, count) query, _, _ := buildAQuery("example.test") binary.BigEndian.PutUint16(query[len(query)-4:], 28) baseline := len(statistic.DefaultManager.Snapshot().Connections) - var origins []net.Conn - for i := 0; i < count; i++ { - a, b := net.Pipe() - defer b.Close() - origins = append(origins, a) - origin := &dnsLifecycleOrigin{Conn: a, query: query, ready: ready, id: stack.TransportEndpointID{LocalAddress: tcpip.AddrFrom4([4]byte{10, 77, 0, 2}), LocalPort: 53, RemoteAddress: tcpip.AddrFrom4([4]byte{10, 77, 0, 1}), RemotePort: uint16(10000 + i)}} - transport.HandleUDP(origin) - } + var peers []net.Conn var packets []net.PacketConn defer func() { for _, pc := range packets { @@ -160,16 +153,27 @@ func TestRealTunnelReleasesIdleDNSFlowsOnProxyClose(t *testing.T) { } }() for i := 0; i < count; i++ { + a, b := net.Pipe() + defer b.Close() + peers = append(peers, b) + origin := &dnsLifecycleOrigin{Conn: a, query: query, ready: ready, id: stack.TransportEndpointID{LocalAddress: tcpip.AddrFrom4([4]byte{10, 77, 0, 2}), LocalPort: 53, RemoteAddress: tcpip.AddrFrom4([4]byte{10, 77, 0, 1}), RemotePort: uint16(10000 + i)}} + transport.HandleUDP(origin) select { case <-ready: case <-time.After(time.Second): - t.Fatal("DNS fixture did not answer") + t.Fatal("DNS fixture did not answer", i) } packets = append(packets, <-proxy.packets) + if len(proxy.dnsFlows.packets) > maxDNSFlows { + t.Fatal("DNS admission became unbounded") + } } proxy.Close() - for _, origin := range origins { - origin.Close() + for _, peer := range peers { + _ = peer.SetReadDeadline(time.Now().Add(time.Second)) + if _, err := peer.Read(make([]byte, 1)); !errors.Is(err, io.EOF) { + t.Fatal("idle TUN origin survived close", err) + } } deadline := time.Now().Add(time.Second) for len(statistic.DefaultManager.Snapshot().Connections) > baseline && time.Now().Before(deadline) { @@ -190,7 +194,7 @@ func TestDNSAdmissionRacesWithClose(t *testing.T) { defer workers.Done() <-start for j := 0; j < 100; j++ { - packet, err := flows.open(func() *dohPacketConn { + packet, err := flows.open(true, func() *dohPacketConn { return newDoHPacketConnWithClient(config{}, nil, nil, "https://dns.example", nil, nil) }) if err == nil { @@ -206,3 +210,39 @@ func TestDNSAdmissionRacesWithClose(t *testing.T) { t.Fatal("DNS admission leaked after close") } } + +func TestDNSCompletedFlowsAreEvictedWithoutDiscardingQueuedReplies(t *testing.T) { + d := &httpsConnectDialer{config: config{DoHURL: "https://unused.example"}} + defer d.Close() + query, _, _ := buildAQuery("example.test") + binary.BigEndian.PutUint16(query[len(query)-4:], 28) + var packets []net.PacketConn + for i := 0; i < maxDNSFlows; i++ { + packet, err := d.DialUDP(&M.Metadata{Network: M.UDP, DstPort: 53}) + if err != nil { + t.Fatal(err) + } + packets = append(packets, packet) + if _, err = packet.WriteTo(query, nil); err != nil { + t.Fatal(err) + } + } + if packet, err := d.DialUDP(&M.Metadata{Network: M.UDP, DstPort: 53}); err == nil { + packet.Close() + t.Fatal("queued responses were evicted") + } + if _, _, err := packets[0].ReadFrom(make([]byte, 512)); err != nil { + t.Fatal(err) + } + replacement, err := d.DialUDP(&M.Metadata{Network: M.UDP, DstPort: 53}) + if err != nil { + t.Fatal("completed flow still consumes admission", err) + } + defer replacement.Close() + if _, _, err := packets[0].ReadFrom(make([]byte, 512)); !errors.Is(err, net.ErrClosed) { + t.Fatal("completed flow was not evicted", err) + } + if _, _, err := packets[1].ReadFrom(make([]byte, 512)); err != nil { + t.Fatal("pending response was discarded", err) + } +} diff --git a/native/mobile/dns_udp.go b/native/mobile/dns_udp.go new file mode 100644 index 00000000..2fa06a36 --- /dev/null +++ b/native/mobile/dns_udp.go @@ -0,0 +1,73 @@ +package mobile + +import ( + "net" + "net/netip" + "time" + + "github.com/xjasonlyu/tun2socks/v2/buffer" + "github.com/xjasonlyu/tun2socks/v2/core/adapter" + M "github.com/xjasonlyu/tun2socks/v2/metadata" + "github.com/xjasonlyu/tun2socks/v2/tunnel" + "github.com/xjasonlyu/tun2socks/v2/tunnel/statistic" +) + +// DNS eviction must close both relay directions, including the idle TUN endpoint. +// The upstream UDP pipe otherwise waits for the idle origin until its timeout. +// Other TCP/UDP traffic keeps the upstream tunnel's forwarding behavior. +type dnsTransport struct{ *tunnel.Tunnel } + +func (t dnsTransport) HandleUDP(origin adapter.UDPConn) { + id := origin.ID() + if id.LocalPort != 53 { + t.Tunnel.HandleUDP(origin) + return + } + source, _ := netip.AddrFromSlice(id.RemoteAddress.AsSlice()) + target, _ := netip.AddrFromSlice(id.LocalAddress.AsSlice()) + metadata := &M.Metadata{Network: M.UDP, SrcIP: source, SrcPort: id.RemotePort, DstIP: target, DstPort: id.LocalPort} + packet, err := t.Proxy().DialUDP(metadata) + if err != nil { + _ = origin.Close() + return + } + flow, ok := packet.(*dnsFlow) + if !ok { + _ = packet.Close() + _ = origin.Close() + return + } + flow.bindOrigin(origin) + remote := statistic.NewUDPTracker(packet, metadata, statistic.DefaultManager) + peer := metadata.UDPAddr() + // Admission above bounds the buffers and goroutines even during DNS floods. + go func() { + defer remote.Close() + defer origin.Close() + go func() { + defer remote.Close() + defer origin.Close() + relayDNSPackets(remote, origin, peer, nil) + }() + relayDNSPackets(origin, remote, nil, func() { flow.complete(true) }) + }() +} + +func relayDNSPackets(dst, src net.PacketConn, peer net.Addr, delivered func()) { + buf := buffer.Get(buffer.MaxSegmentSize) + defer buffer.Put(buf) + for { + _ = src.SetReadDeadline(time.Now().Add(60 * time.Second)) + n, _, err := src.ReadFrom(buf) + if err != nil { + return + } + if _, err = dst.WriteTo(buf[:n], peer); err != nil { + return + } + if delivered != nil { + delivered() + } + _ = dst.SetReadDeadline(time.Now().Add(60 * time.Second)) + } +} diff --git a/native/mobile/masque.go b/native/mobile/masque.go index ad5aca72..1901e04b 100644 --- a/native/mobile/masque.go +++ b/native/mobile/masque.go @@ -525,7 +525,7 @@ func (d *masqueDialer) DialUDP(metadata *M.Metadata) (net.PacketConn, error) { } client, limiter := d.dohClient, d.dohInFlight d.mu.Unlock() - return d.direct.dnsFlows.open(func() *dohPacketConn { + return d.direct.dnsFlows.open(metadata.Network == M.UDP, func() *dohPacketConn { packet := newDoHPacketConnWithClient(d.config, d.reporter, d.connectTarget, d.config.DoHURL, client, limiter) packet.connectUDP = d.connectDNSUDP return packet diff --git a/native/mobile/masque_test.go b/native/mobile/masque_test.go index 1e115582..24bca5c5 100644 --- a/native/mobile/masque_test.go +++ b/native/mobile/masque_test.go @@ -249,7 +249,7 @@ func TestMASQUERoutingPolicy(t *testing.T) { t.Fatal(err) } defer dns.Close() - doh, ok := dns.(*dohPacketConn) + doh, ok := dns.(*dnsFlow) if !ok || len(doh.endpoints) != 2 || doh.endpoints[0] != c.DoHURL || doh.endpoints[1] != c.DoHFallbackURLs[0] { t.Fatal("DNS did not retain selected DoH and fallback policy") } diff --git a/native/mobile/mobile.go b/native/mobile/mobile.go index 9ea28e62..d326e30f 100644 --- a/native/mobile/mobile.go +++ b/native/mobile/mobile.go @@ -124,7 +124,7 @@ func Start(tunFD int, mtu int, rawConfig string, protector Protector, reporter R t.SetProxy(sshProxy) proxyCloser = sshProxy } - netstack, err := core.CreateStack(&core.Config{LinkEndpoint: dev, TransportHandler: t}) + netstack, err := core.CreateStack(&core.Config{LinkEndpoint: dev, TransportHandler: dnsTransport{t}}) if err != nil { dev.Close() if proxyCloser != nil { diff --git a/native/mobile/socks5.go b/native/mobile/socks5.go index b4d66d1c..b07f18da 100644 --- a/native/mobile/socks5.go +++ b/native/mobile/socks5.go @@ -274,7 +274,7 @@ func (d *socks5Dialer) DialUDP(m *M.Metadata) (net.PacketConn, error) { } client, limiter := d.dohClient, d.dohInFlight d.mu.Unlock() - return d.dnsFlows.open(func() *dohPacketConn { + return d.dnsFlows.open(m.Network == M.UDP, func() *dohPacketConn { packet := newDoHPacketConnWithClient(d.config, d.reporter, d.connectTarget, d.config.DoHURL, client, limiter) packet.connectUDP = d.connectDNSUDP return packet diff --git a/native/mobile/ssh_dialer.go b/native/mobile/ssh_dialer.go index 44850836..ba261f54 100644 --- a/native/mobile/ssh_dialer.go +++ b/native/mobile/ssh_dialer.go @@ -404,7 +404,7 @@ func (d *sshDialer) DialUDP(metadata *M.Metadata) (net.PacketConn, error) { return nil, errUDPBlocked } client, limiter := d.sharedDoHResources() - return d.direct.dnsFlows.open(func() *dohPacketConn { + return d.direct.dnsFlows.open(metadata.Network == M.UDP, func() *dohPacketConn { packet := newDoHPacketConnWithClient(d.config, d.reporter, d.connectTarget, d.config.DoHURL, client, limiter) packet.connectUDP = d.connectDNSUDP return packet From 6807414a31e6976d7de60097c1a011ae8e91d2f9 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Wed, 21 Oct 2026 15:02:35 +0300 Subject: [PATCH 4/6] ci: capture guest and host evidence for emulator transport loss --- .github/workflows/android-device.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android-device.yml b/.github/workflows/android-device.yml index 716d9144..81b39b87 100644 --- a/.github/workflows/android-device.yml +++ b/.github/workflows/android-device.yml @@ -72,12 +72,25 @@ jobs: avd-name: megaproxy-device profile: pixel_5 force-avd-creation: false - emulator-options: -accel on -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none + # Stream guest failures independently of ADB, including when its transport goes offline. + emulator-options: -accel on -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none -show-kernel -logcat '*:W' disable-animations: true script: | mkdir -p test-results/android-api${{ inputs.api }} touch test-results/android-api${{ inputs.api }}/runner-started - bundle exec fastlane android device_tests api:${{ inputs.api }} + result=0 + bundle exec fastlane android device_tests api:${{ inputs.api }} || result=$? + if [ "$result" -ne 0 ]; then + { + date -u + "$ANDROID_HOME/platform-tools/adb" devices -l + ps -eo pid,ppid,stat,rss,comm + free -m + df -h + sudo dmesg --ctime | tail -200 + } > test-results/android-api${{ inputs.api }}/host-diagnostics.txt 2>&1 + fi + exit "$result" - name: Retry emulator startup before tests only id: device_retry if: ${{ !cancelled() && steps.device_tests.outcome == 'failure' && hashFiles(format('test-results/android-api{0}/runner-started', inputs.api)) == '' }} From 3c60ecb614f86a6f3842448ff866f2fd1cf20e60 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Wed, 21 Oct 2026 15:17:27 +0300 Subject: [PATCH 5/6] ci: collect runner diagnostics in a single workflow shell --- .github/workflows/android-device.yml | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/.github/workflows/android-device.yml b/.github/workflows/android-device.yml index 81b39b87..6c494df0 100644 --- a/.github/workflows/android-device.yml +++ b/.github/workflows/android-device.yml @@ -78,19 +78,21 @@ jobs: script: | mkdir -p test-results/android-api${{ inputs.api }} touch test-results/android-api${{ inputs.api }}/runner-started - result=0 - bundle exec fastlane android device_tests api:${{ inputs.api }} || result=$? - if [ "$result" -ne 0 ]; then - { - date -u - "$ANDROID_HOME/platform-tools/adb" devices -l - ps -eo pid,ppid,stat,rss,comm - free -m - df -h - sudo dmesg --ctime | tail -200 - } > test-results/android-api${{ inputs.api }}/host-diagnostics.txt 2>&1 - fi - exit "$result" + bundle exec fastlane android device_tests api:${{ inputs.api }} + - name: Collect runner diagnostics + if: ${{ !cancelled() && steps.device_tests.outcome == 'failure' }} + env: + DEVICE_API: ${{ inputs.api }} + run: | + mkdir -p "test-results/android-api$DEVICE_API" + { + date -u + "$ANDROID_HOME/platform-tools/adb" devices -l + ps -eo pid,ppid,stat,rss,comm + free -m + df -h + sudo dmesg --ctime | tail -200 + } > "test-results/android-api$DEVICE_API/host-diagnostics.txt" 2>&1 - name: Retry emulator startup before tests only id: device_retry if: ${{ !cancelled() && steps.device_tests.outcome == 'failure' && hashFiles(format('test-results/android-api{0}/runner-started', inputs.api)) == '' }} From f6b279d11488bbd1783eb964f41637fde0db711a Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Wed, 21 Oct 2026 15:20:06 +0300 Subject: [PATCH 6/6] ci: avoid conflicting emulator serial log outputs --- .github/workflows/android-device.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android-device.yml b/.github/workflows/android-device.yml index 6c494df0..c14c6c71 100644 --- a/.github/workflows/android-device.yml +++ b/.github/workflows/android-device.yml @@ -72,8 +72,9 @@ jobs: avd-name: megaproxy-device profile: pixel_5 force-avd-creation: false - # Stream guest failures independently of ADB, including when its transport goes offline. - emulator-options: -accel on -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none -show-kernel -logcat '*:W' + # Stream kernel failures independently of ADB. Combining this with -logcat + # makes API 35 assign two serial devices to stdio and abort before boot. + emulator-options: -accel on -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none -show-kernel disable-animations: true script: | mkdir -p test-results/android-api${{ inputs.api }}