fix(ssh): a Disconnect stays disconnected, and Settings and the status bar agree (#30130)

A user's Disconnect of an SSH host is now recorded for the session, and background
dials (a managed server's tunnel and status checks, census, host-resume recovery,
pane and automation restores) refuse it until an explicit Connect. Disconnecting a
managed host from Settings also stops its server's status checks, and the status
bar's Disconnect of a managed server also disconnects the SSH host it runs on, so
both surfaces read the same verdict.

Fixes #30086
Fixes #30085
This commit is contained in:
Neil
2026-10-17 16:25:42 -07:00
committed by GitHub
parent 965f88105e
commit 27cfee6f7e
11 changed files with 540 additions and 14 deletions
@@ -30,7 +30,6 @@ import {
import {
clearRuntimeEnvironmentManualDisconnect,
isRuntimeEnvironmentManuallyDisconnected,
markRuntimeEnvironmentManuallyDisconnected,
RUNTIME_MANUALLY_DISCONNECTED_MESSAGE
} from './runtime-environment-manual-disconnect'
import {
@@ -40,6 +39,11 @@ import {
import { publicRuntimeEnvironmentWithHostKey } from './runtime-environment-host-key'
import { withWslOrcadAccountEnv } from '../wsl/wsl-orcad-account-env'
import { clearSshCredentialDeclinedForEnvironment } from '../ssh/ssh-credential-decline'
import {
disconnectManagedHostOfEnvironmentForUser,
disconnectRuntimeEnvironmentForUser,
liftSshUserDisconnectForEnvironment
} from './ssh-host-user-disconnect'
import {
observeWslOrcadHostResponse,
wslOrcadHostSupports
@@ -141,13 +145,16 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
)
ipcMain.handle(
'runtimeEnvironments:disconnect',
(_event, args: { selector: string }): { disconnected: PublicKnownRuntimeEnvironment } => {
async (
_event,
args: { selector: string }
): Promise<{ disconnected: PublicKnownRuntimeEnvironment }> => {
const environment = resolveEnvironment(getUserDataPath(), args.selector)
markRuntimeEnvironmentManuallyDisconnected(environment.id)
invalidateTransport(environment.id)
const runtimeLayer = { userDataPath: getUserDataPath(), invalidateTransport }
disconnectRuntimeEnvironmentForUser(environment.id, runtimeLayer)
closeLegacySelectorTransport(args.selector, environment.id)
// Retain disconnected evidence for renderers that missed the teardown event.
getRuntimeEnvironmentStatusOwner(getUserDataPath(), environment.id)
// Why: otherwise Settings keeps showing the managed host's SSH connection as live (#30085).
await disconnectManagedHostOfEnvironmentForUser(environment, runtimeLayer)
return { disconnected: redactRuntimeEnvironment(environment) }
}
)
@@ -161,6 +168,7 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
assertWslHostReachable(environment)
clearRuntimeEnvironmentManualDisconnect(environment.id)
clearSshCredentialDeclinedForEnvironment(environment)
await liftSshUserDisconnectForEnvironment(environment)
await connectThroughOwningService(environment)
return getRuntimeEnvironmentStatus(getUserDataPath(), environment.id, args.timeoutMs, {
reconnect: true
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const hoisted = vi.hoisted(() => {
const environment: Record<string, unknown> = {}
return {
handlers: new Map<string, (event: unknown, args: unknown) => unknown>(),
environment,
teardown: vi.fn(),
invalidate: vi.fn(),
connectHost: vi.fn()
}
})
vi.mock('electron', () => ({
ipcMain: {
handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) =>
hoisted.handlers.set(channel, handler)
}
}))
vi.mock('../../shared/runtime-environment-store', async (importOriginal) => ({
...(await importOriginal<object>()),
resolveEnvironment: () => hoisted.environment,
listEnvironments: () => [hoisted.environment]
}))
vi.mock('./runtime-environment-request-connections', async (importOriginal) => ({
...(await importOriginal<object>()),
getRuntimeEnvironmentStatusOwner: () => ({ activate: vi.fn() }),
closeRemoteRuntimeRequestConnection: vi.fn()
}))
vi.mock('./runtime-environment-host-service-ops', () => ({
connectThroughOwningService: vi.fn(async () => {}),
removeThroughOwningService: vi.fn()
}))
vi.mock('./runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: vi.fn(),
getRuntimeEnvironmentStatus: vi.fn(async () => ({ ok: true }))
}))
vi.mock('./ssh-session-teardown', () => ({ disconnectRegisteredSshTarget: hoisted.teardown }))
vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({
...(await importOriginal<object>()),
connectRegisteredSshTarget: hoisted.connectHost
}))
import { registerRuntimeEnvironmentConnectivityHandlers } from './runtime-environment-connectivity-handlers'
import {
clearRuntimeEnvironmentManualDisconnect,
isRuntimeEnvironmentManuallyDisconnected
} from './runtime-environment-manual-disconnect'
import { isSshUserDisconnected, resetSshUserDisconnectsForTests } from '../ssh/ssh-user-disconnect'
const deployment = {
kind: 'ssh',
requestId: 'request-1',
sshTargetId: 'ssh-1',
sshTargetGeneration: 1,
localPort: 41000,
remotePort: 6768
}
describe("the status bar's Disconnect of a server", () => {
beforeEach(() => {
hoisted.handlers.clear()
hoisted.teardown.mockReset().mockResolvedValue(undefined)
hoisted.invalidate.mockReset()
hoisted.connectHost.mockReset().mockResolvedValue(undefined)
resetSshUserDisconnectsForTests()
registerRuntimeEnvironmentConnectivityHandlers({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers read only store.getSettings(), which this stub provides.
store: { getSettings: () => ({}) } as never,
getUserDataPath: () => '/tmp/orca-user-data',
invalidateTransport: hoisted.invalidate
})
})
afterEach(() => {
clearRuntimeEnvironmentManualDisconnect('env-1')
})
function invoke(channel: string): Promise<unknown> {
return Promise.resolve(hoisted.handlers.get(channel)!(null, { selector: 'env-1' }))
}
it('also disconnects the SSH host a managed server runs on, so Settings agrees', async () => {
hoisted.environment = { id: 'env-1', endpoints: [], orcadDeployment: deployment }
await invoke('runtimeEnvironments:disconnect')
expect(isRuntimeEnvironmentManuallyDisconnected('env-1')).toBe(true)
expect(hoisted.teardown).toHaveBeenCalledWith('ssh-1')
expect(isSshUserDisconnected('ssh-1')).toBe(true)
expect(hoisted.invalidate).toHaveBeenCalledTimes(1)
})
it('leaves SSH alone for a server that is not managed over SSH', async () => {
hoisted.environment = { id: 'env-1', endpoints: [] }
await invoke('runtimeEnvironments:disconnect')
expect(hoisted.teardown).not.toHaveBeenCalled()
expect(isSshUserDisconnected('ssh-1')).toBe(false)
})
it("is lifted by the status bar's Connect, through the host's own Connect", async () => {
hoisted.environment = { id: 'env-1', endpoints: [], orcadDeployment: deployment }
await invoke('runtimeEnvironments:disconnect')
await invoke('runtimeEnvironments:connect')
expect(isRuntimeEnvironmentManuallyDisconnected('env-1')).toBe(false)
// That connect republishes the managed verdict and restores the host's port forwards.
expect(hoisted.connectHost).toHaveBeenCalledWith('ssh-1')
})
it('reconnects a server whose host the user never disconnected without dialing the host', async () => {
hoisted.environment = { id: 'env-1', endpoints: [], orcadDeployment: deployment }
await invoke('runtimeEnvironments:connect')
expect(hoisted.connectHost).not.toHaveBeenCalled()
})
})
+1 -1
View File
@@ -109,7 +109,7 @@ describe('SSH IPC handlers', () => {
for (let tick = 0; tick < 5; tick++) {
await Promise.resolve()
}
expect(mockConnectionManager.connect).toHaveBeenCalledWith(target)
expect(mockConnectionManager.connect).toHaveBeenCalledWith(target, { probe: true })
quitTeardownStartGate.tryStart({ preventDefault() {} })
const shutdown = beginSshShutdown()
+23 -3
View File
@@ -26,8 +26,13 @@ import { abandonDecisionTransport } from './ssh-session-teardown'
import { awaitTargetLifecycle } from './ssh-target-lifecycle-queue'
import { startLegacyRelayBridge } from '../ssh/legacy-relay-bridge/legacy-relay-bridge-registry'
import { clearSshCredentialDeclined } from '../ssh/ssh-credential-decline'
import { createSshUserDisconnectedError, isSshUserDisconnected } from '../ssh/ssh-user-disconnect'
import {
liftSshHostUserDisconnect,
resumeRuntimeEnvironmentStatus
} from './ssh-host-user-disconnect'
/** `automatic`: no user asked for this connect, so a cancelled sign-in stays cancelled. */
/** `automatic`: no user asked for this connect, so a cancelled sign-in or a Disconnect stays. */
export async function connectTarget(
targetId: string,
options: { automatic?: boolean } = {}
@@ -37,9 +42,24 @@ export async function connectTarget(
appendFileSync(e2eProbePath, `${JSON.stringify(targetId)}\n`)
throw new Error('e2e_forbidden_local_ssh_connect')
}
if (!options.automatic) {
clearSshCredentialDeclined(targetId)
if (options.automatic) {
// Why before any publish: the host must keep reading "disconnected", not a failed connect.
if (isSshUserDisconnected(targetId)) {
const label = getSshTargetRegistryStore()?.getTarget(targetId)?.label ?? targetId
throw createSshUserDisconnectedError(label)
}
return admitConnect(targetId)
}
clearSshCredentialDeclined(targetId)
const liftedEnvironments = liftSshHostUserDisconnect(targetId)
try {
return await admitConnect(targetId)
} finally {
resumeRuntimeEnvironmentStatus(liftedEnvironments)
}
}
async function admitConnect(targetId: string): Promise<SshConnectionState> {
// Why: fence callers that entered before a same-turn disconnect/reset but resume after its cleanup.
const admissionAuthority = getSshProviderAuthority(targetId)
await awaitTargetLifecycle(targetId)
+4 -3
View File
@@ -8,7 +8,7 @@ import {
import { connectTarget } from './ssh-connect-flow'
import { connectionManager } from './ssh-ipc-context'
import { getPublicSshState } from './ssh-renderer-broadcast'
import { disconnectRegisteredSshTarget } from './ssh-session-teardown'
import { disconnectSshHostForUser } from './ssh-host-user-disconnect'
import { errorMessage } from '../../shared/error-message'
import { clearSshCredentialDeclined, isSshCredentialDeclined } from '../ssh/ssh-credential-decline'
@@ -23,7 +23,7 @@ export function registerSshConnectionHandlers(): void {
})
ipcMain.handle('ssh:disconnect', async (_event, args: { targetId: string }) => {
await disconnectRegisteredSshTarget(args.targetId)
await disconnectSshHostForUser(args.targetId)
})
ipcMain.handle('ssh:getState', (_event, args: { targetId: string }) => {
@@ -65,7 +65,8 @@ export function registerSshConnectionHandlers(): void {
const probe = runSshTestConnectionProbe(args.targetId, async () => {
// Why: a probe transport opened after the shutdown drain would outlive orderly teardown.
assertSshConnectsNotFenced()
const conn = await connectionManager!.connect(target)
// Why: a test of a disconnected host may dial it, but must leave it disconnected.
const conn = await connectionManager!.connect(target, { probe: true })
const state = conn.getState()
await connectionManager!.disconnect(args.targetId)
return state
@@ -0,0 +1,151 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = await vi.hoisted(async () => {
const { createSshIpcMocks } = await import('./ssh-ipc-module-mocks')
return createSshIpcMocks()
})
const runtime = vi.hoisted(() => {
const environments: unknown[] = []
return { environments, activate: vi.fn(), getStatusOwner: vi.fn(), invalidate: vi.fn() }
})
vi.mock('../ssh/ssh-config-host-picker', () => mocks.sshConfigHostPicker)
vi.mock('electron', () => mocks.electron)
vi.mock('../ssh/ssh-connection-store', () => mocks.sshConnectionStore)
vi.mock('./ssh-host-server-connect', () => mocks.hostServerConnect)
vi.mock('../ssh/ssh-connection-manager', () => mocks.sshConnectionManager)
vi.mock('../ssh/ssh-channel-multiplexer', () => mocks.sshChannelMultiplexer)
vi.mock('../ssh/ssh-port-forward', () => mocks.sshPortForward)
vi.mock('../ssh/ssh-port-scanner', () => mocks.sshPortScanner)
vi.mock('../../shared/runtime-environment-store', async (importOriginal) => ({
...(await importOriginal<object>()),
listEnvironments: () => runtime.environments
}))
vi.mock('./runtime-environment-request-connections', async (importOriginal) => ({
...(await importOriginal<object>()),
getRuntimeEnvironmentStatusOwner: runtime.getStatusOwner
}))
vi.mock('./runtime-environments', () => ({
invalidateRuntimeEnvironmentTransport: runtime.invalidate
}))
import type { SshConnectionState, SshTarget } from '../../shared/ssh-types'
import {
clearRuntimeEnvironmentManualDisconnect,
isRuntimeEnvironmentManuallyDisconnected
} from './runtime-environment-manual-disconnect'
import { isSshUserDisconnected } from '../ssh/ssh-user-disconnect'
import { createSshIpcHarness } from './ssh-ipc-test-harness'
const { mockSshStore, mockConnectionManager } = mocks
const target: SshTarget = {
id: 'ssh-1',
label: 'Server',
host: 'example.com',
port: 22,
username: 'deploy'
}
const managedServer = {
id: 'env-managed',
orcadDeployment: {
kind: 'ssh',
requestId: 'request-1',
sshTargetId: 'ssh-1',
sshTargetGeneration: 1,
localPort: 41000,
remotePort: 6768
}
}
const otherServer = { id: 'env-other' }
describe('a user Disconnect of an SSH host', () => {
const harness = createSshIpcHarness(mocks)
const { handlers } = harness
beforeEach(async () => {
await harness.reset()
runtime.environments = [managedServer, otherServer]
runtime.getStatusOwner.mockReset().mockReturnValue({ activate: runtime.activate })
runtime.activate.mockReset()
runtime.invalidate.mockReset()
mockSshStore.getTarget.mockReturnValue(target)
mockConnectionManager.disconnect.mockResolvedValue(undefined)
})
afterEach(() => {
clearRuntimeEnvironmentManualDisconnect(managedServer.id)
clearRuntimeEnvironmentManualDisconnect(otherServer.id)
})
async function disconnect(): Promise<void> {
await handlers.get('ssh:disconnect')!(null, { targetId: target.id })
}
it('is not undone by a background connect such as a pane or automation restore', async () => {
await disconnect()
await expect(
handlers.get('ssh:connect')!(null, { targetId: target.id, automatic: true })
).rejects.toThrow('Server was disconnected. Connect to use it again.')
expect(mockConnectionManager.connect).not.toHaveBeenCalled()
expect(isSshUserDisconnected(target.id)).toBe(true)
})
it("also stops the managed server's status checks, which would redial the host", async () => {
await disconnect()
expect(isRuntimeEnvironmentManuallyDisconnected(managedServer.id)).toBe(true)
expect(runtime.invalidate).toHaveBeenCalledWith(managedServer.id)
expect(isRuntimeEnvironmentManuallyDisconnected(otherServer.id)).toBe(false)
})
/** Settings' Connect; the server decision is mocked, so only the lift matters here. */
async function connectExplicitly(): Promise<void> {
await Promise.resolve(handlers.get('ssh:connect')!(null, { targetId: target.id })).catch(
() => {}
)
}
it('is not re-applied by its own cleanup when a Connect follows at once', async () => {
const disconnecting = disconnect()
const connecting = connectExplicitly()
await Promise.all([disconnecting, connecting])
expect(isSshUserDisconnected(target.id)).toBe(false)
expect(isRuntimeEnvironmentManuallyDisconnected(managedServer.id)).toBe(false)
expect(runtime.invalidate).not.toHaveBeenCalled()
})
it('is lifted, with its server, by an explicit Connect, whose status checks resume after', async () => {
await disconnect()
mockConnectionManager.connect.mockResolvedValue({})
const { publishManagedServerConnect } = await import('./ssh-host-server-connect')
const connected: SshConnectionState = {
targetId: target.id,
status: 'connected',
error: null,
reconnectAttempt: 0
}
vi.mocked(publishManagedServerConnect).mockReturnValue(connected)
await connectExplicitly()
expect(isSshUserDisconnected(target.id)).toBe(false)
expect(isRuntimeEnvironmentManuallyDisconnected(managedServer.id)).toBe(false)
expect(runtime.activate).toHaveBeenCalledTimes(1)
})
it('still lets the user test the connection, which leaves it disconnected', async () => {
await disconnect()
mockConnectionManager.connect.mockResolvedValue({
getState: () => ({ targetId: target.id, status: 'connected', error: null })
})
await handlers.get('ssh:testConnection')!(null, { targetId: target.id })
expect(mockConnectionManager.connect).toHaveBeenCalledWith(target, { probe: true })
expect(isSshUserDisconnected(target.id)).toBe(true)
expect(isRuntimeEnvironmentManuallyDisconnected(managedServer.id)).toBe(true)
})
})
+129
View File
@@ -0,0 +1,129 @@
import { getAppEnvironment } from '../../shared/app-environment'
import { listEnvironments } from '../../shared/runtime-environment-store'
import {
getOrcadSshDeployment,
getRuntimeSshAccess,
type KnownRuntimeEnvironment
} from '../../shared/runtime-environments'
import {
clearSshUserDisconnected,
clearSshUserDisconnectedForEnvironment,
isSshUserDisconnected,
recordSshUserDisconnected
} from '../ssh/ssh-user-disconnect'
import { connectRegisteredSshTarget } from '../ssh/ssh-target-registry'
import {
clearRuntimeEnvironmentManualDisconnect,
isRuntimeEnvironmentManuallyDisconnected,
markRuntimeEnvironmentManuallyDisconnected
} from './runtime-environment-manual-disconnect'
import { getRuntimeEnvironmentStatusOwner } from './runtime-environment-request-connections'
import { disconnectRegisteredSshTarget } from './ssh-session-teardown'
/**
* A server reached through an SSH host cannot work without it, and its status checks redial it.
* So a user's Disconnect of either one (Settings or the status bar) takes down both, and only an
* explicit Connect of either one brings both back; every surface then reads the same verdict.
*/
type InvalidateTransport = (environmentId: string) => Promise<void> | void
type RuntimeLayer = { userDataPath: string; invalidateTransport: InvalidateTransport }
const appUserDataPath = (): string => getAppEnvironment().getPath('userData')
function environmentsReachedThrough(
userDataPath: string,
targetId: string
): KnownRuntimeEnvironment[] {
return listEnvironments(userDataPath).filter(
(environment) => getRuntimeSshAccess(environment)?.sshTargetId === targetId
)
}
export function disconnectRuntimeEnvironmentForUser(
environmentId: string,
{ userDataPath, invalidateTransport }: RuntimeLayer
): void {
markRuntimeEnvironmentManuallyDisconnected(environmentId)
void invalidateTransport(environmentId)
// Retain disconnected evidence for renderers that missed the teardown event.
getRuntimeEnvironmentStatusOwner(userDataPath, environmentId)
}
export async function disconnectSshHostForUser(
targetId: string,
runtimeLayer?: RuntimeLayer
): Promise<void> {
// Why synchronously, before any await: a status check already in flight must not redial, and the
// teardown must reserve the target's lifecycle so a later Connect queues behind it.
recordSshUserDisconnected(targetId)
const userDataPath = runtimeLayer?.userDataPath ?? appUserDataPath()
const environments = environmentsReachedThrough(userDataPath, targetId).filter(
(environment) => !isRuntimeEnvironmentManuallyDisconnected(environment.id)
)
environments.forEach((environment) => markRuntimeEnvironmentManuallyDisconnected(environment.id))
const teardown = disconnectRegisteredSshTarget(targetId)
if (environments.length > 0) {
// Why lazy: runtime-environments wires every runtime handler and imports the SSH layer.
const invalidateTransport =
runtimeLayer?.invalidateTransport ??
(await import('./runtime-environments')).invalidateRuntimeEnvironmentTransport
for (const environment of environments) {
// A Connect during the import already lifted this server; tearing it down would undo that.
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
disconnectRuntimeEnvironmentForUser(environment.id, { userDataPath, invalidateTransport })
}
}
}
await teardown
}
/** The status bar's Disconnect of a managed server is a Disconnect of the machine it runs on. */
export async function disconnectManagedHostOfEnvironmentForUser(
environment: Pick<KnownRuntimeEnvironment, 'orcadDeployment'>,
runtimeLayer: RuntimeLayer
): Promise<void> {
const targetId = getOrcadSshDeployment(environment)?.sshTargetId
if (targetId) {
await disconnectSshHostForUser(targetId, runtimeLayer)
}
}
/** The status bar's Connect of a server lifts the Disconnect of the SSH host it is reached through. */
export async function liftSshUserDisconnectForEnvironment(
environment: Pick<KnownRuntimeEnvironment, 'orcadDeployment' | 'sshAccess'>
): Promise<void> {
const managedHostId = getOrcadSshDeployment(environment)?.sshTargetId
if (managedHostId && isSshUserDisconnected(managedHostId)) {
// Why: the host's own Connect republishes its managed verdict and restores its port forwards,
// which the status bar's Disconnect tore down with the host.
await connectRegisteredSshTarget(managedHostId)
return
}
clearSshUserDisconnectedForEnvironment(environment)
}
/**
* An explicit Connect of an SSH host lifts its Disconnect and that of the servers reached through it.
* Returns the servers lifted, whose status checks resume once the connect settles.
*/
export function liftSshHostUserDisconnect(targetId: string): string[] {
clearSshUserDisconnected(targetId)
const lifted: string[] = []
for (const environment of environmentsReachedThrough(appUserDataPath(), targetId)) {
if (isRuntimeEnvironmentManuallyDisconnected(environment.id)) {
clearRuntimeEnvironmentManualDisconnect(environment.id)
lifted.push(environment.id)
}
}
return lifted
}
/** Why after the connect: an earlier status check would race the connect for the same host. */
export function resumeRuntimeEnvironmentStatus(environmentIds: readonly string[]): void {
for (const environmentId of environmentIds) {
if (!isRuntimeEnvironmentManuallyDisconnected(environmentId)) {
getRuntimeEnvironmentStatusOwner(appUserDataPath(), environmentId).activate()
}
}
}
+2
View File
@@ -30,6 +30,7 @@ import {
} from './ssh-connect-attempt-registry'
import { createSshConnectionCallbacks } from './ssh-connection-state-callbacks'
import { resetSshCredentialDeclinesForTests } from '../ssh/ssh-credential-decline'
import { resetSshUserDisconnectsForTests } from '../ssh/ssh-user-disconnect'
import { registerSshConnectionHandlers } from './ssh-connection-handlers'
import {
registerPowerMonitorReconnect,
@@ -170,6 +171,7 @@ export async function resetSshHandlerStateForTests(): Promise<void> {
testConnectionProbes.clear()
credentialRequestedForTarget.clear()
resetSshCredentialDeclinesForTests()
resetSshUserDisconnectsForTests()
quitTeardownStartGate.resetForTests()
resetSshShutdownDrain()
@@ -55,6 +55,7 @@ import {
resetSshCredentialDeclinesForTests
} from './ssh-credential-decline'
import { isAuthError } from './ssh-connection-utils'
import { recordSshUserDisconnected, resetSshUserDisconnectsForTests } from './ssh-user-disconnect'
const target = {
id: 'target-1',
@@ -271,3 +272,43 @@ describe('SshConnectionManager after a cancelled sign-in', () => {
expect(mockState.instances).toHaveLength(1)
})
})
describe('SshConnectionManager after a user Disconnect', () => {
beforeEach(() => {
mockState.connectResults.length = 0
mockState.instances.length = 0
resetSshUserDisconnectsForTests()
})
it('refuses the background dials that revived the host, without opening a connection', async () => {
// A managed server's tunnel, its status checks, a census and host-resume recovery dial here.
const manager = new SshConnectionManager({ onStateChange: vi.fn() })
await manager.connect(target)
await manager.disconnect(target.id)
recordSshUserDisconnected(target.id)
mockState.instances.length = 0
const error = await manager.connect(target).catch((err: unknown) => err)
expect(error instanceof Error && error.message).toBe(
'Target 1 was disconnected. Connect to use it again.'
)
expect(error instanceof Error && isAuthError(error)).toBe(false)
expect(mockState.instances).toHaveLength(0)
})
it("does not lend a connection test's transport to a background dial", async () => {
const manager = new SshConnectionManager({ onStateChange: vi.fn() })
recordSshUserDisconnected(target.id)
await manager.connect(target, { probe: true })
await expect(manager.connect(target)).rejects.toThrow('was disconnected')
})
it("dials a user's connection test", async () => {
const manager = new SshConnectionManager({ onStateChange: vi.fn() })
recordSshUserDisconnected(target.id)
await expect(manager.connect(target, { probe: true })).resolves.toBeDefined()
})
})
+8 -1
View File
@@ -6,6 +6,7 @@ import {
} from './ssh-connection'
import { recordSshConnectionOpened, recordSshConnectionReused } from './ssh-connection-attribution'
import { createSshCredentialDeclinedError, isSshCredentialDeclined } from './ssh-credential-decline'
import { createSshUserDisconnectedError, isSshUserDisconnected } from './ssh-user-disconnect'
/** A remembered sign-in only applies to the endpoint it was given for; an edited host must ask. */
function signInEndpoint(target: SshTarget): string {
@@ -37,7 +38,13 @@ export class SshConnectionManager {
}
}
async connect(target: SshTarget): Promise<SshConnection> {
/** `probe`: a user's connection test, which a Disconnect does not refuse. */
async connect(target: SshTarget, options?: { probe?: boolean }): Promise<SshConnection> {
// Why: background dials would revive a host the user disconnected (#30086); before reuse, so
// none borrows the transport a connection test opened.
if (!options?.probe && isSshUserDisconnected(target.id)) {
throw createSshUserDisconnectedError(target.label)
}
const existing = this.connections.get(target.id)
if (existing?.getState().status === 'connected') {
recordSshConnectionReused(existing)
+46
View File
@@ -0,0 +1,46 @@
import {
getRuntimeSshAccess,
type KnownRuntimeEnvironment
} from '../../shared/runtime-environments'
/**
* Hosts the user pressed Disconnect on. Until the user explicitly connects again, no background
* dial (a managed server's tunnel or status check, a census, a pane or automation restore) may
* reconnect them. Session-only, like a paired server's manual disconnect, so both halves of a
* managed host are forgotten together at restart.
*/
const userDisconnectedTargets = new Set<string>()
export function recordSshUserDisconnected(targetId: string): void {
userDisconnectedTargets.add(targetId)
}
export function isSshUserDisconnected(targetId: string): boolean {
return userDisconnectedTargets.has(targetId)
}
/** Only an explicit user connect may lift it. */
export function clearSshUserDisconnected(targetId: string): void {
userDisconnectedTargets.delete(targetId)
}
/** An explicit connect to a server reached over SSH is a connect of that SSH host too. */
export function clearSshUserDisconnectedForEnvironment(
environment: Pick<KnownRuntimeEnvironment, 'orcadDeployment' | 'sshAccess'>
): void {
const targetId = getRuntimeSshAccess(environment)?.sshTargetId
if (targetId) {
clearSshUserDisconnected(targetId)
}
}
export function resetSshUserDisconnectsForTests(): void {
userDisconnectedTargets.clear()
}
/** Not an auth or transient failure: callers neither ask for a sign-in nor retry it. */
export function createSshUserDisconnectedError(label: string): Error {
return Object.assign(new Error(`${label} was disconnected. Connect to use it again.`), {
code: 'ssh_user_disconnected'
})
}