diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt index 4e08bbfe9..a9f67cde1 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt @@ -359,7 +359,13 @@ class BaseService { } data.changeState(State.Connecting) - runOnMainDispatcher { + // Track the connect coroutine so stopRunner()/reload() can cancel an in-flight + // start. Without this, data.connectingJob stays null and stopRunner's + // cancelAndJoin() is a no-op: a superseded start's awaitExternalProcessesReady() + // keeps polling a now-killed sidecar port for its full (60s for MasterDnsVPN) + // window and then throws "sidecar listener not ready", surfacing a false + // "connection failed" even though the live instance is already carrying traffic. + data.connectingJob = runOnMainDispatcher { try { data.notification = createNotification(ServiceNotification.genTitle(profile)) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt index 36820a9c4..c44203c0d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt @@ -291,6 +291,11 @@ abstract class BoxInstance( val deadline = SystemClock.elapsedRealtime() + readinessTimeoutMs val pending = ports.toMutableSet() while (pending.isNotEmpty() && SystemClock.elapsedRealtime() < deadline) { + // Honor cancellation promptly: if this start was superseded (reload/profile + // switch), the connect job is cancelled and the sidecars are torn down. Exiting + // here stops us from polling a now-dead port for the full (60s for MasterDnsVPN) + // window and then throwing a false "sidecar listener not ready". + ensureActive() val iterator = pending.iterator() while (iterator.hasNext()) { val port = iterator.next() @@ -306,6 +311,16 @@ abstract class BoxInstance( if (pending.isNotEmpty()) delay(50) } if (pending.isNotEmpty()) { + // If the process pool is no longer active, its sidecars were torn down (e.g. a + // superseded start during reload). A port that never bound on a dead pool is an + // orphan, not a real failure - drop it instead of throwing. + if (!processes.isActive) { + Logs.w( + "sidecar listener not ready on port(s): ${pending.joinToString()}; " + + "process pool already stopped (superseded start), ignoring" + ) + return@withContext + } // MasterDnsVPN must have its listener up before the first dial (it crashed // otherwise), so a timeout there is fatal. Other sidecars (Mieru/Naïve/ // TrojanGo/Hysteria) were historically fire-and-forget: the first sing-box