fix(android): keep cold-start gateway auto-connect from overriding explicit intents (#101799)

* fix(android): keep cold-start gateway auto-connect from overriding explicit intents

NodeRuntime's init-time auto-connect now atomically claims the first gateway
lifecycle intent (CAS 0->1) and stands down permanently when any explicit
connect/disconnect/switch intent already exists, so a late discovery emission
can no longer override a user decision. This was also the root cause of the
CI-only flakes in GatewayBootstrapAuthTest (refreshGatewayConnection_* at
:616/:627 and switchToUndiscoveredGateway* on PRs #101002/#101387/#101396).

GatewaySession.Connection.sendRequestFrame now starts its response watcher
with CoroutineStart.ATOMIC: disconnect teardown cancels connectionJob right
after failPending(), and a DEFAULT-start watcher still queued for dispatch was
cancelled without running, silently dropping the terminal UNAVAILABLE onError
owed to fire-and-forget callers (root cause of the flaky
GatewaySessionInvokeTest.disconnectReportsUnknownOutcomeForFireAndForgetRpc).

Tests neutralize runtime background work (cancel+join the runtime scope)
before arming the registry and scripting lifecycle steps, and add regression
coverage for the auto-connect intent claim.

Proof: refresh flake reproduced at iteration 1 under taskset -c 0,1 on a
Blacksmith Linux box; post-fix 3 pinned stress rounds (120/150 looped
iterations) plus full :app:testPlayDebugUnitTest (109 classes, 0 failures)
and :app:ktlintCheck green on the same box.

* chore(i18n): resync native inventory line positions after NodeRuntime edits
This commit is contained in:
Peter Steinberger 2026-07-07 18:51:46 +01:00 committed by GitHub
parent b5f67ac4c7
commit 3d53ed7ed8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 107 additions and 39 deletions

View file

@ -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",

View file

@ -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))
}
}

View file

@ -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 {

View file

@ -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<MutableStateFlow<Boolean>>(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<GatewayEndpoint>(desired, "endpoint").host)
assertEquals("shared-token", readField<String?>(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<NodeRuntime, SecurePrefs> {
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<CoroutineScope>(runtime, "scope").coroutineContext[Job]?.cancelAndJoin() }
}
private fun waitForGatewayTrustPrompt(runtime: NodeRuntime): NodeRuntime.GatewayTrustPrompt {
repeat(50) {
runtime.pendingGatewayTrust.value?.let { return it }