Skip to content

Commit 75e220a

Browse files
committed
fix(ios): force stale foreground gateway reconnects
1 parent 8d9ce35 commit 75e220a

2 files changed

Lines changed: 62 additions & 20 deletions

File tree

apps/ios/Sources/Model/NodeAppModel.swift

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ final class NodeAppModel {
188188
@ObservationIgnored private var backgroundGraceTaskTimer: Task<Void, Never>?
189189
private var backgroundReconnectSuppressed = false
190190
private var backgroundReconnectLeaseUntil: Date?
191+
@ObservationIgnored private var foregroundGatewayResumeCheckInFlight = false
191192
private var lastSignificantLocationWakeAt: Date?
192193
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
193194
private var watchExecApprovalPromptsByID: [String: ExecApprovalPrompt] = [:]
@@ -214,6 +215,7 @@ final class NodeAppModel {
214215
private static let watchExecApprovalBridgeStateKey = "watch.execApproval.bridge.state.v1"
215216
private static let backgroundAliveLastSuccessAtMsKey = "gateway.backgroundAlive.lastSuccessAtMs"
216217
private static let backgroundAliveLastTriggerKey = "gateway.backgroundAlive.lastTrigger"
218+
private static let foregroundResumeHealthTimeoutSeconds = 1
217219

218220
var cameraHUDText: String?
219221
var cameraHUDKind: CameraHUDKind?
@@ -417,9 +419,7 @@ final class NodeAppModel {
417419
self.isBackgrounded = false
418420
self.endBackgroundConnectionGracePeriod(reason: "scene_foreground")
419421
self.clearBackgroundReconnectSuppression(reason: "scene_foreground")
420-
if self.operatorConnected {
421-
self.startGatewayHealthMonitor()
422-
}
422+
var shouldStartGatewayHealthMonitor = self.operatorConnected
423423
if phase == .active {
424424
self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: self.backgroundVoiceWakeSuspended)
425425
self.backgroundVoiceWakeSuspended = false
@@ -444,6 +444,8 @@ final class NodeAppModel {
444444
// iOS may suspend network sockets in background without a clean close.
445445
// On foreground, force a fresh handshake to avoid "connected but dead" states.
446446
if backgroundedFor >= 3.0 {
447+
shouldStartGatewayHealthMonitor = false
448+
self.foregroundGatewayResumeCheckInFlight = true
447449
Task { [weak self] in
448450
guard let self else { return }
449451
let operatorWasConnected = await MainActor.run { self.operatorConnected }
@@ -452,31 +454,26 @@ final class NodeAppModel {
452454
let healthy = await (try? self.operatorGateway.request(
453455
method: "health",
454456
paramsJSON: nil,
455-
timeoutSeconds: 2)) != nil
457+
timeoutSeconds: Self.foregroundResumeHealthTimeoutSeconds)) != nil
456458
if healthy {
457-
await MainActor.run { self.startGatewayHealthMonitor() }
459+
await MainActor.run {
460+
self.foregroundGatewayResumeCheckInFlight = false
461+
self.startGatewayHealthMonitor()
462+
}
458463
return
459464
}
460465
}
461466

462-
await self.operatorGateway.disconnect()
463-
await self.nodeGateway.disconnect()
464467
await MainActor.run {
465-
guard !self.isAppleReviewDemoModeEnabled else { return }
466-
self.setOperatorConnected(false)
467-
self.gatewayConnected = false
468-
// Foreground recovery must actively restart the saved gateway config.
469-
// Disconnecting stale sockets alone can leave us idle if the old
470-
// reconnect tasks were suppressed or otherwise got stuck in background.
471-
self.gatewayStatusText = "Reconnecting…"
472-
self.talkMode.updateGatewayConnected(false)
473-
if let cfg = self.activeGatewayConnectConfig {
474-
self.applyGatewayConnectConfig(cfg)
475-
}
468+
self.foregroundGatewayResumeCheckInFlight = false
476469
}
470+
await self.restartGatewaySessionsAfterForegroundStaleConnection()
477471
}
478472
}
479473
}
474+
if shouldStartGatewayHealthMonitor {
475+
self.startGatewayHealthMonitor()
476+
}
480477
@unknown default:
481478
self.isBackgrounded = false
482479
self.endBackgroundConnectionGracePeriod(reason: "scene_unknown")
@@ -786,6 +783,12 @@ final class NodeAppModel {
786783

787784
func refreshGatewayOverviewIfConnected() async {
788785
guard await self.isOperatorConnected() else { return }
786+
if self.foregroundGatewayResumeCheckInFlight {
787+
GatewayDiagnostics.log("gateway overview refresh deferred reason=foreground_resume_check")
788+
try? await Task.sleep(
789+
nanoseconds: UInt64(Self.foregroundResumeHealthTimeoutSeconds) * 1_000_000_000)
790+
guard await self.isOperatorConnected(), !self.foregroundGatewayResumeCheckInFlight else { return }
791+
}
789792
await self.refreshBrandingFromGateway()
790793
await self.refreshAgentsFromGateway()
791794
}
@@ -1986,12 +1989,33 @@ extension NodeAppModel {
19861989
}
19871990

19881991
func resetGatewaySessionsForForcedReconnect() async {
1989-
self.nodeGatewayTask?.cancel()
1992+
let nodeGatewayTask = self.nodeGatewayTask
1993+
let operatorGatewayTask = self.operatorGatewayTask
1994+
nodeGatewayTask?.cancel()
19901995
self.nodeGatewayTask = nil
1991-
self.operatorGatewayTask?.cancel()
1996+
operatorGatewayTask?.cancel()
19921997
self.operatorGatewayTask = nil
19931998
await self.operatorGateway.disconnect()
19941999
await self.nodeGateway.disconnect()
2000+
// Foreground recovery reuses the same config immediately after reset.
2001+
// Wait for canceled loops so their shutdown cleanup cannot clobber the new reconnect state.
2002+
if let operatorGatewayTask {
2003+
await operatorGatewayTask.value
2004+
}
2005+
if let nodeGatewayTask {
2006+
await nodeGatewayTask.value
2007+
}
2008+
}
2009+
2010+
private func restartGatewaySessionsAfterForegroundStaleConnection() async {
2011+
await self.resetGatewaySessionsForForcedReconnect()
2012+
guard !self.isAppleReviewDemoModeEnabled else { return }
2013+
self.setOperatorConnected(false)
2014+
self.gatewayConnected = false
2015+
self.gatewayStatusText = "Reconnecting…"
2016+
self.talkMode.updateGatewayConnected(false)
2017+
guard let cfg = self.activeGatewayConnectConfig else { return }
2018+
self.applyGatewayConnectConfig(cfg, forceReconnect: true)
19952019
}
19962020

19972021
func disconnectGateway() {
@@ -4826,6 +4850,10 @@ extension NodeAppModel {
48264850
(self.nodeGatewayTask != nil, self.operatorGatewayTask != nil)
48274851
}
48284852

4853+
func _test_restartGatewaySessionsAfterForegroundStaleConnection() async {
4854+
await self.restartGatewaySessionsAfterForegroundStaleConnection()
4855+
}
4856+
48294857
func _test_handleSuccessfulBootstrapGatewayOnboarding() async {
48304858
await self.handleSuccessfulBootstrapGatewayOnboarding(
48314859
url: URL(string: "wss://gateway.example")!,

apps/ios/Tests/GatewayConnectionControllerTests.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,20 @@ import UIKit
356356
#expect(!appModel._test_hasGatewayLoopTasks().operator)
357357
}
358358

359+
@Test @MainActor func foregroundStaleConnectionRestartReappliesActiveGatewayConfig() async {
360+
let appModel = NodeAppModel()
361+
defer { appModel.disconnectGateway() }
362+
363+
let config = Self.makeGatewayConnectConfig()
364+
appModel.applyGatewayConnectConfig(config)
365+
await appModel._test_restartGatewaySessionsAfterForegroundStaleConnection()
366+
367+
#expect(appModel.gatewayStatusText == "Reconnecting…")
368+
#expect(appModel.activeGatewayConnectConfig?.hasSameConnectionInputs(as: config) == true)
369+
#expect(appModel._test_hasGatewayLoopTasks().node)
370+
#expect(appModel._test_hasGatewayLoopTasks().operator)
371+
}
372+
359373
@Test @MainActor func loadLastConnectionReadsSavedValues() {
360374
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
361375
defer {

0 commit comments

Comments
 (0)