mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
b5f67ac4c7
commit
3d53ed7ed8
4 changed files with 107 additions and 39 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue