From bcb7305feacdd846ca5d930c37e9a7ab290bbc6a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 9 Oct 2026 00:41:19 -0700 Subject: [PATCH] Refresh SSH host status after a verified manual managed-server update (#26665) Co-authored-by: m4air --- .../managed-host-status-publication.test.ts | 120 ++++++++++++++++++ src/main/ipc/managed-orcad-actions.ts | 8 ++ ...orcad-runtime-maintenance-handlers.test.ts | 45 +++++++ .../ipc/runtime-environment-managed-tunnel.ts | 33 +++-- src/main/ipc/runtime-environments.ts | 2 + 5 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 src/main/ipc/managed-host-status-publication.test.ts diff --git a/src/main/ipc/managed-host-status-publication.test.ts b/src/main/ipc/managed-host-status-publication.test.ts new file mode 100644 index 00000000000..0f529e539e7 --- /dev/null +++ b/src/main/ipc/managed-host-status-publication.test.ts @@ -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>()), + 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() + }) +}) diff --git a/src/main/ipc/managed-orcad-actions.ts b/src/main/ipc/managed-orcad-actions.ts index 7c610b23d5a..20c94e7ef97 100644 --- a/src/main/ipc/managed-orcad-actions.ts +++ b/src/main/ipc/managed-orcad-actions.ts @@ -15,6 +15,8 @@ export type ManagedOrcadActionOptions = { getUserDataPath: () => string getActiveEnvironmentId: () => string | null | undefined invalidateTransport: (environmentId: string) => Promise | 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) => { diff --git a/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts b/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts index 340517c0ded..223be800070 100644 --- a/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts +++ b/src/main/ipc/orcad-runtime-maintenance-handlers.test.ts @@ -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 { @@ -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((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' }) diff --git a/src/main/ipc/runtime-environment-managed-tunnel.ts b/src/main/ipc/runtime-environment-managed-tunnel.ts index 2654314d36d..0eb872b4cdf 100644 --- a/src/main/ipc/runtime-environment-managed-tunnel.ts +++ b/src/main/ipc/runtime-environment-managed-tunnel.ts @@ -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 }) } } diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 9e3506eb5fb..376dd51db78 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -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({