diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index 9112a0157d2c..8aeefc052979 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -807,7 +807,9 @@ extension GatewayNodeSession { // The underlying channel can auto-reconnect; resetting state here ensures we surface a fresh // onConnected callback once a new snapshot arrives after reconnect. self.resetConnectionState() - let lifecycleCallback = self.enqueueLifecycleCallback( + // Transport reconnect must not wait on owner callbacks that can suspend + // indefinitely. The lifecycle barrier still gates readiness and invokes. + _ = self.enqueueLifecycleCallback( immediate: { // Release held input before waiting for a connected callback that // may already be suspended in owner code. @@ -817,11 +819,8 @@ extension GatewayNodeSession { // This cleanup runs after all older lifecycle callbacks, so they // cannot resume later and restore a disconnected route. await onDisconnected?(reason) + await self.awaitActiveInvokes(activeInvokes) }) - if !self.isExecutingLifecycleCallback() { - await lifecycleCallback.task.value - } - await self.awaitActiveInvokes(activeInvokes) } private func markSnapshotReceived() { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index 2fe7a59c1931..0c2c6ec18db9 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -1087,10 +1087,64 @@ struct GatewayNodeSessionTests { await gateway._test_notifyConnectedIfNeeded( admissionGeneration: staleAdmissionGeneration) + try await waitUntil("disconnect callback completed") { + await lifecycle.values().contains("disconnected") + } #expect(await lifecycle.values() == ["connected", "disconnected"]) await gateway.disconnect() } + @Test + func `transport reconnect does not wait for blocked disconnect lifecycle`() async throws { + let session = FakeGatewayWebSocketSession() + let gateway = GatewayNodeSession() + let invalidationGate = AsyncGate() + let lifecycle = DisconnectProbe() + let options = GatewayConnectOptions( + role: "node", + scopes: [], + caps: ["computer"], + commands: ["computer.act"], + 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: { await lifecycle.record("connected") }, + onDisconnected: { _ in await lifecycle.record("disconnected") }, + onInvoke: { req in + BridgeInvokeResponse(id: req.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 + } + #expect(await lifecycle.values() == ["connected"]) + + await invalidationGate.release() + try await waitUntil("replacement lifecycle completed") { + await lifecycle.values() == ["connected", "disconnected", "connected"] + } + await gateway.disconnect() + } + @Test func `disconnect cleanup finishes after an in flight connected callback`() async throws { let session = FakeGatewayWebSocketSession()