mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
fix(ssh): probe relay liveness on system resume instead of unconditional reconnect
powerMonitor 'resume' previously called connectionManager.reconnect() for every active target, guaranteeing a teardown + reconnect overlay on every wake even when the connection survived sleep. Now each session's relay link is probed (keepalive round-trip, 5s timeout, one retry for slow post-wake network); only targets whose probe fails are reconnected. Dead-after-sleep connections still reconnect promptly. The 'suspend' grace-time handling is unchanged. Refs #7773 Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -50,7 +50,8 @@ const {
|
||||
onRequest: vi.fn().mockReturnValue(() => {}),
|
||||
onDispose: vi.fn().mockReturnValue(() => {}),
|
||||
request: vi.fn().mockResolvedValue({}),
|
||||
notify: vi.fn()
|
||||
notify: vi.fn(),
|
||||
probeLiveness: vi.fn().mockResolvedValue(false)
|
||||
},
|
||||
mockPtyProvider: {
|
||||
onData: vi.fn(),
|
||||
@@ -316,6 +317,7 @@ describe('SSH IPC handlers', () => {
|
||||
mockMux.isDisposed.mockReset().mockReturnValue(false)
|
||||
mockMux.onNotification.mockReset()
|
||||
mockMux.onDispose.mockReset().mockReturnValue(() => {})
|
||||
mockMux.probeLiveness.mockReset().mockResolvedValue(false)
|
||||
mockPtyProvider.onData.mockReset()
|
||||
mockPtyProvider.onExit.mockReset()
|
||||
mockPtyProvider.onReplay.mockReset()
|
||||
@@ -1401,7 +1403,7 @@ describe('SSH IPC handlers', () => {
|
||||
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
|
||||
})
|
||||
|
||||
it('forces active SSH sessions to reconnect when the system resumes from sleep', async () => {
|
||||
it('reconnects on system resume when the relay liveness probe fails', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
@@ -1419,6 +1421,7 @@ describe('SSH IPC handlers', () => {
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
mockMux.probeLiveness.mockResolvedValue(false)
|
||||
|
||||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||||
|
||||
@@ -1427,7 +1430,42 @@ describe('SSH IPC handlers', () => {
|
||||
|
||||
resumeListener()
|
||||
|
||||
expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1')
|
||||
await vi.waitFor(() => expect(mockConnectionManager.reconnect).toHaveBeenCalledWith('ssh-1'))
|
||||
// Why: a failed first probe gets one retry before teardown (slow post-wake network).
|
||||
expect(mockMux.probeLiveness).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('skips reconnect on system resume when the relay link is still alive', async () => {
|
||||
const target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy'
|
||||
}
|
||||
const conn = {}
|
||||
mockSshStore.getTarget.mockReturnValue(target)
|
||||
mockConnectionManager.connect.mockResolvedValue(conn)
|
||||
mockConnectionManager.getConnection.mockReturnValue(conn)
|
||||
mockConnectionManager.getState.mockReturnValue({
|
||||
targetId: 'ssh-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
mockMux.probeLiveness.mockResolvedValue(true)
|
||||
|
||||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||||
|
||||
const resumeListener = powerMonitorOnMock.mock.calls.find(([event]) => event === 'resume')?.[1]
|
||||
expect(resumeListener).toBeTypeOf('function')
|
||||
|
||||
resumeListener()
|
||||
|
||||
await vi.waitFor(() => expect(mockMux.probeLiveness).toHaveBeenCalledTimes(1))
|
||||
// Let the async resume handler settle before asserting no teardown happened.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(mockConnectionManager.reconnect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('extends active relay grace while the system is suspending', async () => {
|
||||
|
||||
+36
-9
@@ -442,6 +442,24 @@ function registerAdvertisedUrlRefresh(getMainWindow: () => BrowserWindow | null)
|
||||
})
|
||||
}
|
||||
|
||||
// Why: macOS can resume the process before the network stack is back up, so
|
||||
// a failed first probe gets one retry before the link is declared dead (#7773).
|
||||
const RESUME_PROBE_TIMEOUT_MS = 5_000
|
||||
const RESUME_PROBE_ATTEMPTS = 2
|
||||
|
||||
async function isRelayLinkAliveAfterResume(session: SshRelaySession): Promise<boolean> {
|
||||
const mux = session.getMux()
|
||||
if (!mux || mux.isDisposed()) {
|
||||
return false
|
||||
}
|
||||
for (let attempt = 0; attempt < RESUME_PROBE_ATTEMPTS; attempt++) {
|
||||
if (await mux.probeLiveness(RESUME_PROBE_TIMEOUT_MS)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function registerPowerMonitorReconnect(): void {
|
||||
powerMonitorUnsubscribe?.()
|
||||
const onSuspend = (): void => {
|
||||
@@ -450,19 +468,28 @@ function registerPowerMonitorReconnect(): void {
|
||||
}
|
||||
}
|
||||
const onResume = (): void => {
|
||||
for (const targetId of activeSessions.keys()) {
|
||||
for (const [targetId, session] of activeSessions) {
|
||||
const conn = connectionManager?.getConnection(targetId)
|
||||
if (!conn) {
|
||||
continue
|
||||
}
|
||||
const reconnect = connectionManager?.reconnect(targetId)
|
||||
void reconnect?.catch((err) => {
|
||||
console.warn(
|
||||
`[ssh] Failed to reconnect ${targetId} after system resume: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
)
|
||||
})
|
||||
void (async () => {
|
||||
// Why: unconditional reconnect on every wake tore down live sessions
|
||||
// and flashed the reconnect overlay (#7773). Only reconnect targets
|
||||
// whose relay link actually died during sleep.
|
||||
if (await isRelayLinkAliveAfterResume(session)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await connectionManager?.reconnect(targetId)
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[ssh] Failed to reconnect ${targetId} after system resume: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
)
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
powerMonitor.on('suspend', onSuspend)
|
||||
|
||||
Reference in New Issue
Block a user