From 97a3d09ef4e808687d149d1898afedf266f7f74d Mon Sep 17 00:00:00 2001 From: garmr-ulfr Date: Fri, 26 Jun 2026 11:56:26 -0700 Subject: [PATCH 1/4] fix(android): recover default-interface index via Os.if_nametoindex NetworkInterface.getByName relies on the interface-enumeration syscall, which is restricted on some Android versions/ROMs (returns null or throws there). When it failed, checkDefaultInterfaceUpdate exhausted its retries without ever calling updateDefaultInterface, so sing-box never received a default interface and every outbound failed with "no available network interface". Fall back to Os.if_nametoindex when getByName cannot resolve the index, mirroring the already-hardened getInterfaces sibling, and retry only when the index is still unresolvable. Also early-return on a null interface name instead of burning the retry budget on getByName(null). --- .../lantern/service/DefaultNetworkMonitor.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt b/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt index 248fb24c23..df4b8e7e07 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt @@ -2,6 +2,7 @@ package org.getlantern.lantern.service import android.net.Network import android.os.Build +import android.system.Os import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -63,11 +64,18 @@ object DefaultNetworkMonitor { if (newNetwork != null) { val interfaceName = (LanternApp.connectivity.getLinkProperties(newNetwork) ?: return).interfaceName + ?: return for (times in 0 until 10) { - var interfaceIndex: Int - try { - interfaceIndex = NetworkInterface.getByName(interfaceName).index + // getByName relies on the interface-enumeration syscall, which is + // restricted on some Android versions/ROMs (returns null or throws + // there); Os.if_nametoindex recovers the index so the default + // interface is still delivered, otherwise every outbound fails to bind. + val interfaceIndex = try { + NetworkInterface.getByName(interfaceName)?.index } catch (e: Exception) { + null + } ?: Os.if_nametoindex(interfaceName) + if (interfaceIndex <= 0) { Thread.sleep(100) continue } From df1ca3b3838c97331eead48fd84815a3a84e7234 Mon Sep 17 00:00:00 2001 From: garmr-ulfr <104022054+garmr-ulfr@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:35:13 -0700 Subject: [PATCH 2/4] move `Os.if_nametoindex` inside the `try` and yield 0 on throw Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../org/getlantern/lantern/service/DefaultNetworkMonitor.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt b/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt index df4b8e7e07..0e02bf49df 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt @@ -71,10 +71,10 @@ object DefaultNetworkMonitor { // there); Os.if_nametoindex recovers the index so the default // interface is still delivered, otherwise every outbound fails to bind. val interfaceIndex = try { - NetworkInterface.getByName(interfaceName)?.index + NetworkInterface.getByName(interfaceName)?.index ?: Os.if_nametoindex(interfaceName) } catch (e: Exception) { - null - } ?: Os.if_nametoindex(interfaceName) + 0 + } if (interfaceIndex <= 0) { Thread.sleep(100) continue From 3a263237a3adb786e97633c0a9c4f64e77d6d8b6 Mon Sep 17 00:00:00 2001 From: garmr-ulfr Date: Tue, 30 Jun 2026 12:39:32 -0700 Subject: [PATCH 3/4] fix(android): resolve default interface inside the retry loop getLinkProperties (and its interfaceName) can briefly return null right after a network change. Resolving it once before the loop meant a transient null abandoned the update entirely, leaving the default interface unset for that network change. Resolve inside the retry loop instead, matching upstream, so a transient null is retried rather than dropped. --- .../lantern/service/DefaultNetworkMonitor.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt b/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt index 0e02bf49df..59fab551d1 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt @@ -62,10 +62,15 @@ object DefaultNetworkMonitor { ) { val listener = listener ?: return if (newNetwork != null) { - val interfaceName = - (LanternApp.connectivity.getLinkProperties(newNetwork) ?: return).interfaceName - ?: return for (times in 0 until 10) { + // getLinkProperties can briefly return null (or a null interfaceName) + // right after a network change, so resolve inside the retry loop. + val interfaceName = + LanternApp.connectivity.getLinkProperties(newNetwork)?.interfaceName + if (interfaceName == null) { + Thread.sleep(100) + continue + } // getByName relies on the interface-enumeration syscall, which is // restricted on some Android versions/ROMs (returns null or throws // there); Os.if_nametoindex recovers the index so the default From 940b673e2479411c7b66d1a3767831e711922e8d Mon Sep 17 00:00:00 2001 From: garmr-ulfr <104022054+garmr-ulfr@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:56:55 -0700 Subject: [PATCH 4/4] fix(android): deliver default-interface updates synchronously (#8890) * fix(android): deliver default-interface updates synchronously checkDefaultInterfaceUpdate dispatched each updateDefaultInterface call to GlobalScope.launch(Dispatchers.IO), so successive default-network changes could be applied out of order, pinning a stale interface index (Go side is last-writer-wins). The golang/go#68760 stack workaround that the launch was duplicating is already handled on the Go side (libbox SetupOptions.FixAndroidStack, enabled by radiance), so delivering synchronously preserves the network-change ordering that DefaultNetworkListener already guarantees. Remove the now-unused Bugs.fixAndroidStack helper. * refactor(android): serialize interface updates on a background thread Default-network callbacks are delivered on the main looper, so resolving the interface (which sleeps between retries and calls into Go over JNI) and delivering the update ran on the UI thread. Move that work onto a single-thread executor: it stays off the UI thread while still applying updates in network-change order, which a shared pool would not guarantee. Extract the resolution into resolveDefaultInterface/getInterfaceIndex/ sleepBeforeRetry helpers, snapshot the listener on the caller thread and mark it @Volatile so the worker reliably observes setListener writes, and recover via if_nametoindex when getByName throws (not just when it returns null). Add logging when the interface is resolved, cleared, or fails to resolve. * fix(android): skip interface updates for a torn-down listener notifyDefaultInterface re-checks that the snapshotted listener is still the active one before calling into libbox, so an update queued or being resolved across a setListener teardown can no longer invoke a closed handler. Also tidy the resolution logging: split the getByName attempt from the if_nametoindex fallback so the warning names the actual failing call and the fallback runs once, and drop the fixed attempt-count from the failure log since resolution can bail early on interruption. * fix(android): guard if_nametoindex fallback behind API 26 Os.if_nametoindex was added in API 26, but the app supports API 24+. Skip the name-based fallback on API 24/25 with an SDK_INT check instead of letting the call fail, and drop the runCatching that was masking it. --- .../lantern/service/DefaultNetworkMonitor.kt | 135 ++++++++++++------ .../org/getlantern/lantern/utils/Bugs.kt | 13 -- 2 files changed, 95 insertions(+), 53 deletions(-) delete mode 100644 android/app/src/main/kotlin/org/getlantern/lantern/utils/Bugs.kt diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt b/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt index 59fab551d1..5d6ff6a032 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/service/DefaultNetworkMonitor.kt @@ -4,17 +4,29 @@ import android.net.Network import android.os.Build import android.system.Os -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch import lantern.io.libbox.InterfaceUpdateListener import org.getlantern.lantern.LanternApp -import org.getlantern.lantern.utils.Bugs +import org.getlantern.lantern.utils.AppLogger import java.net.NetworkInterface +import java.util.concurrent.Executors object DefaultNetworkMonitor { + private const val TAG = "DefaultNetworkMonitor" + private const val NO_INTERFACE_NAME = "" + private const val NO_INTERFACE_INDEX = -1 + private const val INTERFACE_RESOLUTION_RETRY_COUNT = 10 + private const val INTERFACE_RESOLUTION_RETRY_DELAY_MS = 100L + + private data class ResolvedInterface( + val name: String, + val index: Int, + ) var defaultNetwork: Network? = null + + // Written by setListener and read by checkDefaultInterfaceUpdate on possibly + // different threads, so @Volatile guarantees the read sees the latest write. + @Volatile private var listener: InterfaceUpdateListener? = null private var networkChangeCallback: ((Network?) -> Unit)? = null @@ -57,51 +69,94 @@ object DefaultNetworkMonitor { checkDefaultInterfaceUpdate(defaultNetwork) } - private fun checkDefaultInterfaceUpdate( - newNetwork: Network? - ) { + // Default-network callbacks arrive on the main looper, so resolution runs on a + // single background thread to keep the retry loop's sleeps and the JNI call off + // the UI thread while still applying updates in network-change order. The monitor + // is a process-wide singleton; the daemon worker intentionally lives for the + // process lifetime, so there is no per-session shutdown. + private val updateExecutor = Executors.newSingleThreadExecutor { r -> + Thread(r, "default-interface-monitor").apply { isDaemon = true } + } + + private fun checkDefaultInterfaceUpdate(newNetwork: Network?) { val listener = listener ?: return - if (newNetwork != null) { - for (times in 0 until 10) { - // getLinkProperties can briefly return null (or a null interfaceName) - // right after a network change, so resolve inside the retry loop. - val interfaceName = - LanternApp.connectivity.getLinkProperties(newNetwork)?.interfaceName - if (interfaceName == null) { - Thread.sleep(100) - continue + updateExecutor.execute { + when (newNetwork) { + null -> { + AppLogger.i(TAG, "Default network lost; clearing default interface") + notifyDefaultInterface(listener, NO_INTERFACE_NAME, NO_INTERFACE_INDEX) } + else -> { + val resolved = resolveDefaultInterface(newNetwork) + if (resolved == null) { + AppLogger.w(TAG, "Failed to resolve default interface for the new default network") + return@execute + } + AppLogger.i(TAG, "Default interface resolved: ${resolved.name} (index ${resolved.index})") + notifyDefaultInterface(listener, resolved.name, resolved.index) + } + } + } + } + + private fun notifyDefaultInterface(listener: InterfaceUpdateListener, name: String, index: Int) { + // A teardown may clear or replace the listener while this update sits queued + // or is being resolved; don't call into a stale (closed) libbox handler. + if (this.listener !== listener) return + listener.updateDefaultInterface(name, index, false, false) + } + + private fun resolveDefaultInterface(network: Network): ResolvedInterface? { + repeat(INTERFACE_RESOLUTION_RETRY_COUNT) { attempt -> + // getLinkProperties can briefly return null (or a null interfaceName) + // right after a network change, so resolve inside the retry loop. + val interfaceName = LanternApp.connectivity + .getLinkProperties(network) + ?.interfaceName + + if (interfaceName != null) { // getByName relies on the interface-enumeration syscall, which is // restricted on some Android versions/ROMs (returns null or throws // there); Os.if_nametoindex recovers the index so the default // interface is still delivered, otherwise every outbound fails to bind. - val interfaceIndex = try { - NetworkInterface.getByName(interfaceName)?.index ?: Os.if_nametoindex(interfaceName) - } catch (e: Exception) { - 0 - } - if (interfaceIndex <= 0) { - Thread.sleep(100) - continue + val interfaceIndex = getInterfaceIndex(interfaceName) + if (interfaceIndex > 0) { + return ResolvedInterface(interfaceName, interfaceIndex) } - if (Bugs.fixAndroidStack) { - GlobalScope.launch(Dispatchers.IO) { - listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false) - } - } else { - listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false) - } - return // successfully notified, don't retry } - } else { - if (Bugs.fixAndroidStack) { - GlobalScope.launch(Dispatchers.IO) { - listener.updateDefaultInterface("", -1, false, false) - } - } else { - listener.updateDefaultInterface("", -1, false, false) + + val hasMoreAttempts = attempt < INTERFACE_RESOLUTION_RETRY_COUNT - 1 + if (hasMoreAttempts && !sleepBeforeRetry()) { + return null } } + + return null } -} \ No newline at end of file + private fun getInterfaceIndex(interfaceName: String): Int { + val indexByName = try { + NetworkInterface.getByName(interfaceName)?.index + } catch (e: Exception) { + AppLogger.w(TAG, "getByName failed for $interfaceName; falling back to if_nametoindex", e) + null + } + if (indexByName != null) { + return indexByName + } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Os.if_nametoindex(interfaceName) + } else { + 0 + } + } + + private fun sleepBeforeRetry(): Boolean = + try { + Thread.sleep(INTERFACE_RESOLUTION_RETRY_DELAY_MS) + true + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + false + } +} diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/utils/Bugs.kt b/android/app/src/main/kotlin/org/getlantern/lantern/utils/Bugs.kt deleted file mode 100644 index 163fc1df1c..0000000000 --- a/android/app/src/main/kotlin/org/getlantern/lantern/utils/Bugs.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.getlantern.lantern.utils - -import android.os.Build - -object Bugs { - - // TODO: remove launch after fixed - // https://github.com/golang/go/issues/68760 - val fixAndroidStack = true || - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1 || - Build.VERSION.SDK_INT >= Build.VERSION_CODES.P - -} \ No newline at end of file