diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index 8aeefc052979..cffb86295462 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -965,10 +965,25 @@ extension GatewayNodeSession { channel: GatewayChannelActor, socketGeneration: UInt64) async { - guard await self.awaitLifecycleCallbacks(ifCurrentRoute: route) else { return } guard self.isCurrentRoute(route), self.channel === channel else { return } + // Lifecycle cleanup gates owner readiness. Reject while it is suspended instead of + // holding the Gateway request until timeout; the replacement route stays fail-closed. + if self.lifecycleCallbackBarrier != nil { + self.logger.info("node invoke rejected during lifecycle transition id=\(request.id, privacy: .public)") + await self.sendInvokeResult( + request: request, + response: BridgeInvokeResponse( + id: request.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: "UNAVAILABLE: node lifecycle transition in progress")), + channel: channel, + socketGeneration: socketGeneration) + return + } self.logger.info("node invoke executing id=\(request.id, privacy: .public)") let bridgeRequest = BridgeInvokeRequest( id: request.id, @@ -1004,14 +1019,6 @@ extension GatewayNodeSession { socketGeneration: socketGeneration) } - private func awaitLifecycleCallbacks(ifCurrentRoute route: GatewayNodeSessionRoute) async -> Bool { - while let lifecycleCallback = self.lifecycleCallbackBarrier { - await lifecycleCallback.task.value - guard self.isCurrentRoute(route), self.channel != nil else { return false } - } - return self.isCurrentRoute(route) && self.channel != nil - } - func invokeIfCurrentRoute( _ request: BridgeInvokeRequest, expectedRoute: GatewayNodeSessionRoute, diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index 0c2c6ec18db9..ad876a417339 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -1145,6 +1145,71 @@ struct GatewayNodeSessionTests { await gateway.disconnect() } + @Test + func `replacement invoke fails promptly while disconnect lifecycle is blocked`() async throws { + let session = FakeGatewayWebSocketSession() + let gateway = GatewayNodeSession() + let invalidationGate = AsyncGate() + let invocations = DisconnectProbe() + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: ["system.which"], + permissions: [:], + clientId: "openclaw-macos", + clientMode: "node", + clientDisplayName: "macOS Test", + includeDeviceIdentity: false) + + try await gateway.connect( + url: #require(URL(string: "ws://first.example.invalid")), + token: nil, + bootstrapToken: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { request in + await invocations.record(request.id) + return BridgeInvokeResponse(id: request.id, ok: true, payloadJSON: nil, error: nil) + }, + onRouteInvalidated: { await invalidationGate.wait() }) + let firstTask = try #require(session.latestTask()) + try await waitUntil("receive loop armed before disconnect") { + firstTask.hasPendingReceiveHandler() + } + + firstTask.emitReceiveFailure() + try await waitUntil("disconnect lifecycle blocked") { + await invalidationGate.hasStarted() + } + try await waitUntil("replacement transport connected") { + session.snapshotMakeCount() == 2 + } + let replacementTask = try #require(session.latestTask()) + try await waitUntil("replacement socket receiving") { + replacementTask.hasPendingReceiveHandler() + } + replacementTask.emitInvokeRequest(id: "during-lifecycle", command: "system.which") + + try await waitUntil("lifecycle unavailable result") { + replacementTask.sentRequestCount(method: "node.invoke.result") == 1 + } + let result = try #require(replacementTask.sentRequests(method: "node.invoke.result").first) + let params = try #require(result["params"] as? [String: Any]) + let error = try #require(params["error"] as? [String: Any]) + #expect(params["id"] as? String == "during-lifecycle") + #expect(params["ok"] as? Bool == false) + #expect(error["code"] as? String == OpenClawNodeErrorCode.unavailable.rawValue) + #expect(error["message"] as? String == "UNAVAILABLE: node lifecycle transition in progress") + #expect(await invocations.values() == []) + + await invalidationGate.release() + await gateway.disconnect() + } + @Test func `disconnect cleanup finishes after an in flight connected callback`() async throws { let session = FakeGatewayWebSocketSession()