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:
Jinwoo-H
2026-07-08 15:12:11 -04:00
co-authored by Orca
parent f3c4d44be1
commit 676566d4dc
2 changed files with 77 additions and 12 deletions
+41 -3
View File
@@ -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
View File
@@ -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)