diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 43c58ef3210..a50477f46e2 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -203,7 +203,7 @@ }, { "kind": "conditional-branch", - "line": 2706, + "line": 2714, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: this host requires wss:// or Tailscale Serve. No TLS endpoint detected.", "surface": "android", @@ -211,7 +211,7 @@ }, { "kind": "conditional-branch", - "line": 2708, + "line": 2716, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.", "surface": "android", @@ -219,7 +219,7 @@ }, { "kind": "conditional-branch", - "line": 2710, + "line": 2718, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: couldn't reach the secure gateway endpoint for this host.", "surface": "android", @@ -387,7 +387,7 @@ }, { "kind": "conditional-branch", - "line": 1286, + "line": 1293, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Connecting…", "surface": "android", @@ -395,7 +395,7 @@ }, { "kind": "conditional-branch", - "line": 1286, + "line": 1293, "path": "apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt", "source": "Reconnecting…", "surface": "android", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index d5c3bb71d9f..28ed779e40f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -1742,7 +1742,11 @@ class NodeRuntime private constructor( // Only attempt the stored preferred gateway once per runtime lifetime; users // can still reconnect explicitly from the UI after a failed auto attempt. didAutoConnect = true - connect(endpoint) + // Cold-start fallback only: discovery can emit late, so atomically claim the very first + // lifecycle intent. If any explicit connect/disconnect/switch intent already exists, stand + // down permanently instead of overriding the user's decision with a stale auto-connect. + if (!gatewayLifecycleIntentSeq.compareAndSet(0L, 1L)) return + launchConnect(endpoint, explicitAuth = null) } private fun reconnectPreferredGatewayOnForeground() { @@ -2631,10 +2635,7 @@ class NodeRuntime private constructor( fun connect(endpoint: GatewayEndpoint) { gatewayLifecycleIntentSeq.incrementAndGet() - launchGatewayLifecycle { - prepareGatewayTarget(endpoint) - beginConnect(endpoint = endpoint, auth = resolveGatewayConnectAuth(endpoint)) - } + launchConnect(endpoint, explicitAuth = null) } fun connect( @@ -2642,9 +2643,16 @@ class NodeRuntime private constructor( auth: GatewayConnectAuth, ) { gatewayLifecycleIntentSeq.incrementAndGet() + launchConnect(endpoint, explicitAuth = auth) + } + + private fun launchConnect( + endpoint: GatewayEndpoint, + explicitAuth: GatewayConnectAuth?, + ) { launchGatewayLifecycle { prepareGatewayTarget(endpoint) - beginConnect(endpoint = endpoint, auth = resolveGatewayConnectAuth(endpoint, auth)) + beginConnect(endpoint = endpoint, auth = resolveGatewayConnectAuth(endpoint, explicitAuth)) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index 1712cda04ec..cdb06df3b26 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable @@ -611,6 +612,7 @@ class GatewaySession( } } + @OptIn(DelicateCoroutinesApi::class) suspend fun sendRequestFrame( method: String, params: JsonElement?, @@ -625,7 +627,12 @@ class GatewaySession( pending.remove(id) throw err } - connectionScope.launch(Dispatchers.IO) { + // ATOMIC start: joinOwnedWork() cancels connectionJob right after failPending(); a + // DEFAULT-start watcher still queued for dispatch would be cancelled without ever running + // and silently drop the terminal onError owed to fire-and-forget callers. ATOMIC + // guarantees this block runs; await() on the already-failed deferred then surfaces the + // disconnect outcome without suspending. + connectionScope.launch(Dispatchers.IO, start = CoroutineStart.ATOMIC) { try { val response = try { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt index c0429ec1d44..4e52ba4edc0 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayBootstrapAuthTest.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking @@ -589,34 +590,13 @@ class GatewayBootstrapAuthTest { @Test fun refreshGatewayConnection_reconnectsSavedManualEndpointAfterDisconnect() { - val app = RuntimeEnvironment.getApplication() - val securePrefs = - app.getSharedPreferences( - "openclaw.node.secure.test.${UUID.randomUUID()}", - android.content.Context.MODE_PRIVATE, - ) - val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) - prefs.setManualEnabled(true) - prefs.setManualHost("127.0.0.1") - prefs.setManualPort(18789) - prefs.setManualTls(false) - val savedEndpoint = GatewayEndpoint.manual(host = "127.0.0.1", port = 18789) - prefs.gatewayRegistry.upsert( - GatewayRegistryEntry( - stableId = savedEndpoint.stableId, - kind = GatewayRegistryEntryKind.MANUAL, - name = savedEndpoint.name, - host = savedEndpoint.host, - port = savedEndpoint.port, - tls = false, - ), - ) - prefs.gatewayRegistry.setActive(savedEndpoint.stableId) - prefs.saveGatewayCredentials(savedEndpoint.stableId, token = "initial-token") - val runtime = NodeRuntime(app, prefs) + val (runtime, prefs) = createNeutralizedRuntime() + armSavedActiveManualGateway(prefs) - waitForDesiredConnection(runtime, "nodeSession") - prefs.saveGatewayCredentials(savedEndpoint.stableId, token = "shared-token") + runtime.connect( + GatewayEndpoint.manual(host = "127.0.0.1", port = 18789), + NodeRuntime.GatewayConnectAuth(token = "initial-token", bootstrapToken = null, password = null), + ) runtime.disconnect() assertNull(desiredConnection(runtime, "nodeSession")) @@ -721,6 +701,7 @@ class GatewayBootstrapAuthTest { ) val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) val runtime = NodeRuntime(app, prefs) + neutralizeColdStartAutoConnect(runtime) val current = GatewayEndpoint.manual("127.0.0.1", 18789) val missingStableId = "bonjour-missing" prefs.gatewayRegistry.upsert( @@ -1149,6 +1130,78 @@ class GatewayBootstrapAuthTest { assertFalse(readField>(runtime, "externalAudioCaptureActive").value) } + @Test + fun coldStartAutoConnectConnectsSavedActiveGatewayWhenNoExplicitIntentExists() { + val (runtime, prefs) = createNeutralizedRuntime() + armSavedActiveManualGateway(prefs) + + invokeAutoConnectIfNeeded(runtime) + + val desired = desiredConnection(runtime, "nodeSession") ?: error("Expected desired node connection") + assertEquals("127.0.0.1", readField(desired, "endpoint").host) + assertEquals("shared-token", readField(desired, "token")) + } + + @Test + fun coldStartAutoConnectStandsDownAfterExplicitLifecycleIntent() { + val (runtime, prefs) = createNeutralizedRuntime() + armSavedActiveManualGateway(prefs) + runtime.disconnect() + + invokeAutoConnectIfNeeded(runtime) + + assertNull(desiredConnection(runtime, "nodeSession")) + } + + // Arms the registry only after the runtime's background work is neutralized, so the real + // discovery collector can never observe an auto-connectable active gateway. + private fun createNeutralizedRuntime(): Pair { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.secure.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + val prefs = SecurePrefs(app, securePrefsOverride = securePrefs) + val runtime = NodeRuntime(app, prefs) + neutralizeColdStartAutoConnect(runtime) + return runtime to prefs + } + + private fun armSavedActiveManualGateway(prefs: SecurePrefs) { + prefs.setManualEnabled(true) + prefs.setManualHost("127.0.0.1") + prefs.setManualPort(18789) + prefs.setManualTls(false) + val savedEndpoint = GatewayEndpoint.manual(host = "127.0.0.1", port = 18789) + prefs.gatewayRegistry.upsert( + GatewayRegistryEntry( + stableId = savedEndpoint.stableId, + kind = GatewayRegistryEntryKind.MANUAL, + name = savedEndpoint.name, + host = savedEndpoint.host, + port = savedEndpoint.port, + tls = false, + ), + ) + prefs.gatewayRegistry.setActive(savedEndpoint.stableId) + prefs.saveGatewayCredentials(savedEndpoint.stableId, token = "shared-token") + } + + private fun invokeAutoConnectIfNeeded(runtime: NodeRuntime) { + val method = runtime.javaClass.getDeclaredMethod("autoConnectIfNeeded") + method.isAccessible = true + method.invoke(runtime) + } + + // NodeRuntime's init collects gateway discovery on a background dispatcher and auto-connects + // the saved active gateway whenever that collector happens to run, racing scripted lifecycle + // steps (observed as CI-only flakes on loaded Linux runners). Cancel and join all runtime-scope + // work so nothing runs in the background; arm the registry only afterwards. + private fun neutralizeColdStartAutoConnect(runtime: NodeRuntime) { + runBlocking { readField(runtime, "scope").coroutineContext[Job]?.cancelAndJoin() } + } + private fun waitForGatewayTrustPrompt(runtime: NodeRuntime): NodeRuntime.GatewayTrustPrompt { repeat(50) { runtime.pendingGatewayTrust.value?.let { return it }