fix(macos): unblock node transport reconnects (#105091)

* fix(macos): unblock node transport reconnects

* chore(macos): leave release notes to release flow
This commit is contained in:
Peter Steinberger
2026-07-12 08:17:59 +01:00
committed by GitHub
parent 5839c2e04b
commit ba9a04580b
2 changed files with 58 additions and 5 deletions

View File

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

View File

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