From 1bf3e4e9b87de2c035ab7c91a9b655cdf87fecb1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 19 Aug 2026 22:56:15 -0700 Subject: [PATCH] fix(android): gate gateway RPC polling on the hello method catalog (#126540) * fix(android): gate gateway RPC polling on the hello method catalog Released 2026.7.x gateways authorize before dispatch and reject unknown methods with "missing scope: operator.admin", so the app's "unknown method: X" detectors never fired: outbox sends parked forever behind an ~800ms sessions.branches.list retry loop and question.list retried on every health event. Generalize the progress-card negotiation (3377a21c4e2) into a tri-state gatewayAdvertisesMethod seam fed by hello features.methods and skip sessions.branches.list, question.list, and progressCard.get when the gateway does not advertise them; branch scopes reconcile immediately and queued sends flush. * fix(android): keep the hello method catalog unknown when hello omits features.methods A successful connect without a usable features.methods list must not read as a known-empty catalog: parse it as null so gatewayAdvertisesMethod stays tri-state and the catalog gates no-op instead of skipping documented RPCs. Pairing capabilities keep positive-advertisement semantics via orEmpty(). Addresses the ClawSweeper P1 on #126540. --- .../main/java/ai/openclaw/app/NodeRuntime.kt | 13 ++-- .../ai/openclaw/app/chat/ChatController.kt | 61 +++++++++++-------- .../ai/openclaw/app/gateway/GatewaySession.kt | 3 +- .../ChatControllerBranchCoordinationTest.kt | 45 ++++++++++++++ .../chat/ChatControllerProgressCardTest.kt | 24 +++++--- .../ai/openclaw/app/chat/ChatQuestionTest.kt | 24 ++++++++ .../ai/openclaw/app/chat/ChatReplayHarness.kt | 6 +- .../gateway/GatewaySessionReconnectTest.kt | 24 +++++++- 8 files changed, 155 insertions(+), 45 deletions(-) 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 13c6ac1437b4..fcf3f297d433 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 @@ -1326,7 +1326,7 @@ class NodeRuntime private constructor( // response from publishing into a replacement socket on the same stable endpoint. private val gatewayMethodsLock = Any() private var gatewayApprovalRpcFamily = GatewayApprovalRpcFamily.Unavailable - private var gatewayProgressCardAdvertised: Boolean? = null + private var gatewayAdvertisedMethods: Set? = null private var gatewayMethodsEpoch = 0L @Volatile internal var gatewayDataRequestOverrideForTests: GatewayDataRequestOverride? = null @@ -1404,8 +1404,9 @@ class NodeRuntime private constructor( replaceGatewayMethods(hello.methods) val operatorScopes = normalizeOperatorScopes(hello.authScopes) _operatorScopes.value = operatorScopes + // Pairing capabilities require positive hello advertisement; an unknown catalog grants none. _devicePairingCapabilities.value = - selectGatewayDevicePairingCapabilities(hello.methods, operatorScopes) + selectGatewayDevicePairingCapabilities(hello.methods.orEmpty(), operatorScopes) _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB val mainSessionKey = prepareMainSessionKey(resolveAgentIdFromMainSessionKey(hello.mainSessionKey)) @@ -1918,7 +1919,7 @@ class NodeRuntime private constructor( cacheScope = ::chatCacheScope, currentDefaultAgentId = { gatewayDefaultAgentId.value }, currentDefaultAgentRevision = gatewayDefaultAgentRevision::get, - gatewayAdvertisesProgressCard = ::gatewayAdvertisesProgressCard, + gatewayAdvertisesMethod = ::gatewayAdvertisesMethod, commandOutbox = chatCommandOutbox, recordModelRecent = prefs::recordModelRecent, onSessionDeleted = ::publishChatSessionDeletion, @@ -1934,7 +1935,7 @@ class NodeRuntime private constructor( scope = scope, json = json, requestGateway = AndroidScreenshotFixture::request, - gatewayAdvertisesProgressCard = { true }, + gatewayAdvertisesMethod = { _ -> true }, ) }.also { it.applyMainSessionKey(_mainSessionKey.value) @@ -7400,8 +7401,8 @@ class NodeRuntime private constructor( private fun replaceGatewayMethods(methods: Set?) { synchronized(gatewayMethodsLock) { val advertisedMethods = methods.orEmpty() + gatewayAdvertisedMethods = methods gatewayApprovalRpcFamily = selectGatewayApprovalRpcFamily(advertisedMethods) - gatewayProgressCardAdvertised = methods?.let { GatewayMethod.ProgressCardGet.rawValue in it } _clawHubSkillMethodsAvailable.value = supportsClawHubSkillManagement(advertisedMethods) _desktopObserveAvailable.value = GatewayMethod.DesktopObserve.rawValue in advertisedMethods systemAgentChatSupported.value = GatewayMethod.OpenclawChat.rawValue in advertisedMethods @@ -7409,7 +7410,7 @@ class NodeRuntime private constructor( } } - private fun gatewayAdvertisesProgressCard(): Boolean? = synchronized(gatewayMethodsLock) { gatewayProgressCardAdvertised } + private fun gatewayAdvertisesMethod(method: String): Boolean? = synchronized(gatewayMethodsLock) { gatewayAdvertisedMethods?.let { method in it } } private fun captureGatewayMethods(): GatewayMethodsSnapshot = synchronized(gatewayMethodsLock) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 11079f336283..1228fd318a3d 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -101,6 +101,8 @@ private class MainSessionReadiness( var job: Job? = null } +private class BranchListingUnsupportedException : IllegalStateException("sessions.branches.list is not supported by this gateway") + class ChatController internal constructor( private val scope: CoroutineScope, private val json: Json, @@ -109,7 +111,7 @@ class ChatController internal constructor( { method, paramsJson, _ -> requestGateway(method, paramsJson) }, private val requestGatewayForGateway: suspend (gatewayId: String, method: String, paramsJson: String?) -> String = { _, method, paramsJson -> requestGateway(method, paramsJson) }, - private val gatewayAdvertisesProgressCard: () -> Boolean? = { null }, + private val gatewayAdvertisesMethod: (method: String) -> Boolean? = { null }, private val captureSettingsRequestLease: (gatewayScope: ChatCacheScope?) -> GatewaySession.RequestLease? = { gatewayScope -> GatewaySession.RequestLease(endpointStableId = gatewayScope?.gatewayId.orEmpty()) { method, paramsJson, _ -> @@ -152,7 +154,7 @@ class ChatController internal constructor( cacheScope: () -> ChatCacheScope? = { null }, currentDefaultAgentId: () -> String? = { "main" }, currentDefaultAgentRevision: () -> Long = { 0L }, - gatewayAdvertisesProgressCard: () -> Boolean? = { null }, + gatewayAdvertisesMethod: (method: String) -> Boolean? = { null }, commandOutbox: ChatCommandOutbox? = null, recordModelRecent: (String) -> Unit = {}, onSessionDeleted: (ChatSessionDeletion) -> Unit = {}, @@ -168,7 +170,7 @@ class ChatController internal constructor( requestGatewayForGateway = { gatewayId, method, paramsJson -> session.requestForEndpoint(gatewayId, method, paramsJson) }, - gatewayAdvertisesProgressCard = gatewayAdvertisesProgressCard, + gatewayAdvertisesMethod = gatewayAdvertisesMethod, captureSettingsRequestLease = { gatewayScope -> session.captureRequestLease(gatewayScope?.gatewayId) }, @@ -1772,6 +1774,7 @@ class ChatController internal constructor( sessionKey: String, ownerAgentId: String, ): List { + if (gatewayAdvertisesMethod("sessions.branches.list") == false) throw BranchListingUnsupportedException() val params = buildJsonObject { put("sessionKey", JsonPrimitive(sessionKey)) @@ -1914,7 +1917,9 @@ class ChatController internal constructor( } } - private fun branchListingUnsupported(error: Throwable): Boolean = error.message?.contains("unknown method: sessions.branches.list", ignoreCase = true) == true + private fun branchListingUnsupported(error: Throwable): Boolean = + error is BranchListingUnsupportedException || + error.message?.contains("unknown method: sessions.branches.list", ignoreCase = true) == true private suspend fun refreshHistoryForSessionAction( snapshot: SessionActionSnapshot, @@ -3409,27 +3414,34 @@ class ChatController internal constructor( gatewayScope: ChatCacheScope?, ): Boolean { val response = - try { - requestGatewayBound(gatewayScope?.gatewayId, "question.list", "{}") - } catch (err: GatewayRequestRejected) { - val unavailable = - err.gatewayError.missingScope() == "operator.questions" || - ( - err.gatewayError.code == "INVALID_REQUEST" && - err.gatewayError.message == "unknown method: question.list" - ) - if (!unavailable) throw err - if (!questionRefreshIsCurrent(refreshGeneration, stateRevision, gatewayScope)) return false - return synchronized(questionStateLock) { - if (!questionRefreshIsCurrentLocked(refreshGeneration, stateRevision)) return@synchronized false - if (_questions.value.isNotEmpty()) { - _questions.value = emptyList() - questionStateRevision += 1 - } - syncQuestionEvictionsLocked() - true + if (gatewayAdvertisesMethod("question.list") == false) { + null + } else { + try { + requestGatewayBound(gatewayScope?.gatewayId, "question.list", "{}") + } catch (err: GatewayRequestRejected) { + val unavailable = + err.gatewayError.missingScope() == "operator.questions" || + ( + err.gatewayError.code == "INVALID_REQUEST" && + err.gatewayError.message == "unknown method: question.list" + ) + if (!unavailable) throw err + null } } + if (response == null) { + if (!questionRefreshIsCurrent(refreshGeneration, stateRevision, gatewayScope)) return false + return synchronized(questionStateLock) { + if (!questionRefreshIsCurrentLocked(refreshGeneration, stateRevision)) return@synchronized false + if (_questions.value.isNotEmpty()) { + _questions.value = emptyList() + questionStateRevision += 1 + } + syncQuestionEvictionsLocked() + true + } + } if (!questionRefreshIsCurrent(refreshGeneration, stateRevision, gatewayScope)) return false val listedRecords = json.decodeFromString(response).questions val listedIds = listedRecords.mapTo(mutableSetOf()) { it.id } @@ -5889,7 +5901,7 @@ class ChatController internal constructor( // SUNSET 2026-10-18: this fallback is a fixed cutover window, not a permanent contract. // On that date delete it together with the Gateway's legacy stream:"plan" dual-emit and // the Apple twin in ChatViewModel+TransportEvents.swift. Tracked: #125639. - if (gatewayAdvertisesProgressCard() != false) return + if (gatewayAdvertisesMethod("progressCard.get") != false) return val planData = data ?: return if (planData["phase"].asStringOrNull() != "update") return val steps = parseChatPlanSteps(planData["steps"]) @@ -6098,6 +6110,7 @@ class ChatController internal constructor( } private fun refreshProgressCard() { + if (gatewayAdvertisesMethod("progressCard.get") == false) return val sessionKey = normalizeRequestedSessionKey(_sessionKey.value) val gatewayScope = currentCacheScope() val generation = progressCardFetchGeneration.incrementAndGet() 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 3c19954c59de..fa619c64f4a0 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 @@ -214,7 +214,7 @@ data class GatewayHelloSummary( val updateAvailable: GatewayUpdateAvailableSummary?, val authRole: String? = null, val authScopes: List = emptyList(), - val methods: Set = emptySet(), + val methods: Set? = null, ) data class GatewayUpdateAvailableSummary( @@ -1441,7 +1441,6 @@ class GatewaySession( .asArrayOrNull() ?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf { method -> method.isNotEmpty() } } ?.toSet() - .orEmpty() val authObj = obj["auth"].asObjectOrNull() val deviceToken = authObj?.get("deviceToken").asStringOrNull() val authRole = authObj?.get("role").asStringOrNull() ?: options.role diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerBranchCoordinationTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerBranchCoordinationTest.kt index 5c5dc6705ced..fe457134ee0d 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerBranchCoordinationTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerBranchCoordinationTest.kt @@ -1,6 +1,8 @@ package ai.openclaw.app.chat import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown +import ai.openclaw.app.gateway.GatewayRequestRejected +import ai.openclaw.app.gateway.GatewaySession import androidx.room.Room import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher @@ -53,6 +55,7 @@ class ChatControllerBranchCoordinationTest { private fun controller( gateway: ScriptedGateway, dispatcher: CoroutineDispatcher = Dispatchers.Default, + gatewayAdvertisesMethod: (method: String) -> Boolean? = { null }, ): ChatController { val controllerScope = CoroutineScope(SupervisorJob() + dispatcher) controllerScopes += controllerScope @@ -61,6 +64,7 @@ class ChatControllerBranchCoordinationTest { json = json, requestGateway = gateway::request, cacheScope = { ChatCacheScope("gateway-a", 1) }, + gatewayAdvertisesMethod = gatewayAdvertisesMethod, commandOutbox = outbox, ) } @@ -261,6 +265,47 @@ class ChatControllerBranchCoordinationTest { assertFalse(outbox.branchState("gateway-a", ChatOutboxScope("main", "main"))?.needsReconciliation == true) } + @Test + fun gatewayWithoutBranchListingDispatchesQueuedInputWithoutRequestingBranches() = + runTest { + val gateway = ScriptedGateway(json) + gateway.respondWith("chat.history", historyResponse(sessionId = "main", messages = emptyList())) + gateway.respond("sessions.branches.list") { + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "missing scope: operator.admin", + ), + ) + } + gateway.respondChatSend("started") + val controller = + controller( + gateway, + StandardTestDispatcher(testScheduler), + gatewayAdvertisesMethod = { method -> method != "sessions.branches.list" }, + ) + runCurrent() + controller.awaitOutboxRestore() + controller.handleGatewayEvent("health", null) + runCurrent() + assertTrue(controller.healthOk.value) + + assertTrue(controller.sendMessageAwaitAcceptance("dispatch without branches", "off", emptyList())) + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(5_000) { + while (gateway.callCount("chat.send") == 0 && gateway.callCount("sessions.branches.list") == 0) { + runCurrent() + kotlinx.coroutines.delay(10) + } + } + } + + assertEquals(0, gateway.callCount("sessions.branches.list")) + assertEquals(1, gateway.callCount("chat.send")) + assertFalse(outbox.load("gateway-a").single().status == ChatOutboxStatus.Queued) + } + @Test fun expiredMutationLeaseReconcilesBeforeStartingTheNextAction() = runTest { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerProgressCardTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerProgressCardTest.kt index 66264ba16fee..63080ad45283 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerProgressCardTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerProgressCardTest.kt @@ -20,17 +20,20 @@ class ChatControllerProgressCardTest { private fun TestScope.newController( gateway: ScriptedGateway, - gatewayAdvertisesProgressCard: () -> Boolean? = { null }, + gatewayAdvertisesMethod: (method: String) -> Boolean? = { null }, ): ChatController = backgroundScope.createChatController( requestGateway = gateway::request, - gatewayAdvertisesProgressCard = gatewayAdvertisesProgressCard, + gatewayAdvertisesMethod = gatewayAdvertisesMethod, ) - private suspend fun TestScope.startRun(gatewayAdvertisesProgressCard: Boolean?): StartedRun { + private suspend fun TestScope.startRun(progressCardAdvertised: Boolean?): StartedRun { val gateway = ScriptedGateway(chatControllerTestJson) gateway.respondChatSend(status = "started") - val controller = newController(gateway) { gatewayAdvertisesProgressCard } + val controller = + newController(gateway) { method -> + if (method == "progressCard.get") progressCardAdvertised else true + } controller.handleGatewayEvent("health", null) runCurrent() assertTrue(controller.sendMessageAwaitAcceptance("make a plan", "off", emptyList())) @@ -59,7 +62,7 @@ class ChatControllerProgressCardTest { @Test fun legacyPlanRendersWhenGatewayLacksProgressCardStore() = runTest { - val (controller, _, runId) = startRun(gatewayAdvertisesProgressCard = false) + val (controller, _, runId) = startRun(progressCardAdvertised = false) controller.handleGatewayEvent( "agent", @@ -88,7 +91,7 @@ class ChatControllerProgressCardTest { @Test fun emptyLegacyPlanClearsFallbackCard() = runTest { - val (controller, _, runId) = startRun(gatewayAdvertisesProgressCard = false) + val (controller, _, runId) = startRun(progressCardAdvertised = false) controller.handleGatewayEvent( "agent", planEvent(runId, """{"phase":"update","steps":[{"step":"Active","status":"in_progress"}]}"""), @@ -109,7 +112,7 @@ class ChatControllerProgressCardTest { @Test fun capableGatewayIgnoresLegacyPlanDualEmit() = runTest { - val (controller, gateway, runId) = startRun(gatewayAdvertisesProgressCard = true) + val (controller, gateway, runId) = startRun(progressCardAdvertised = true) gateway.respondWith("progressCard.get", cardResponse(markdown = "Canonical")) controller.handleGatewayEvent("progressCard.changed", changedEvent("main", "1")) runCurrent() @@ -126,7 +129,7 @@ class ChatControllerProgressCardTest { @Test fun unknownGatewayCapabilityIgnoresLegacyPlan() = runTest { - val (controller, _, runId) = startRun(gatewayAdvertisesProgressCard = null) + val (controller, _, runId) = startRun(progressCardAdvertised = null) controller.handleGatewayEvent( "agent", @@ -137,9 +140,9 @@ class ChatControllerProgressCardTest { } @Test - fun failedStoreFetchPreservesLegacyFallbackCard() = + fun healthRefreshSkipsUnadvertisedStoreAndPreservesLegacyFallbackCard() = runTest { - val (controller, gateway, runId) = startRun(gatewayAdvertisesProgressCard = false) + val (controller, gateway, runId) = startRun(progressCardAdvertised = false) controller.handleGatewayEvent( "agent", planEvent(runId, """{"phase":"update","explanation":"Keep me","steps":[{"step":"Active","status":"in_progress"}]}"""), @@ -151,6 +154,7 @@ class ChatControllerProgressCardTest { runCurrent() assertEquals(expected, controller.progressCard.value) + assertEquals(0, gateway.callCount("progressCard.get")) } @Test diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt index 595219c03fef..f008f05994a0 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatQuestionTest.kt @@ -178,6 +178,30 @@ class ChatQuestionTest { assertTrue(controller.questions.value.isEmpty()) } + @Test + fun gatewayWithoutQuestionListClearsStaleCardsWithoutRequestingQuestions() = + runTest { + val (controller, requests) = + chatControllerTestSetup { + gatewayAdvertisesMethod = { method -> method != "question.list" } + respond("question.list") { + throw GatewayRequestRejected( + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "missing scope: operator.admin", + ), + ) + } + } + + controller.handleGatewayEvent("question.requested", json.encodeToString(record(id = "ask_stale"))) + controller.handleGatewayEvent("health", null) + advanceUntilIdle() + + assertTrue(requests.none { it.first == "question.list" }) + assertTrue(controller.questions.value.isEmpty()) + } + @Test fun pendingRefreshPreservesSubmissionLock() = runTest { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt index 5d48af2dee8e..c4367dbab60e 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt @@ -19,7 +19,7 @@ internal fun CoroutineScope.createChatController( cacheScope: () -> ChatCacheScope? = { null }, currentDefaultAgentId: () -> String? = { "main" }, currentDefaultAgentRevision: () -> Long = { 0L }, - gatewayAdvertisesProgressCard: () -> Boolean? = { null }, + gatewayAdvertisesMethod: (method: String) -> Boolean? = { null }, recordModelRecent: (String) -> Unit = {}, onSessionDeleted: (ChatSessionDeletion) -> Unit = {}, onOfflineDefaultAgentRestored: (String) -> Unit = {}, @@ -48,7 +48,7 @@ internal fun CoroutineScope.createChatController( cacheScope = cacheScope, currentDefaultAgentId = currentDefaultAgentId, currentDefaultAgentRevision = currentDefaultAgentRevision, - gatewayAdvertisesProgressCard = gatewayAdvertisesProgressCard, + gatewayAdvertisesMethod = gatewayAdvertisesMethod, recordModelRecent = recordModelRecent, onSessionDeleted = onSessionDeleted, onOfflineDefaultAgentRestored = onOfflineDefaultAgentRestored, @@ -61,6 +61,7 @@ internal class ChatControllerTestSetup( ) { val requests = mutableListOf>() var cacheScope: () -> ChatCacheScope? = { null } + var gatewayAdvertisesMethod: (method: String) -> Boolean? = { null } var recordModelRecent: (String) -> Unit = {} private val handlers = mutableMapOf String>() @@ -82,6 +83,7 @@ internal class ChatControllerTestSetup( val controller: ChatController by lazy { scope.createChatController( cacheScope = cacheScope, + gatewayAdvertisesMethod = gatewayAdvertisesMethod, recordModelRecent = recordModelRecent, requestGateway = { method, paramsJson -> requests += method to paramsJson diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt index 2aca809d369f..7f2b93bd7b5c 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionReconnectTest.kt @@ -238,6 +238,25 @@ class GatewaySessionReconnectTest { } } + @Test + fun connectedHelloKeepsMethodCatalogUnknownWhenHelloOmitsFeatures() = + runBlocking { + val json = Json { ignoreUnknownKeys = true } + val hello = CompletableDeferred() + val server = + startGatewayServer(json = json) { webSocket, id, method -> + if (method == "connect") webSocket.send(connectResponseFrame(id, methods = null)) + } + val harness = createReconnectHarness(onHello = hello::complete) + + try { + connectNodeSession(harness.session, server.port) + assertNull(withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { hello.await() }.methods) + } finally { + shutdownReconnectHarness(harness, server) + } + } + @Test fun disconnectAndJoinWaitsForNaturalFailureCallback() = runBlocking { @@ -1100,8 +1119,11 @@ class GatewaySessionReconnectTest { private fun connectResponseFrame( id: String, - methods: Set = emptySet(), + methods: Set? = emptySet(), ): String { + if (methods == null) { + return """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""" + } val encodedMethods = methods.joinToString(",") { JsonPrimitive(it).toString() } return """{"type":"res","id":"$id","ok":true,"payload":{"features":{"methods":[$encodedMethods]},"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""" }