From 7967a7ae9af2478be8d36db96dd892f951e0ebce Mon Sep 17 00:00:00 2001 From: Hidden-Node Date: Fri, 24 Jul 2026 17:59:56 +0330 Subject: [PATCH] fix(vpn): retain stopJob and reset isStopping synchronously Plan 014. Previously stopVpn() used a local CoroutineScope whose Job was unreachable from the service fields; a low-memory process pause between state=DISCONNECTED and stopSelf() could GC the cleanup coroutine mid- teardown, leaving Go core / TUN fd / WakeLock / NetworkCallback live. isStopping also stayed true until onDestroy(), blocking a fresh connect() during that gap. Fix: retain stopJob on the service so onDestroy() can join it, reset isStopping in a finally block inside the launch lambda so it goes false when teardown actually finishes, and add a DISCONNECTING guard to VpnManager.connect() so the UI can't race the disconnect dispatch. --- .../vpn/service/MasterDnsVpnService.kt | 20 +++++++++++++------ .../java/com/masterdns/vpn/util/VpnManager.kt | 4 +++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/com/masterdns/vpn/service/MasterDnsVpnService.kt b/android/app/src/main/java/com/masterdns/vpn/service/MasterDnsVpnService.kt index 40fe926..8f2200f 100644 --- a/android/app/src/main/java/com/masterdns/vpn/service/MasterDnsVpnService.kt +++ b/android/app/src/main/java/com/masterdns/vpn/service/MasterDnsVpnService.kt @@ -65,6 +65,7 @@ class MasterDnsVpnService : VpnService() { private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var connectJob: Job? = null + private var stopJob: Job? = null private var vpnInterface: ParcelFileDescriptor? = null private var goClientJob: Job? = null private var httpProxyJob: Job? = null @@ -465,10 +466,10 @@ class MasterDnsVpnService : VpnService() { if (isStopping) return isStopping = true - // Use a separate scope so that serviceScope.cancel() in onDestroy() - // does not kill this coroutine mid-cleanup. - val stopScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - stopScope.launch { + // plan 014: retain the cleanup Job in stopJob so a process pause/resume + // can't GC it mid-teardown; onDestroy() joins it before cancelling scope. + if (stopJob?.isActive == true) return + stopJob = serviceScope.launch { try { connectJob?.cancel() VpnManager.appendLog("VPN stop requested") @@ -550,9 +551,10 @@ class MasterDnsVpnService : VpnService() { VpnManager.updateState(VpnManager.VpnState.DISCONNECTED) VpnManager.stopTrafficMonitor() runCatching { stopSelf() } + } finally { + // plan 014: reset synchronously so connect() doesn't race stopSelf() + isStopping = false } - // NOTE: isStopping intentionally stays true until onDestroy() completes. - // This prevents onDestroy() from double-closing already-freed resources. } } @@ -573,6 +575,12 @@ class MasterDnsVpnService : VpnService() { } override fun onDestroy() { + // plan 014: let any in-flight stopVpn() finish before cancelling serviceScope. + val inFlightStop = stopJob + if (inFlightStop != null && inFlightStop.isActive) { + kotlinx.coroutines.runBlocking { inFlightStop.join() } + } + // Normal path: stopVpn() already ran — Go layer guards make re-calls no-ops. // Force-kill path: stopVpn() was never called, so do full cleanup. if (!isStopping) { diff --git a/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt b/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt index 012cf2d..f382365 100644 --- a/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt +++ b/android/app/src/main/java/com/masterdns/vpn/util/VpnManager.kt @@ -314,7 +314,9 @@ object VpnManager { * Start the VPN service. */ fun connect(context: Context, profile: ProfileEntity) { - if (_state.value == VpnState.CONNECTED || _state.value == VpnState.CONNECTING) return + if (_state.value == VpnState.CONNECTED || + _state.value == VpnState.CONNECTING || + _state.value == VpnState.DISCONNECTING) return updateState(VpnState.CONNECTING) clearError()