From ed147d5d6818beaf4aa25b76f7050e478deb7183 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:18:09 -0700 Subject: [PATCH] fix(ssh): re-check a macOS host's ended launch-session warning so another desktop's restart can clear it (#29915) * fix(ssh): re-check a macOS host's ended launch-session warning so another desktop's restart can clear it The ended verdict was cached for the whole SSH session, so when another desktop restarted the server under a live session, this desktop kept the warning. It now expires like every other verdict: a live re-probe clears the note, and an unanswered one keeps the proven verdict. Co-Authored-By: Claude * fix(ssh): only a live probe clears a proven launch-session end, and a failed recheck republishes it Co-Authored-By: Claude * fix(ssh): a failed launch-session recheck restores the warning only for the same live connection Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../ipc/ssh-host-launch-session-note.test.ts | 57 ++++++++++++++++++- src/main/ipc/ssh-host-launch-session-note.ts | 49 +++++++++++----- 2 files changed, 91 insertions(+), 15 deletions(-) diff --git a/src/main/ipc/ssh-host-launch-session-note.test.ts b/src/main/ipc/ssh-host-launch-session-note.test.ts index a7e884a31e0..2b349a62892 100644 --- a/src/main/ipc/ssh-host-launch-session-note.test.ts +++ b/src/main/ipc/ssh-host-launch-session-note.test.ts @@ -8,7 +8,7 @@ const mocks = vi.hoisted(() => { current: { getConnectGeneration: () => 1 } } const environments: { current: string[] } = { current: ['env-1'] } - return { read: vi.fn(), publish: vi.fn(), os, connection, status, environments } + return { read: vi.fn(), publish: vi.fn(), clear: vi.fn(), os, connection, status, environments } }) vi.mock('../../shared/app-environment', () => ({ @@ -29,7 +29,10 @@ vi.mock('../ssh/ssh-host-server-status', () => ({ vi.mock('./ssh-ipc-context', () => ({ connectionManager: { getConnection: () => mocks.connection.current } })) -vi.mock('./runtime-environment-managed-tunnel', () => ({ publishHostServerStatus: mocks.publish })) +vi.mock('./runtime-environment-managed-tunnel', () => ({ + publishHostServerStatus: mocks.publish, + clearLaunchSessionNote: mocks.clear +})) const { forgetLaunchSessionVerdict, noteEndedLaunchSshSession } = await import('./ssh-host-launch-session-note') @@ -129,6 +132,56 @@ describe('noteEndedLaunchSshSession', () => { expect(mocks.read).toHaveBeenCalledTimes(2) }) + it('clears the warning once a later probe finds the launching session live', async () => { + let clock = 0 + await noteEndedLaunchSshSession('ssh-1', 'env-1', () => clock) + mocks.status.current = { kind: 'managed', environmentId: 'env-1', launchSessionEnded: true } + // Another desktop restarted the server under a session that is still open. + mocks.read.mockResolvedValue('live') + clock = 11 * 60_000 + await noteEndedLaunchSshSession('ssh-1', 'env-1', () => clock) + expect(mocks.read).toHaveBeenCalledTimes(2) + expect(mocks.clear).toHaveBeenCalledWith('ssh-1', 'env-1') + }) + + it.each([ + ['cannot ask', () => mocks.read.mockResolvedValue('unverifiable')], + ['finds no record', () => mocks.read.mockResolvedValue('not-recorded')], + ['fails', () => mocks.read.mockRejectedValue(new Error('ssh down'))] + ])('keeps a proven ended verdict when the later probe %s', async (_name, unanswered) => { + let clock = 0 + await noteEndedLaunchSshSession('ssh-1', 'env-1', () => clock) + unanswered() + clock = 11 * 60_000 + // A connect replaced the status before the recheck, which republishes the warning. + mocks.status.current = { kind: 'managed', environmentId: 'env-1' } + await noteEndedLaunchSshSession('ssh-1', 'env-1', () => clock) + expect(mocks.clear).not.toHaveBeenCalled() + expect(mocks.publish).toHaveBeenCalledTimes(2) + // A status a later connect replaced still gets the warning back. + mocks.status.current = { kind: 'managed', environmentId: 'env-1' } + await noteEndedLaunchSshSession('ssh-1', 'env-1', () => clock) + expect(mocks.read).toHaveBeenCalledTimes(2) + expect(mocks.publish).toHaveBeenLastCalledWith('ssh-1', { + kind: 'managed', + environmentId: 'env-1', + launchSessionEnded: true + }) + }) + + it('never restores the warning for a host disconnected while its recheck failed', async () => { + let clock = 0 + await noteEndedLaunchSshSession('ssh-1', 'env-1', () => clock) + mocks.status.current = undefined + mocks.read.mockImplementationOnce(async () => { + mocks.connection.current = { getConnectGeneration: () => 1 } + throw new Error('ssh closed') + }) + clock = 11 * 60_000 + await noteEndedLaunchSshSession('ssh-1', 'env-1', () => clock) + expect(mocks.publish).toHaveBeenCalledOnce() + }) + it('drops an answer whose server was unlinked or whose connection changed meanwhile', async () => { mocks.read.mockImplementationOnce(async () => { mocks.environments.current = [] diff --git a/src/main/ipc/ssh-host-launch-session-note.ts b/src/main/ipc/ssh-host-launch-session-note.ts index a7c492c6eb2..853ab5e6bae 100644 --- a/src/main/ipc/ssh-host-launch-session-note.ts +++ b/src/main/ipc/ssh-host-launch-session-note.ts @@ -5,12 +5,15 @@ import { readManagedOrcadLaunchSshSession } from '../ssh/orcad-managed-launch-se import { knownSshHostPlatform } from '../ssh/ssh-host-platform-memo' import { getSshHostServerStatus } from '../ssh/ssh-host-server-status' import { connectionManager } from './ssh-ipc-context' -import { publishHostServerStatus } from './runtime-environment-managed-tunnel' +import { + clearLaunchSessionNote, + publishHostServerStatus +} from './runtime-environment-managed-tunnel' // Once per SSH session: a restore runs on every runtime call, and the answer only changes when // a session ends, which the next session (a new connection or reconnect generation) checks. -// The ended verdict is kept so a status a later connect replaces gets the warning back. Any -// other verdict expires: another desktop's launching session can end while this one stays. +// The ended verdict is reapplied so a status a later connect replaces gets the warning back. +// Every verdict expires: another desktop can end its launching session, or restart the server. const checked = new WeakMap() const RECHECK_MS = 10 * 60_000 @@ -30,7 +33,8 @@ export async function noteEndedLaunchSshSession( } const generation = connection.getConnectGeneration() const prior = checked.get(connection) - if (prior?.generation === generation && (prior.ended || now() - prior.at < RECHECK_MS)) { + const sameSession = prior?.generation === generation + if (sameSession && now() - prior.at < RECHECK_MS) { if (prior.ended) { flag(targetId, environmentId) } @@ -38,17 +42,23 @@ export async function noteEndedLaunchSshSession( } const pending = { generation, ended: false, at: now() } checked.set(connection, pending) + // Only a live probe disproves a proven end; unanswered or unknown keeps it. + const provenEnded = sameSession && prior.ended + const markEnded = (): void => { + checked.set(connection, { generation, ended: true, at: now() }) + flag(targetId, environmentId) + } + // Why: a disconnect, reconnect, unlink or relaunch during the probe makes its answer stale. + const stale = (): boolean => + connectionManager?.getConnection(targetId) !== connection || + connection.getConnectGeneration() !== generation || + checked.get(connection) !== pending try { const session = await readManagedOrcadLaunchSshSession( getAppEnvironment().getPath('userData'), environmentId ) - // Why: a disconnect, reconnect, unlink or relaunch during the probe makes its answer stale. - if ( - connectionManager?.getConnection(targetId) !== connection || - connection.getConnectGeneration() !== generation || - checked.get(connection) !== pending - ) { + if (stale()) { return } const status = getSshHostServerStatus(targetId) @@ -57,15 +67,28 @@ export async function noteEndedLaunchSshSession( checked.delete(connection) return } - if (session === 'exited') { - checked.set(connection, { generation, ended: true, at: now() }) - flag(targetId, environmentId) + if (session === 'exited' || (session !== 'live' && provenEnded)) { + markEnded() + } else if (session === 'live' && launchSessionNoted(targetId, environmentId)) { + clearLaunchSessionNote(targetId, environmentId) } } catch (error) { + if (provenEnded && !stale()) { + markEnded() + } console.warn('[ssh] Could not check which SSH session started the managed server:', error) } } +function launchSessionNoted(targetId: string, environmentId: string): boolean { + const status = getSshHostServerStatus(targetId) + return ( + status?.kind === 'managed' && + status.environmentId === environmentId && + status.launchSessionEnded === true + ) +} + /** A restored host may have no status yet; any other server decision is left alone. */ function flag(targetId: string, environmentId: string): void { const userDataPath = getAppEnvironment().getPath('userData')