Refresh SSH host status after a verified manual managed-server update (#26665)

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
Neil
2026-10-09 00:41:19 -07:00
committed by GitHub
co-authored by m4air
parent 418c601d20
commit bcb7305fea
5 changed files with 199 additions and 9 deletions
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ send: vi.fn(), notify: vi.fn(), getState: vi.fn() }))
vi.mock('./ssh-ipc-context', () => ({
connectionManager: { getState: mocks.getState },
currentRuntime: { notifySshStateChanged: mocks.notify },
getCurrentMainWindow: () => ({ isDestroyed: () => false, webContents: { send: mocks.send } }),
persistedStore: null,
portForwardManager: null
}))
vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getSshTargetRegistryStore: () => null
}))
const { clearManagedServerNotes, publishHostServerStatus } =
await import('./runtime-environment-managed-tunnel')
const { getPublicSshState, relayStateOverrides } = await import('./ssh-renderer-broadcast')
const { clearSshHostServerStatus, getSshHostServerStatus, setSshHostServerStatus } =
await import('../ssh/ssh-host-server-status')
const ready = { kind: 'managed', environmentId: 'env-1' } as const
const deferred = {
...ready,
update: { state: 'deferred', detail: 'A terminal is still running.' }
} as const
function connection(status: 'connected' | 'disconnected') {
return { targetId: 'ssh-1', status, error: null, reconnectAttempt: 0, managedServer: deferred }
}
describe('publishing a verified managed host status', () => {
beforeEach(() => {
vi.clearAllMocks()
relayStateOverrides.clear()
clearSshHostServerStatus('ssh-1')
clearSshHostServerStatus('ssh-2')
})
it('replaces the deferred diagnostic in the host store and both subscribers', () => {
setSshHostServerStatus('ssh-1', deferred)
mocks.getState.mockReturnValue(connection('connected'))
publishHostServerStatus('ssh-1', ready)
expect(getSshHostServerStatus('ssh-1')).toEqual(ready)
expect(mocks.notify).toHaveBeenCalledWith(
'ssh-1',
expect.objectContaining({ managedServer: ready })
)
expect(mocks.send).toHaveBeenCalledWith(
'ssh:state-changed',
expect.objectContaining({ state: expect.objectContaining({ managedServer: ready }) })
)
})
it('refreshes a retained override so later reads also forget the deferral', () => {
relayStateOverrides.set('ssh-1', connection('connected'))
publishHostServerStatus('ssh-1', ready)
expect(relayStateOverrides.get('ssh-1')?.managedServer).toEqual(ready)
expect(getPublicSshState('ssh-1')?.managedServer).toEqual(ready)
})
it('preserves disconnected contact instead of inventing a connection', () => {
mocks.getState.mockReturnValue(connection('disconnected'))
publishHostServerStatus('ssh-1', ready)
expect(mocks.notify).toHaveBeenCalledWith(
'ssh-1',
expect.objectContaining({ status: 'disconnected', managedServer: ready })
)
})
it('retains the current unverifiable host evidence when publishing it', () => {
mocks.getState.mockReturnValue(connection('connected'))
const unverifiable = {
...ready,
serving: { state: 'unverifiable', detail: 'The process is live but not answering.' }
} as const
publishHostServerStatus('ssh-1', unverifiable)
expect(getSshHostServerStatus('ssh-1')).toEqual(unverifiable)
expect(mocks.notify.mock.calls[0]?.[1].managedServer).toEqual(unverifiable)
})
it('records a host without a connection and leaves another host untouched', () => {
mocks.getState.mockReturnValue(undefined)
setSshHostServerStatus('ssh-2', deferred)
publishHostServerStatus('ssh-1', ready)
expect(getSshHostServerStatus('ssh-1')).toEqual(ready)
expect(getSshHostServerStatus('ssh-2')).toEqual(deferred)
expect(mocks.notify).not.toHaveBeenCalled()
expect(mocks.send).not.toHaveBeenCalled()
})
it("clears a verified host's deferral and unverifiable notes", () => {
mocks.getState.mockReturnValue(connection('connected'))
setSshHostServerStatus('ssh-1', {
...deferred,
serving: { state: 'unverifiable', detail: 'No reply.' }
})
clearManagedServerNotes('ssh-1', 'env-1')
expect(getSshHostServerStatus('ssh-1')).toEqual(ready)
expect(mocks.notify).toHaveBeenCalledWith(
'ssh-1',
expect.objectContaining({ managedServer: ready })
)
})
it('leaves a relay route, another server, or an unknown host as it was', () => {
mocks.getState.mockReturnValue(connection('connected'))
const relay = { kind: 'relay', reason: 'source_changed' } as const
setSshHostServerStatus('ssh-1', relay)
clearManagedServerNotes('ssh-1', 'env-1')
expect(getSshHostServerStatus('ssh-1')).toEqual(relay)
setSshHostServerStatus('ssh-1', { ...deferred, environmentId: 'env-2' })
clearManagedServerNotes('ssh-1', 'env-1')
expect(getSshHostServerStatus('ssh-1')).toMatchObject({ environmentId: 'env-2' })
clearManagedServerNotes('ssh-2', 'env-1')
expect(getSshHostServerStatus('ssh-2')).toBeUndefined()
expect(mocks.notify).not.toHaveBeenCalled()
})
})
+8
View File
@@ -15,6 +15,8 @@ export type ManagedOrcadActionOptions = {
getUserDataPath: () => string
getActiveEnvironmentId: () => string | null | undefined
invalidateTransport: (environmentId: string) => Promise<void> | void
/** Drops the SSH host's stale update/serving notes once an update verified its server. */
clearHostServerNotes: (sshTargetId: string, environmentId: string) => void
/** Drops the SSH host's stale managed-server state once its server is unlinked. */
clearHostServerStatus: (sshTargetId: string) => void
/** Drops the unlinked server's workspace session partition. */
@@ -33,6 +35,12 @@ export function createManagedOrcadActions(
if (result.outcome === 'updated') {
await options.invalidateTransport(result.environment.id)
}
if (result.outcome !== 'deferred') {
const targetId = result.environment.orcadDeployment?.sshTargetId
if (targetId) {
options.clearHostServerNotes(targetId, result.environment.id)
}
}
return result
},
rollback: async (selector) => {
@@ -27,6 +27,7 @@ const { registerOrcadRuntimeMaintenanceHandlers } =
const invalidateTransport = vi.fn()
const clearHostServerStatus = vi.fn()
const clearHostServerNotes = vi.fn()
const forgetHostSession = vi.fn()
function handler(channel: string): (_event: unknown, args: unknown) => Promise<unknown> {
@@ -45,6 +46,7 @@ describe('managed orcad maintenance IPC', () => {
getActiveEnvironmentId: () => 'active-environment',
invalidateTransport,
clearHostServerStatus,
clearHostServerNotes,
forgetHostSession
})
})
@@ -54,11 +56,54 @@ describe('managed orcad maintenance IPC', () => {
await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: 'yes' })
expect(mocks.update).toHaveBeenCalledWith('/profile', { selector: 'Managed', force: false })
expect(invalidateTransport).not.toHaveBeenCalled()
expect(clearHostServerNotes).not.toHaveBeenCalled()
mocks.update.mockResolvedValueOnce({ outcome: 'updated', environment: { id: 'e-1' } })
await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: true })
expect(invalidateTransport).toHaveBeenCalledWith('e-1')
})
it.each(['updated', 'already-current'])(
'clears the host notes after a verified %s update',
async (outcome) => {
mocks.update.mockResolvedValueOnce({
outcome,
environment: { id: 'e-1', orcadDeployment: { sshTargetId: 'ssh-1' } },
activeVersion: 'candidate'
})
await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: true })
expect(clearHostServerNotes).toHaveBeenCalledExactlyOnceWith('ssh-1', 'e-1')
expect(invalidateTransport).toHaveBeenCalledTimes(outcome === 'updated' ? 1 : 0)
}
)
it('clears the notes only after the old transport has been retired', async () => {
let finishRetirement: (() => void) | undefined
invalidateTransport.mockReturnValueOnce(
new Promise<void>((resolve) => {
finishRetirement = resolve
})
)
mocks.update.mockResolvedValueOnce({
outcome: 'updated',
environment: { id: 'e-1', orcadDeployment: { sshTargetId: 'ssh-1' } }
})
const updating = handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed' })
await vi.waitFor(() => expect(invalidateTransport).toHaveBeenCalledOnce())
expect(clearHostServerNotes).not.toHaveBeenCalled()
finishRetirement?.()
await updating
expect(clearHostServerNotes).toHaveBeenCalledOnce()
})
it('keeps the previous diagnostic when an update rejects', async () => {
mocks.update.mockRejectedValueOnce(new Error('SSH contact lost'))
await expect(
handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed' })
).rejects.toThrow('SSH contact lost')
expect(clearHostServerNotes).not.toHaveBeenCalled()
expect(invalidateTransport).not.toHaveBeenCalled()
})
it('reconnects after rollback and after recovery restores a serving slot', async () => {
mocks.rollback.mockResolvedValueOnce({ outcome: 'rolled-back', environment: { id: 'e-1' } })
await handler('runtimeEnvironments:rollbackOrcad')(null, { selector: 'Managed' })
@@ -9,10 +9,10 @@ import { ensureOrcadManagedTunnel } from '../ssh/orcad-managed-tunnel'
import { verifyOrcadManagedServing } from '../ssh/orcad-managed-serving-verify'
import { setManagedOrcadStartListener } from '../ssh/orcad-managed-serving'
import { updateManagedOrcadOnRestore } from '../ssh/orcad-managed-update-on-restore'
import { setSshHostServerStatus } from '../ssh/ssh-host-server-status'
import { getSshHostServerStatus, setSshHostServerStatus } from '../ssh/ssh-host-server-status'
import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry'
import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context'
import { broadcastSshState } from './ssh-renderer-broadcast'
import { broadcastSshState, relayStateOverrides } from './ssh-renderer-broadcast'
export async function resolveManagedRuntimeEnvironment(
userDataPath: string,
@@ -41,7 +41,7 @@ function publishRestoreUpdate(
note?: SshManagedServerUpdateNote
): void {
publishHostServerStatus(
target,
target.id,
phase === 'updating'
? { kind: 'setting-up', phase: 'updating' }
: { kind: 'managed', environmentId, ...(note ? { update: note } : {}) }
@@ -52,9 +52,9 @@ function publishRestoreUpdate(
export function installManagedOrcadStartStatus(): void {
setManagedOrcadStartListener({
starting: (target) =>
publishHostServerStatus(target, { kind: 'setting-up', phase: 'starting' }),
publishHostServerStatus(target.id, { kind: 'setting-up', phase: 'starting' }),
settled: (target, environmentId, serving) =>
publishHostServerStatus(target, {
publishHostServerStatus(target.id, {
kind: 'managed',
environmentId,
...(serving.state === 'unverifiable' ? { serving } : {})
@@ -78,11 +78,26 @@ export function publishResolvedChangedHostStatus(target: SshTarget, environmentI
)
}
function publishHostServerStatus(target: SshTarget, status: SshManagedServerStatus): void {
setSshHostServerStatus(target.id, status)
export function publishHostServerStatus(targetId: string, status: SshManagedServerStatus): void {
setSshHostServerStatus(targetId, status)
const override = relayStateOverrides.get(targetId)
if (override) {
relayStateOverrides.set(targetId, { ...override, managedServer: status })
}
// Only a host with a connection state has a status line to refresh.
const state = connectionManager?.getState(target.id)
const state = relayStateOverrides.get(targetId) ?? connectionManager?.getState(targetId)
if (state) {
broadcastSshState(getCurrentMainWindow, target.id, state)
broadcastSshState(getCurrentMainWindow, targetId, { ...state, managedServer: status })
}
}
/** A verified update supersedes the host's update and serving notes; any other host route is kept. */
export function clearManagedServerNotes(targetId: string, environmentId: string): void {
const current = getSshHostServerStatus(targetId)
if (current?.kind !== 'managed' || current.environmentId !== environmentId) {
return
}
if (current.update || current.serving) {
publishHostServerStatus(targetId, { kind: 'managed', environmentId })
}
}
+2
View File
@@ -24,6 +24,7 @@ import { registerOrcadRuntimeConversionHandlers } from './orcad-runtime-conversi
import { registerOrcadDeltaMoveHandlers } from './orcad-delta-move-handlers'
import { registerOrcadRuntimeMaintenanceHandlers } from './orcad-runtime-maintenance-handlers'
import { clearPublishedManagedServer } from './ssh-renderer-broadcast'
import { clearManagedServerNotes } from './runtime-environment-managed-tunnel'
import { reconcileOrphanedRuntimeSessions } from './runtime-environment-session-reconcile'
import { registerRuntimeSshAccessHandlers } from './runtime-ssh-access-handlers'
import { retirePairedRuntimeBrowserClientHostEnvironment } from '../browser/paired-runtime-browser-client-host-runtime'
@@ -129,6 +130,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
getActiveEnvironmentId: () => store.getSettings().activeRuntimeEnvironmentId,
invalidateTransport: invalidateRuntimeEnvironmentTransport,
clearHostServerStatus: clearPublishedManagedServer,
clearHostServerNotes: clearManagedServerNotes,
forgetHostSession: (hostId) => store.removeWorkspaceSessionHost(hostId)
})
registerRuntimeEnvironmentSubscriptionHandlers({