From 51fb8470640a753d068997753b0c87508e5e7035 Mon Sep 17 00:00:00 2001 From: Hidden-Node Date: Thu, 23 Jul 2026 23:49:12 +0330 Subject: [PATCH] fix(vpn): close stale vpnInterface on reconnect and throw on go-core stop timeout Reconnecting while the prior Go core hadn't fully stopped leaked the old ParcelFileDescriptor: ensureGoCoreStopped() only waited 4s, logged a warning, and continued, so the next builder.establish() overwrote vpnInterface at line 360 without closing the previous one. After enough reconnects the process hit its fd soft limit and establish() threw. Two-part fix: 1. Add closeStaleVpnInterface() and call it at the top of startVpn() AFTER ensureGoCoreStopped(). The ordering preserves the existing invariant (see MasterDnsVpnService.kt:491 comment): the fd is closed only once the Go core has stopped, so tun2socks goroutines mid-read on the fd don't hit EBADF. Also clears tunBridgeActive so a later stopClient() doesn't assume a live bridge on the stale fd. 2. Make ensureGoCoreStopped() throw IllegalStateException on timeout instead of logging-and-continuing. The throw propagates to the catch-all in startVpn(), which calls setError(message) and runs the proven stopVpn() cleanup path (Mobile.stopClient is Go-side idempotent; stopVpn itself is wrapped in try/catch). The user now sees a "Go core did not stop cleanly within 4 seconds" error instead of a silent CONNECTING-forever or cascading fd leaks. No recursion risk: stopVpn() does not call startVpn() or ensureGoCoreStopped() (grep-confirmed single call site at line 150). Scope: MasterDnsVpnService.kt only; no Go bridge, go.mod, or go.sum changes. Composes with plan 014 (isStopping lifecycle) cleanly. Verification: gradle build skipped per no-local-build constraint; CI on push is the gate. Grep-based done criteria pass: - closeStaleVpnInterface() x2 matches (def + call site) - throw "Go core did not stop cleanly within 4 seconds" present - "Warning: Go core may still be running" removed (0 matches) --- .../vpn/service/MasterDnsVpnService.kt | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) 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 1474577..1abd169 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 @@ -83,6 +83,24 @@ class MasterDnsVpnService : VpnService() { @Volatile private var activeLocalSocksPort: Int = DEFAULT_SOCKS_PORT + /** + * Close any ParcelFileDescriptor left from a previous session before we + * attempt establish() again. The fd must be closed AFTER the Go core + * stops (see MasterDnsVpnService.kt:457's comment) to avoid EBADF in + * tun2socks goroutines that are mid-read on the fd. + * + * Plan 013: called at the top of startVpn() so connect-after-disconnect + * racy reconnects don't leak an fd when ensureGoCoreStopped() reports + * "Go core may still be running" and we proceed anyway. + */ + private fun closeStaleVpnInterface() { + val stale = vpnInterface ?: return + VpnManager.appendLog("Closing stale TUN interface from previous session (fd=${stale.fd})") + vpnInterface = null + tunBridgeActive = false + runCatching { stale.close() } + } + override fun onCreate() { super.onCreate() Log.i(TAG, "VPN Service created") @@ -130,6 +148,7 @@ class MasterDnsVpnService : VpnService() { VpnManager.appendLog("Loading profile: ${profile.name}") ensureGoCoreStopped() + closeStaleVpnInterface() ensureSocksPortAvailable(socksPort) // Generate config files @@ -722,7 +741,12 @@ class MasterDnsVpnService : VpnService() { return } } - VpnManager.appendLog("Warning: Go core may still be running") + // Plan 013: previously this logged and continued, leaking the prior + // vpnInterface fd if the Go core hung. Now we throw so the catch-all + // at startVpn()'s bottom sets ERROR state and runs stopVpn(). The + // user sees a "Go core did not stop cleanly" error instead of a + // silent CONNECTING-forever or cascading fd leaks. + throw IllegalStateException("Go core did not stop cleanly within 4 seconds") } private fun isLocalPortInUse(port: Int): Boolean {