From 15953ef0ea5d439f92b18559f889f78be0fc45db Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:23:21 -0700 Subject: [PATCH] fix(settings): let an unfinished SSH access link be retried or cancelled (#29441) * fix(settings): let an unfinished SSH access link be retried or cancelled A failed link to a paired server keeps its durable pending operation so it can resume, but the control only offered a new link with a fresh request id, which main refuses, and no cancel. The pending state then blocked environment rm and re-pairing with no way out. Show the unfinished operation with Retry and Cancel, both sent under its saved request id, and make the removal refusal say where to resolve it. * test: match the pending SSH access refusal --- .../settings/RuntimeSshAccessControl.test.tsx | 78 +++++++++++++++++ .../settings/RuntimeSshAccessControl.tsx | 84 ++++++++++++++++--- src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/fr.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- ...ntime-environment-ssh-access-store.test.ts | 6 +- src/shared/runtime-environment-store.ts | 10 ++- 10 files changed, 192 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/components/settings/RuntimeSshAccessControl.test.tsx diff --git a/src/renderer/src/components/settings/RuntimeSshAccessControl.test.tsx b/src/renderer/src/components/settings/RuntimeSshAccessControl.test.tsx new file mode 100644 index 00000000000..8d64d77640f --- /dev/null +++ b/src/renderer/src/components/settings/RuntimeSshAccessControl.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api' +import { RuntimeSshAccessControl } from './RuntimeSshAccessControl' + +function environment( + pendingSshAccessOperation: PublicKnownRuntimeEnvironment['pendingSshAccessOperation'] +): PublicKnownRuntimeEnvironment { + return { + id: 'env-a', + name: 'fight PC', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: 'runtime-a', + endpoints: [{ id: 'ws', kind: 'websocket', label: 'Tailscale', endpoint: 'ws://a:6768' }], + preferredEndpointId: 'ws', + pendingSshAccessOperation + } +} + +const pendingLink = { + requestId: 'request-1', + operation: 'link' as const, + sshTargetId: 'ssh-weknora', + sshTargetGeneration: 3, + remotePort: 6768, + targetFingerprint: 'fingerprint' +} + +function renderControl(env: PublicKnownRuntimeEnvironment) { + const linkSshAccess = vi.fn(async () => env) + const unlinkSshAccess = vi.fn(async () => env) + const onChanged = vi.fn() + const api: Pick = { + linkSshAccess, + unlinkSshAccess + } + render() + return { linkSshAccess, unlinkSshAccess, onChanged } +} + +afterEach(cleanup) + +// #27765: an unfinished link blocked removing the server and offered no way out. +describe('an unfinished SSH access link', () => { + it('cancels the recorded operation by its own request id', async () => { + const { unlinkSshAccess, onChanged } = renderControl(environment(pendingLink)) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(onChanged).toHaveBeenCalled()) + expect(unlinkSshAccess).toHaveBeenCalledWith({ selector: 'env-a', requestId: 'request-1' }) + }) + + it('retries the recorded link rather than starting another', async () => { + const { linkSshAccess, onChanged } = renderControl(environment(pendingLink)) + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(onChanged).toHaveBeenCalled()) + expect(linkSshAccess).toHaveBeenCalledWith({ + selector: 'env-a', + requestId: 'request-1', + sshTargetId: 'ssh-weknora', + remotePort: 6768 + }) + }) + + it('finishes an unfinished unlink by its own request id', async () => { + const { unlinkSshAccess, onChanged } = renderControl( + environment({ ...pendingLink, operation: 'unlink' }) + ) + expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(onChanged).toHaveBeenCalled()) + expect(unlinkSshAccess).toHaveBeenCalledWith({ selector: 'env-a', requestId: 'request-1' }) + }) +}) diff --git a/src/renderer/src/components/settings/RuntimeSshAccessControl.tsx b/src/renderer/src/components/settings/RuntimeSshAccessControl.tsx index a320a596898..1b3573346d0 100644 --- a/src/renderer/src/components/settings/RuntimeSshAccessControl.tsx +++ b/src/renderer/src/components/settings/RuntimeSshAccessControl.tsx @@ -14,7 +14,7 @@ import { SshTargetSelect } from './SshTargetSelect' const DEFAULT_REMOTE_PORT = '6768' type RuntimeSshAccessControlProps = { - api: ManagedOrcadPreloadApi + api: Pick environment: PublicKnownRuntimeEnvironment targets: SshTarget[] onChanged: () => void @@ -33,19 +33,12 @@ export function RuntimeSshAccessControl({ const targetFieldId = useId() const portFieldId = useId() const linked = environment.sshAccess + const pending = environment.pendingSshAccessOperation - const submit = async (): Promise => { + const run = async (call: () => Promise): Promise => { setBusy(true) try { - const requestId = createBrowserUuid() - await (linked - ? api.unlinkSshAccess({ selector: environment.id, requestId }) - : api.linkSshAccess({ - selector: environment.id, - requestId, - sshTargetId: targetId, - remotePort: Number(remotePort) - })) + await call() onChanged() } catch (error) { toast.error(error instanceof Error ? error.message : String(error)) @@ -54,6 +47,75 @@ export function RuntimeSshAccessControl({ } } + if (pending) { + // Why the saved request id: main resumes or cancels only the operation it recorded, and a + // fresh id made an unfinished link a dead end that also blocked removing the server (#27765). + const requestId = pending.requestId + const retry = (): Promise => + pending.operation === 'link' + ? api.linkSshAccess({ + selector: environment.id, + requestId, + sshTargetId: pending.sshTargetId, + remotePort: pending.remotePort + }) + : api.unlinkSshAccess({ selector: environment.id, requestId }) + return ( +
+
+
{environment.name}
+

+ {pending.operation === 'link' + ? translate( + 'auto.components.settings.managedServers.access.pendingLink', + 'Connecting through SSH didn’t finish.' + ) + : translate( + 'auto.components.settings.managedServers.access.pendingUnlink', + 'Stopping SSH access didn’t finish.' + )} +

+
+ + {pending.operation === 'link' ? ( + + ) : null} +
+ ) + } + + const submit = (): Promise => { + const requestId = createBrowserUuid() + return run(() => + linked + ? api.unlinkSshAccess({ selector: environment.id, requestId }) + : api.linkSshAccess({ + selector: environment.id, + requestId, + sshTargetId: targetId, + remotePort: Number(remotePort) + }) + ) + } + if (linked) { return (
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4c0d95e3103..f785149daf7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12650,7 +12650,11 @@ "target": "Reach through SSH host", "port": "Server port", "link": "Use SSH", - "linking": "Connecting…" + "linking": "Connecting…", + "pendingLink": "Connecting through SSH didn’t finish.", + "pendingUnlink": "Stopping SSH access didn’t finish.", + "retry": "Retry", + "cancelLink": "Cancel" }, "search": { "title": "Managed servers", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f475c512412..512928ccf91 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -12461,7 +12461,11 @@ "target": "Conectar a través del host SSH", "port": "Puerto del servidor", "link": "Usar SSH", - "linking": "Conectando…" + "linking": "Conectando…", + "pendingLink": "La conexión por SSH no terminó.", + "pendingUnlink": "La desactivación del acceso SSH no terminó.", + "retry": "Reintentar", + "cancelLink": "Cancelar" }, "search": { "title": "Servidores gestionados", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index f0b8ae7e7e3..1ac835508f5 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -12573,7 +12573,11 @@ "target": "Passer par un hôte SSH", "port": "Port du serveur", "link": "Utiliser SSH", - "linking": "Connexion…" + "linking": "Connexion…", + "pendingLink": "La connexion via SSH n’a pas abouti.", + "pendingUnlink": "L’arrêt de l’accès SSH n’a pas abouti.", + "retry": "Réessayer", + "cancelLink": "Annuler" }, "search": { "title": "Serveurs gérés", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 45cc7743afb..a3f0ab0aaf4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12465,7 +12465,11 @@ "target": "SSH ホスト経由で接続", "port": "サーバーポート", "link": "SSH を使用", - "linking": "接続中…" + "linking": "接続中…", + "pendingLink": "SSH 経由の接続が完了しませんでした。", + "pendingUnlink": "SSH アクセスの停止が完了しませんでした。", + "retry": "再試行", + "cancelLink": "キャンセル" }, "search": { "title": "管理対象サーバー", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d95944fd05b..f513d039137 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12465,7 +12465,11 @@ "target": "SSH 호스트를 통해 연결", "port": "서버 포트", "link": "SSH 사용", - "linking": "연결 중…" + "linking": "연결 중…", + "pendingLink": "SSH를 통한 연결이 완료되지 않았습니다.", + "pendingUnlink": "SSH 액세스 중지가 완료되지 않았습니다.", + "retry": "다시 시도", + "cancelLink": "취소" }, "search": { "title": "관리형 서버", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7115b37d050..4408e1c7b79 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12479,7 +12479,11 @@ "target": "通过 SSH 主机访问", "port": "服务器端口", "link": "使用 SSH", - "linking": "正在连接…" + "linking": "正在连接…", + "pendingLink": "通过 SSH 连接未完成。", + "pendingUnlink": "停止 SSH 访问未完成。", + "retry": "重试", + "cancelLink": "取消" }, "search": { "title": "受管服务器", diff --git a/src/shared/runtime-environment-ssh-access-store.test.ts b/src/shared/runtime-environment-ssh-access-store.test.ts index 1b28e46dbc4..814c14aa81e 100644 --- a/src/shared/runtime-environment-ssh-access-store.test.ts +++ b/src/shared/runtime-environment-ssh-access-store.test.ts @@ -220,12 +220,12 @@ describe('independent paired runtime SSH access persistence', () => { expect(prepare(original)).toEqual(prepared) expect(prepare(prepared)).toEqual(prepared) expect(() => prepare(prepared, 'other-request')).toThrow('Another SSH access') - expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Unlink') + expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Retry or cancel it') expect(() => updateEnvironmentFromPairingCode(userDataPath, prepared.id, { pairingCode: encodePairingOffer(pairing) }) - ).toThrow('Unlink') + ).toThrow('Retry or cancel it') expect(() => markEnvironmentUsed(userDataPath, prepared.id, { runtimeId: 'different-runtime' }) ).toThrow('cannot change') @@ -288,7 +288,7 @@ describe('independent paired runtime SSH access persistence', () => { requestId: 'stale-request' }) ).toThrow('pending unlink intent') - expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Unlink') + expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Retry or cancel it') const finished = completeRuntimeEnvironmentSshAccessUnlink(userDataPath, { expectedEnvironment: prepared, requestId: 'release-request' diff --git a/src/shared/runtime-environment-store.ts b/src/shared/runtime-environment-store.ts index 919eb650063..2315a969347 100644 --- a/src/shared/runtime-environment-store.ts +++ b/src/shared/runtime-environment-store.ts @@ -269,10 +269,16 @@ function assertNoIndependentSshAccess(environment: KnownRuntimeEnvironment): voi 'This server is managed by Orca over SSH; its pairing cannot be replaced or removed here. To decommission it, run `orca environment stop --environment --yes` or use Settings > Managed servers; for a host that is gone, run `orca environment forget --environment --yes`.' ) } - if (environment.sshAccess || environment.pendingSshAccessOperation) { + if (environment.pendingSshAccessOperation) { throw new RuntimeEnvironmentStoreError( 'invalid_argument', - "Unlink this server's SSH access before replacing its pairing or removing it." + "This server's SSH access change didn't finish. Retry or cancel it under Settings > Remote Orca Servers > SSH access for paired servers before replacing its pairing or removing it." + ) + } + if (environment.sshAccess) { + throw new RuntimeEnvironmentStoreError( + 'invalid_argument', + "Unlink this server's SSH access under Settings > Remote Orca Servers > SSH access for paired servers before replacing its pairing or removing it." ) } }