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 <noreply@anthropic.com>

* fix(ssh): only a live probe clears a proven launch-session end, and a failed recheck republishes it

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): a failed launch-session recheck restores the warning only for the same live connection

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-16 19:18:09 -07:00
committed by GitHub
co-authored by Claude
parent 710120991f
commit ed147d5d68
2 changed files with 91 additions and 15 deletions
@@ -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 = []
+36 -13
View File
@@ -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<object, { generation: number; ended: boolean; at: number }>()
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')