mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(ssh): stop a failed worktree scan from publishing authoritative emptiness (#17833)
* fix(ssh): keep an unreadable worktree catalog from authorizing teardown #14004: the relay's worktree-list fallback caught every failure and returned `[]`, so `SshGitProvider.listWorktrees` resolved as a success with an empty list. Downstream reconciliation treats a resolved listing as authoritative, which reaches `teardownMissingWorktreeTerminalsBestEffort` and the unregistered-worktree removal paths — a data-loss path from a failed scan. - relay: the `-z`-unsupported fallback lane propagates its failure instead of swallowing it to `[]`. - provider: an empty or malformed `git.listWorktrees` response is refused as `WorktreeCatalogUnavailableError`. A Git repo always lists its own checkout, so a zero-row listing can only be a scan that never answered — this is the mixed-version guard against relays that still swallow. - `listRepoWorktrees`: an unreachable SSH host reports unavailable instead of an empty catalog. #12661: `ssh:terminateSessions` now returns `{ terminated, unverifiable }`, so an offline sweep that only tore down local transport cannot be mistaken for a remote kill. The Manage-hosts toast warns instead of claiming success. * chore(i18n): register the unreachable-terminal terminate message
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import type { SshTarget, SshTerminateSessionsResult } from '../../shared/ssh-types'
|
||||
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants'
|
||||
import { isSshPtyNotFoundError } from '../providers/ssh-pty-errors'
|
||||
import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
|
||||
@@ -98,6 +98,9 @@ export function registerSshConnectionHandlers(): void {
|
||||
|
||||
ipcMain.handle('ssh:terminateSessions', async (_event, args: { targetId: string }) => {
|
||||
invalidateConnectAttempt(args.targetId)
|
||||
// Why (#12661): an offline sweep tears down local transport only. The caller must be able to tell
|
||||
// "the host stopped these" from "nobody asked the host", so carry the verdict out of the lifecycle queue.
|
||||
let outcome: SshTerminateSessionsResult = { terminated: 0, unverifiable: 0 }
|
||||
await runTargetLifecycle(args.targetId, async () => {
|
||||
const provider = getSshPtyProvider(args.targetId)
|
||||
const leases = persistedStore!.getSshRemotePtyLeases(args.targetId)
|
||||
@@ -142,6 +145,10 @@ export function registerSshConnectionHandlers(): void {
|
||||
)
|
||||
)
|
||||
: []
|
||||
if (!provider) {
|
||||
// Nothing observed these remote shells, so their state is unknown — not "nothing to do".
|
||||
outcome = { terminated: 0, unverifiable: ptyIds.length }
|
||||
}
|
||||
const shutdownFailures: string[] = []
|
||||
for (const [index, result] of shutdownResults.entries()) {
|
||||
const { appPtyId, relayPtyId } = ptyIds[index]
|
||||
@@ -154,6 +161,7 @@ export function registerSshConnectionHandlers(): void {
|
||||
clearProviderPtyState(appPtyId)
|
||||
deletePtyOwnership(appPtyId)
|
||||
persistedStore!.markSshRemotePtyLease(args.targetId, relayPtyId, 'terminated')
|
||||
outcome = { ...outcome, terminated: outcome.terminated + 1 }
|
||||
}
|
||||
if (shutdownFailures.length > 0) {
|
||||
// Why: a failed relay shutdown can leave the remote process alive in the grace window; keep the lease/session so the user can retry.
|
||||
@@ -161,6 +169,7 @@ export function registerSshConnectionHandlers(): void {
|
||||
}
|
||||
await teardownSshTargetTransport(args.targetId, (session) => session.disposeAndPersist())
|
||||
})
|
||||
return outcome
|
||||
})
|
||||
|
||||
ipcMain.handle('ssh:resetRelay', (_event, args: { targetId: string }) => {
|
||||
|
||||
@@ -95,7 +95,9 @@ describe('SSH IPC handlers', () => {
|
||||
mockPtyProvider.shutdown.mockResolvedValue(undefined)
|
||||
|
||||
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
|
||||
await handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
|
||||
await expect(
|
||||
handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
|
||||
).resolves.toEqual({ terminated: 2, unverifiable: 0 })
|
||||
|
||||
expect(mockPtyProvider.shutdown).toHaveBeenCalledWith('ssh:ssh-1@@pty-live', {
|
||||
immediate: true,
|
||||
@@ -168,7 +170,9 @@ describe('SSH IPC handlers', () => {
|
||||
await expect(reconnect).resolves.toMatchObject({ targetId: 'ssh-1', status: 'connected' })
|
||||
})
|
||||
|
||||
it('ssh:terminateSessions cannot reach expired leases without a relay', async () => {
|
||||
// Issue #12661: an offline sweep tears down local transport only. Reporting plain success would
|
||||
// read as "the remote shells are gone" when nobody asked the host.
|
||||
it('ssh:terminateSessions reports expired leases as unverifiable without a relay', async () => {
|
||||
mockStore.getSshRemotePtyLeases.mockReturnValue([
|
||||
{ targetId: 'ssh-1', ptyId: 'pty-expired', state: 'expired' }
|
||||
])
|
||||
@@ -177,10 +181,26 @@ describe('SSH IPC handlers', () => {
|
||||
|
||||
await expect(
|
||||
handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
|
||||
).resolves.toBeUndefined()
|
||||
).resolves.toEqual({ terminated: 0, unverifiable: 1 })
|
||||
|
||||
expect(mockPtyProvider.shutdown).not.toHaveBeenCalled()
|
||||
// Still no forced reconnect: an expired lease can name a host that is gone for good (#2626).
|
||||
expect(mockConnectionManager.disconnect).toHaveBeenCalledWith('ssh-1')
|
||||
expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
'pty-expired',
|
||||
'terminated'
|
||||
)
|
||||
})
|
||||
|
||||
it('ssh:terminateSessions reports nothing unverifiable when there is nothing to reach', async () => {
|
||||
mockStore.getSshRemotePtyLeases.mockReturnValue([])
|
||||
vi.mocked(getSshPtyProvider).mockReturnValue(undefined)
|
||||
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
|
||||
|
||||
await expect(
|
||||
handlers.get('ssh:terminateSessions')!(null, { targetId: 'ssh-1' })
|
||||
).resolves.toEqual({ terminated: 0, unverifiable: 0 })
|
||||
})
|
||||
|
||||
it('ssh:terminateSessions kills expired leases whose remote PTY may still be alive', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { GitStatusResult } from '../../shared/git-status-types'
|
||||
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import type { GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import { CapabilityProbeCache } from '../../shared/capability-probe-cache'
|
||||
import { assertAuthoritativeWorktreeCatalog } from '../../shared/worktree/worktree-catalog-availability'
|
||||
import { isJsonRpcMethodNotFoundError } from './ssh-git-relay-errors'
|
||||
import { SshGitReviewHeadProvider } from './ssh-git-review-head-provider'
|
||||
|
||||
@@ -32,11 +33,14 @@ export class SshGitWorktreeProvider extends SshGitReviewHeadProvider {
|
||||
repoPath: string,
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<GitWorktreeInfo[]> {
|
||||
return (await this.mux.request(
|
||||
const response = await this.mux.request(
|
||||
'git.listWorktrees',
|
||||
{ repoPath },
|
||||
{ signal: options?.signal }
|
||||
)) as GitWorktreeInfo[]
|
||||
)
|
||||
// Why (#14004): relays before this fix answered a failed worktree scan with `[]`. Mixed versions are
|
||||
// normal, so refuse the shape here too — a Git repo always lists its own checkout.
|
||||
return assertAuthoritativeWorktreeCatalog<GitWorktreeInfo>(response, repoPath)
|
||||
}
|
||||
|
||||
async addWorktree(
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Issue #14004: an SSH worktree catalog Orca could not read must never surface as an authoritative
|
||||
* empty catalog. Covers the whole client-side chain — provider response guard, the repo-level
|
||||
* listing, and the detected-worktree result whose `authoritative` flag gates renderer terminal
|
||||
* teardown (`teardownMissingWorktreeTerminalsBestEffort`).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SshGitProvider } from './ssh-git-provider'
|
||||
import { createMockMux, type MockMultiplexer } from './ssh-git-provider-test-harness'
|
||||
import { isWorktreeCatalogUnavailableError } from '../../shared/worktree/worktree-catalog-availability'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import { listDetectedWorktreesForCapturedRepo } from '../ipc/worktrees/listing/detected-provider-listing'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { Store } from '../persistence/loading-store/store'
|
||||
|
||||
const { getSshGitProviderMock } = vi.hoisted(() => ({ getSshGitProviderMock: vi.fn() }))
|
||||
|
||||
vi.mock('./ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
requireSshGitProvider: getSshGitProviderMock,
|
||||
getSshGitProviderGeneration: () => 1
|
||||
}))
|
||||
|
||||
const CONNECTION_ID = 'conn-1'
|
||||
const REPO_PATH = '/home/user/repo'
|
||||
const WORKTREE_PATH = '/home/user/feature'
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'repo-1',
|
||||
path: REPO_PATH,
|
||||
displayName: 'repo',
|
||||
connectionId: CONNECTION_ID
|
||||
} as Repo
|
||||
|
||||
const worktreeId = `${repo.id}::${WORKTREE_PATH}`
|
||||
|
||||
function createStore(): Store {
|
||||
const meta: Record<string, { hostId?: string; instanceId?: string }> = {
|
||||
[worktreeId]: { instanceId: 'instance-1' }
|
||||
}
|
||||
return {
|
||||
getRepos: () => [repo],
|
||||
getRepo: () => repo,
|
||||
getAllWorktreeMeta: () => meta,
|
||||
getWorktreeMeta: (id: string) => meta[id],
|
||||
setWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeLineage: () => ({}),
|
||||
getProjectHostSetups: () => [],
|
||||
getSettings: () => ({})
|
||||
} as unknown as Store
|
||||
}
|
||||
|
||||
describe('SSH worktree catalog authority (#14004)', () => {
|
||||
let mux: MockMultiplexer
|
||||
let provider: SshGitProvider
|
||||
|
||||
beforeEach(() => {
|
||||
mux = createMockMux()
|
||||
provider = new SshGitProvider(CONNECTION_ID, mux as never)
|
||||
getSshGitProviderMock.mockReset()
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
})
|
||||
|
||||
it('refuses an empty relay response instead of publishing an empty catalog', async () => {
|
||||
// An older relay converted a failed `git worktree list` into `[]`; mixed versions are the normal state.
|
||||
mux.request.mockResolvedValue([])
|
||||
|
||||
await expect(provider.listWorktrees(REPO_PATH)).rejects.toSatisfy(
|
||||
isWorktreeCatalogUnavailableError
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a malformed relay response', async () => {
|
||||
mux.request.mockResolvedValue(undefined)
|
||||
|
||||
await expect(provider.listWorktrees(REPO_PATH)).rejects.toSatisfy(
|
||||
isWorktreeCatalogUnavailableError
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an unreachable SSH host as unavailable, not as an empty repo listing', async () => {
|
||||
getSshGitProviderMock.mockReturnValue(undefined)
|
||||
|
||||
await expect(listRepoWorktrees(repo)).rejects.toSatisfy(isWorktreeCatalogUnavailableError)
|
||||
})
|
||||
|
||||
it('does not authorize missing-worktree teardown when the relay listing fails', async () => {
|
||||
mux.request.mockRejectedValue(new Error('relay request failed'))
|
||||
|
||||
const result = await listDetectedWorktreesForCapturedRepo(
|
||||
createStore(),
|
||||
repo,
|
||||
() => true,
|
||||
provider
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' })
|
||||
// The persisted workspace survives the failed scan, so the renderer has nothing to reconcile away.
|
||||
expect(
|
||||
(result as { worktrees: { id: string }[] }).worktrees.map((worktree) => worktree.id)
|
||||
).toContain(worktreeId)
|
||||
})
|
||||
|
||||
it('does not authorize missing-worktree teardown when the relay answers with an empty list', async () => {
|
||||
mux.request.mockResolvedValue([])
|
||||
|
||||
const result = await listDetectedWorktreesForCapturedRepo(
|
||||
createStore(),
|
||||
repo,
|
||||
() => true,
|
||||
provider
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ authoritative: false, source: 'metadata-fallback' })
|
||||
expect(
|
||||
(result as { worktrees: { id: string }[] }).worktrees.map((worktree) => worktree.id)
|
||||
).toContain(worktreeId)
|
||||
})
|
||||
|
||||
it('republishes an authoritative catalog once the relay answers again', async () => {
|
||||
mux.request.mockResolvedValue([
|
||||
{ path: REPO_PATH, head: 'abc123', branch: 'main', isBare: false, isMainWorktree: true },
|
||||
{
|
||||
path: WORKTREE_PATH,
|
||||
head: 'def456',
|
||||
branch: 'feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await listDetectedWorktreesForCapturedRepo(
|
||||
createStore(),
|
||||
repo,
|
||||
() => true,
|
||||
provider
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ authoritative: true, source: 'git' })
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { listWorktreeGraph, listWorktrees, listWorktreesStrict } from './git/wor
|
||||
import { isFolderRepo } from '../shared/repo-kind'
|
||||
import { getSshGitProvider } from './providers/ssh-git-dispatch'
|
||||
import { areWorktreePathsEqual } from './ipc/worktree-logic'
|
||||
import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability'
|
||||
|
||||
type LocalRepoWorktreeListOptions = {
|
||||
wslDistro?: string
|
||||
@@ -42,10 +43,15 @@ export async function listRepoWorktrees(
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
// Why: runtime worktree resolution can run before SSH providers have
|
||||
// reattached during startup. Return empty instead of falling back to
|
||||
// local git against a server path.
|
||||
return provider ? await provider.listWorktrees(repo.path) : []
|
||||
// Why: runtime worktree resolution can run before SSH providers have reattached during startup.
|
||||
// Never fall back to local git against a server path, and never report the unreachable host as an
|
||||
// empty catalog (#14004) — callers treat a resolved listing as authoritative.
|
||||
if (!provider) {
|
||||
throw new WorktreeCatalogUnavailableError(
|
||||
`Worktree catalog unavailable for ${repo.path}: SSH connection "${repo.connectionId}" is not connected.`
|
||||
)
|
||||
}
|
||||
return await provider.listWorktrees(repo.path)
|
||||
}
|
||||
return hasLocalRepoWorktreeListOptions(options)
|
||||
? await listWorktrees(repo.path, options)
|
||||
|
||||
@@ -9,7 +9,8 @@ import type {
|
||||
SshTarget,
|
||||
SshTargetAddResult,
|
||||
SshTargetCreateInput,
|
||||
SshTargetUpdateInput
|
||||
SshTargetUpdateInput,
|
||||
SshTerminateSessionsResult
|
||||
} from '../../shared/ssh-types'
|
||||
import type { FilesystemPathFlavor } from '../../shared/filesystem-entry-types'
|
||||
|
||||
@@ -25,7 +26,7 @@ export type SshApi = {
|
||||
resolveConfigHost: (args: { alias: string }) => Promise<SshConfigHostResolution | null>
|
||||
connect: (args: { targetId: string }) => Promise<SshConnectionState | null>
|
||||
disconnect: (args: { targetId: string }) => Promise<void>
|
||||
terminateSessions: (args: { targetId: string }) => Promise<void>
|
||||
terminateSessions: (args: { targetId: string }) => Promise<SshTerminateSessionsResult>
|
||||
resetRelay: (args: { targetId: string }) => Promise<void>
|
||||
getState: (args: { targetId: string }) => Promise<SshConnectionState | null>
|
||||
needsPassphrasePrompt: (args: { targetId: string }) => Promise<boolean>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Issue #14004: a relay-side worktree-list failure must stay a failure across the relay/provider
|
||||
* boundary. Converting it to `[]` reports an unreadable catalog as an authoritative empty one, and
|
||||
* downstream reconciliation uses that to authorize missing-worktree teardown.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RelayContext } from './context'
|
||||
import { GitHandler } from './git-handler'
|
||||
import {
|
||||
createMockDispatcher,
|
||||
type MockDispatcher,
|
||||
type RelayDispatcher
|
||||
} from './git-handler-test-setup'
|
||||
|
||||
type GitSpyTarget = {
|
||||
git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }>
|
||||
}
|
||||
|
||||
const WORKTREE_LIST_OUTPUT = `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
`
|
||||
|
||||
/** Git <2.36 rejects `worktree list -z` with a usage error, which routes the handler to the fallback lane. */
|
||||
function unsupportedZError(): Error {
|
||||
return Object.assign(new Error('git usage error'), {
|
||||
code: 129,
|
||||
stderr: 'usage: git worktree list [<options>]\n'
|
||||
})
|
||||
}
|
||||
|
||||
describe('relay worktree-list authority (#14004)', () => {
|
||||
let dispatcher: MockDispatcher
|
||||
let handler: GitHandler
|
||||
|
||||
beforeEach(() => {
|
||||
dispatcher = createMockDispatcher()
|
||||
handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext())
|
||||
})
|
||||
|
||||
it('rejects instead of reporting an empty catalog when the fallback listing fails', async () => {
|
||||
vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args: string[]) =>
|
||||
args.includes('-z')
|
||||
? Promise.reject(unsupportedZError())
|
||||
: Promise.reject(
|
||||
Object.assign(new Error('fatal: not a git repository'), { code: 128, stderr: '' })
|
||||
)
|
||||
)
|
||||
|
||||
await expect(
|
||||
dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' })
|
||||
).rejects.toThrow('not a git repository')
|
||||
})
|
||||
|
||||
it('rejects a timed-out fallback listing on a host whose -z support is already known absent', async () => {
|
||||
const gitSpy = vi
|
||||
.spyOn(handler as unknown as GitSpyTarget, 'git')
|
||||
.mockImplementation((args: string[]) =>
|
||||
args.includes('-z')
|
||||
? Promise.reject(unsupportedZError())
|
||||
: Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' })
|
||||
)
|
||||
// Prime the capability cache so the probe is not repeated; later scans go straight to the fallback.
|
||||
await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' })
|
||||
|
||||
gitSpy.mockRejectedValue(Object.assign(new Error('ETIMEDOUT'), { code: 'ETIMEDOUT' }))
|
||||
|
||||
await expect(
|
||||
dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' })
|
||||
).rejects.toThrow('ETIMEDOUT')
|
||||
expect(gitSpy.mock.calls.at(-1)?.[0]).toEqual(['worktree', 'list', '--porcelain'])
|
||||
})
|
||||
|
||||
it('republishes the catalog when a later fallback listing succeeds', async () => {
|
||||
let failListing = true
|
||||
vi.spyOn(handler as unknown as GitSpyTarget, 'git').mockImplementation((args: string[]) => {
|
||||
if (args.includes('-z')) {
|
||||
return Promise.reject(unsupportedZError())
|
||||
}
|
||||
return failListing
|
||||
? Promise.reject(new Error('transient relay failure'))
|
||||
: Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' })
|
||||
})
|
||||
|
||||
await expect(
|
||||
dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' })
|
||||
).rejects.toThrow('transient relay failure')
|
||||
|
||||
failListing = false
|
||||
const result = (await dispatcher.callRequest('git.listWorktrees', {
|
||||
repoPath: '/repo'
|
||||
})) as Record<string, unknown>[]
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({ path: '/repo', isMainWorktree: true })
|
||||
})
|
||||
})
|
||||
@@ -132,19 +132,14 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext {
|
||||
},
|
||||
async () => {
|
||||
// Why: Git <2.36 lacks worktree-list `-z`, so fall back to the newline-block parser (loses newline-in-path safety).
|
||||
try {
|
||||
const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, {
|
||||
signal: context?.signal
|
||||
})
|
||||
const normalized = await this.normalizeMainWorktreePath(
|
||||
repoPath,
|
||||
parseWorktreeList(stdout)
|
||||
)
|
||||
// Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389).
|
||||
return annotatePrunableWorktreesByExistence(normalized)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
// Why no catch (#14004): swallowing to `[]` would report an unreadable catalog as an authoritative
|
||||
// empty one, and callers use that to authorize missing-worktree teardown. Let the failure propagate.
|
||||
const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, {
|
||||
signal: context?.signal
|
||||
})
|
||||
const normalized = await this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout))
|
||||
// Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389).
|
||||
return annotatePrunableWorktreesByExistence(normalized)
|
||||
},
|
||||
isUnsupportedWorktreeListZError
|
||||
)
|
||||
|
||||
@@ -6,7 +6,10 @@ import { useAppStore } from '@/store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { Button } from '../ui/button'
|
||||
import { removeSshTargetWithBestEffortCleanup } from './ssh-target-remove'
|
||||
import { terminateSshSessionsWithReconnect } from './ssh-session-termination'
|
||||
import {
|
||||
describeSshTerminateOutcome,
|
||||
terminateSshSessionsWithReconnect
|
||||
} from './ssh-session-termination'
|
||||
import { SshTargetCard } from './SshTargetCard'
|
||||
import { SshTargetDestructiveActions } from './SshTargetDestructiveActions'
|
||||
import { SshTargetForm, EMPTY_FORM, type EditingTarget } from './SshTargetForm'
|
||||
@@ -218,10 +221,8 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem
|
||||
|
||||
const handleTerminateSessions = async (targetId: string): Promise<void> => {
|
||||
try {
|
||||
await terminateSshSessionsWithReconnect(targetId)
|
||||
toast.success(
|
||||
translate('auto.components.settings.SshPane.90e308c98b', 'Remote terminals ended')
|
||||
)
|
||||
const report = describeSshTerminateOutcome(await terminateSshSessionsWithReconnect(targetId))
|
||||
toast[report.level](report.message)
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../../../shared/constants'
|
||||
import type { SshTerminateSessionsResult } from '../../../../shared/ssh-types'
|
||||
import { translate } from '../../i18n/i18n'
|
||||
|
||||
export async function terminateSshSessionsWithReconnect(targetId: string): Promise<void> {
|
||||
export async function terminateSshSessionsWithReconnect(
|
||||
targetId: string
|
||||
): Promise<SshTerminateSessionsResult> {
|
||||
try {
|
||||
await window.api.ssh.terminateSessions({ targetId })
|
||||
return await window.api.ssh.terminateSessions({ targetId })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (!message.includes(SSH_TERMINATE_RECONNECT_REQUIRED)) {
|
||||
@@ -11,6 +15,31 @@ export async function terminateSshSessionsWithReconnect(targetId: string): Promi
|
||||
// Why: disconnect is now non-destructive, so preserved remote PTYs may
|
||||
// require a fresh relay attachment before they can be explicitly killed.
|
||||
await window.api.ssh.connect({ targetId })
|
||||
await window.api.ssh.terminateSessions({ targetId })
|
||||
return await window.api.ssh.terminateSessions({ targetId })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An offline sweep only tears down local transport, so its remote shells are `unverifiable`, never
|
||||
* `exited` (docs/reference/ssh-execution-boundary.md). Reporting plain success there would announce
|
||||
* a kill nobody delivered (issue #12661).
|
||||
*/
|
||||
export function describeSshTerminateOutcome(outcome: SshTerminateSessionsResult): {
|
||||
level: 'success' | 'warning'
|
||||
message: string
|
||||
} {
|
||||
if (outcome.unverifiable > 0) {
|
||||
return {
|
||||
level: 'warning',
|
||||
message: translate(
|
||||
'auto.components.settings.SshPane.terminateUnverifiable',
|
||||
'{{terminals}} remote terminal(s) could not be reached. Reconnect to end them.',
|
||||
{ terminals: outcome.unverifiable }
|
||||
)
|
||||
}
|
||||
}
|
||||
return {
|
||||
level: 'success',
|
||||
message: translate('auto.components.settings.SshPane.90e308c98b', 'Remote terminals ended')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8265,7 +8265,8 @@
|
||||
"4db9afce1c": "Port must be between 1 and 65535",
|
||||
"0e5aa04161": "Host or SSH config alias is required",
|
||||
"f1fc50dad2": "Failed to load SSH targets",
|
||||
"0cda732f43": "Connection test failed"
|
||||
"0cda732f43": "Connection test failed",
|
||||
"terminateUnverifiable": "{{terminals}} remote terminal(s) could not be reached. Reconnect to end them."
|
||||
},
|
||||
"SshPassphraseDialog": {
|
||||
"d5a234456f": "Cancel",
|
||||
|
||||
@@ -144,7 +144,7 @@ export function createSshApi(): NonNullable<Partial<PreloadApi>['ssh']> {
|
||||
return state
|
||||
},
|
||||
disconnect: () => Promise.resolve(),
|
||||
terminateSessions: () => Promise.resolve(),
|
||||
terminateSessions: () => Promise.resolve({ terminated: 0, unverifiable: 0 }),
|
||||
resetRelay: () => Promise.resolve(),
|
||||
getState: async (args) => {
|
||||
if (!requireActiveEnvironmentOrNull()) {
|
||||
|
||||
@@ -266,3 +266,13 @@ export type EnrichedDetectedPort = DetectedPort & {
|
||||
advertisedUrl?: string
|
||||
advertisedProtocol?: 'http' | 'https'
|
||||
}
|
||||
|
||||
/** Outcome of `ssh:terminateSessions`. Uses the fixed verdict vocabulary from
|
||||
* docs/reference/ssh-execution-boundary.md: a host we could not reach yields `unverifiable`,
|
||||
* never `exited`, so an offline sweep can never be read as a successful remote kill (issue #12661). */
|
||||
export type SshTerminateSessionsResult = {
|
||||
/** Remote PTYs the host acknowledged stopping. */
|
||||
terminated: number
|
||||
/** Leases whose remote shells were never reached because the relay was offline. */
|
||||
unverifiable: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* "I could not ask" is not "there is nothing there".
|
||||
*
|
||||
* A worktree catalog that could not be read must stay distinguishable from one that
|
||||
* genuinely lists no worktrees, or downstream reconciliation converts a transport or
|
||||
* Git failure into authoritative emptiness and tears down live state
|
||||
* (docs/reference/ssh-execution-boundary.md, issue #14004).
|
||||
*/
|
||||
export class WorktreeCatalogUnavailableError extends Error {
|
||||
/** Structural marker: survives JSON-RPC re-wrapping better than `instanceof` across module copies. */
|
||||
readonly worktreeCatalogUnavailable = true
|
||||
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options)
|
||||
this.name = 'WorktreeCatalogUnavailableError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isWorktreeCatalogUnavailableError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof WorktreeCatalogUnavailableError ||
|
||||
(typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(error as { worktreeCatalogUnavailable?: unknown }).worktreeCatalogUnavailable === true)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Git always lists at least the repository's own checkout, so a zero-row listing for a Git repo
|
||||
* can only mean the scan never produced an answer. Older relays converted worktree-list failures
|
||||
* into `[]`, so a mixed-version client must reject that shape rather than publish it.
|
||||
*/
|
||||
export function assertAuthoritativeWorktreeCatalog<T>(worktrees: unknown, repoPath: string): T[] {
|
||||
if (!Array.isArray(worktrees) || worktrees.length === 0) {
|
||||
throw new WorktreeCatalogUnavailableError(
|
||||
`Worktree catalog unavailable for ${repoPath}: the execution host returned no worktree listing. ` +
|
||||
'Treating this as an empty catalog would authorize removing workspaces that still exist.'
|
||||
)
|
||||
}
|
||||
return worktrees as T[]
|
||||
}
|
||||
Reference in New Issue
Block a user