mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
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:
@@ -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 = []
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user