From 893c4e24a4db18c34422d9ce67e3032c4683e233 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:18:04 -0400 Subject: [PATCH] fix(ssh): an old SSH host moves to its managed server while a relay shell named only on its tab still runs (#31093) * fix(ssh): an old SSH host converts while a relay shell named only on its tab still runs The conversion check counted legacy pane-key aliases as terminals waiting to reconnect. An alias only routes hook events to a pane whose terminal the lease check, the bridge or the terminal gate already accounts for, so it no longer blocks the move. * Tighten the alias census comment * Register the tab-only relay spec: typed deployment lookup, relay fence baseline, PR route --------- Co-authored-by: E2E Test --- config/legacy-relay-compat-tags-baseline.txt | 1 + config/scripts/pr-e2e-source-routing.mjs | 7 +- .../orcad-source-dependency-census.test.ts | 40 +++ .../orcad-source-dependency-census.ts | 10 +- tests/e2e/helpers/legacy-relay-docker-host.ts | 14 +- .../ssh-orcad-relay-tab-only-shell.spec.ts | 236 ++++++++++++++++++ 6 files changed, 294 insertions(+), 14 deletions(-) create mode 100644 tests/e2e/ssh-orcad-relay-tab-only-shell.spec.ts diff --git a/config/legacy-relay-compat-tags-baseline.txt b/config/legacy-relay-compat-tags-baseline.txt index 16a3bfc3fee..81505f141a7 100644 --- a/config/legacy-relay-compat-tags-baseline.txt +++ b/config/legacy-relay-compat-tags-baseline.txt @@ -40,3 +40,4 @@ tests/e2e/helpers/build-released-relay-bundle.mts tests/e2e/helpers/legacy-relay-docker-host.ts tests/e2e/helpers/orcad-upgrade-profile.ts tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts +tests/e2e/ssh-orcad-relay-tab-only-shell.spec.ts diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index c6d4057af4b..249e99b4a5d 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -136,14 +136,17 @@ export const PR_E2E_SOURCE_ROUTES = [ // COMPAT(legacy-relay-bridge): revisit 2027-01-15 with src/main/ssh/legacy-relay/. { id: 'ssh.orcad-legacy-relay-bridge', - specs: ['tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts'], + specs: [ + 'tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts', + 'tests/e2e/ssh-orcad-relay-tab-only-shell.spec.ts' + ], matches: (file) => MANAGED_SSH_HARNESS.test(file) || /^tests\/e2e\/(?:helpers\/(?:legacy-relay-docker-host|build-released-relay-bundle)|cross-version-wire\/released-relay-(?:daemon|transport))\.m?ts$/.test( file ) || (isProductSource(file) && - /(?:legacy-relay(?:-bridge|\/)|orcad-migration-terminal-gate|ssh-host-relay-terminals-on-connect)/.test( + /(?:legacy-relay(?:-bridge|\/)|orcad-migration-terminal-gate|ssh-host-relay-terminals-on-connect|orcad-source-dependency-census)/.test( file )) }, diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts index 52c4030f72d..df4d29be8a4 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.test.ts @@ -134,4 +134,44 @@ describe('orcad migration source dependency census', () => { counts: { 'terminal-lease': 1 } }) }) + + // A relay-era tab named its shell only on the tab; loading wrote one legacy key alias per leaf. + it('does not count legacy pane-key aliases of a tab-only relay shell as terminals to reconnect', () => { + const state = getDefaultPersistedState('/home/test') + state.sshTargets = [TARGET] + state.repos = [REPO] + const worktreeId = `${REPO.id}::/srv/repo` + const tabId = 'tab-relay' + const stablePaneKey = `${tabId}:11111111-1111-4111-8111-111111111111` + state.workspaceSessionsByHostId = { + [`ssh:${TARGET.id}`]: { + ...state.workspaceSession, + tabsByWorktree: { + [worktreeId]: [ + { + id: tabId, + ptyId: 'pty-relay', + worktreeId, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + remoteSessionIdsByTabId: { [tabId]: 'pty-relay' } + } + } + state.legacyPaneKeyAliasEntries = [ + { legacyPaneKey: `${tabId}:0`, stablePaneKey, ptyId: 'pty-relay', updatedAt: 1 }, + { legacyPaneKey: `${tabId}:1`, stablePaneKey, ptyId: 'pty-relay', updatedAt: 1 } + ] + + expect( + collectOrcadMigrationUntransferredDependencyCensus(state, MANIFEST).counts[ + 'terminal-recovery' + ] + ).toBe(0) + }) }) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts index f162300e621..e0b2d012d9d 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-dependency-census.ts @@ -75,22 +75,18 @@ export function collectOrcadMigrationUntransferredDependencyCensus( } // Consumer recoveries never block: the terminal gate proves before every move that their leases exited. +// Legacy pane-key aliases never block either: an alias only routes hook events and names a terminal +// the tab or pane already records, which the lease checks and the terminal gate govern. function countTerminalRecoveryState( state: PersistedState, scope: OrcadMigrationSourceScope, sessions: OrcadMigrationSourceSessionInspection ): number { - const unsupported = state.migrationUnsupportedPtyEntries.filter( + return state.migrationUnsupportedPtyEntries.filter( (entry) => orcadMigrationOwnerMatchesScope(entry.worktreeId, scope) || (entry.tabId ? sessions.tabIds.has(entry.tabId) : false) || sessions.ptyIds.has(entry.ptyId) || (entry.paneKey ? paneBelongsToTabs(entry.paneKey, sessions.tabIds) : false) ).length - const aliases = state.legacyPaneKeyAliasEntries.filter( - (entry) => - paneBelongsToTabs(entry.legacyPaneKey, sessions.tabIds) || - paneBelongsToTabs(entry.stablePaneKey, sessions.tabIds) - ).length - return unsupported + aliases } diff --git a/tests/e2e/helpers/legacy-relay-docker-host.ts b/tests/e2e/helpers/legacy-relay-docker-host.ts index 0d1244031b4..39c8c9ea89e 100644 --- a/tests/e2e/helpers/legacy-relay-docker-host.ts +++ b/tests/e2e/helpers/legacy-relay-docker-host.ts @@ -16,8 +16,6 @@ import { } from '../cross-version-wire/released-relay-transport' import { execDockerSshHostTargetCommand, type DockerSshHostTarget } from './docker-ssh-host-target' -const NODE_PTY_SLOT = 'linux-x64-glibc' - export type DockerReleasedRelay = { dir: string; version: string } export type DockerRelayInstance = { sockPath: string; relayPtyId: string } @@ -54,11 +52,17 @@ export async function installReleasedRelayOnDockerHost( const dir = `/root/.orca-remote/relay-${version}` const staging = mkdtempSync(path.join(os.tmpdir(), 'orca-legacy-relay-')) try { + // Why the host's arch: a Docker host on an arm64 machine runs arm64 containers. + const arch = execDockerSshHostTargetCommand(target, 'uname -m') === 'aarch64' ? 'arm64' : 'x64' const pty = path.join(staging, 'node_modules', 'node-pty') cpSync(path.join(orcadTemplateDir, 'node_modules', 'node-pty'), pty, { recursive: true }) - cpSync(path.join(orcadTemplateDir, 'targets', NODE_PTY_SLOT, 'node_modules', 'node-pty'), pty, { - recursive: true - }) + cpSync( + path.join(orcadTemplateDir, 'targets', `linux-${arch}-glibc`, 'node_modules', 'node-pty'), + pty, + { + recursive: true + } + ) // Why dereference: the bundle is a real file here, never the checkout's symlink. cpSync(path.join(bundleDir, 'relay.js'), path.join(staging, 'relay.js'), { dereference: true }) execDockerSshHostTargetCommand(target, `mkdir -p ${quotePosixShell(dir)}`) diff --git a/tests/e2e/ssh-orcad-relay-tab-only-shell.spec.ts b/tests/e2e/ssh-orcad-relay-tab-only-shell.spec.ts new file mode 100644 index 00000000000..879310f0e9a --- /dev/null +++ b/tests/e2e/ssh-orcad-relay-tab-only-shell.spec.ts @@ -0,0 +1,236 @@ +/** + * An SSH host whose relay-era terminal named its shell only on the tab still converts while that + * shell runs, on a real Docker SSH host, and the old tab keeps the same shell. + * + * A 1.4.224 relay runs detached with a live shell for this desktop's target. The profile names that + * shell on the tab (row ptyId and relay session map) with no per-pane binding, as an older build + * left it at quit. Loading such a tab records legacy pane-key aliases for its leaf; those name no + * extra terminal, so the move must not refuse with "terminals waiting to reconnect". + * + * COMPAT(legacy-relay-bridge): revisit 2027-01-15, with src/main/ssh/legacy-relay/. + */ +import { writeFileSync } from 'node:fs' +import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { managedServer } from './helpers/orcad-convert-flow' +import { seedRelayEraProfile } from './helpers/orcad-upgrade-profile' +import { mutateStoppedProfileState } from './helpers/persisted-profile-state' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../src/shared/execution-host' +import { toAppSshPtyId } from '../../src/shared/ssh-pty-id' +import { getOrcadSshDeployment } from '../../src/shared/runtime-environments' +import { + cleanupDockerSshHostTarget, + DOCKER_SSH_HOST_REMOTE_REPO_PATH, + startDockerSshHostTarget, + type DockerSshHostTarget +} from './helpers/docker-ssh-host-target' +import { + installReleasedRelayOnDockerHost, + listDockerRelayPtys, + startDockerRelayWithShell +} from './helpers/legacy-relay-docker-host' +import { expectTerminalAccessibilityText } from './helpers/terminal-accessibility-tree' +import { focusActiveTerminalInput } from './helpers/terminal' + +const TEMPLATE_SOURCE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE +const BRIDGE_TIMEOUT_MS = 4 * 60_000 + +test.skip( + process.env.ORCA_E2E_ORCAD_CONVERT_HOST !== 'docker' || !TEMPLATE_SOURCE, + 'Set ORCA_E2E_ORCAD_CONVERT_HOST=docker and ORCA_E2E_ORCAD_CONVERT_TEMPLATE' +) + +let host: DockerSshHostTarget | null = null + +test.afterEach(() => { + cleanupDockerSshHostTarget(host) + host = null +}) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function recordAt(parent: Record, key: string): Record { + const value = parent[key] + if (!isRecord(value)) { + throw new Error(`The seeded profile has no ${key}`) + } + return value +} + +/** As an older build left a live relay tab at quit: the shell named on the tab, no pane binding. */ +function nameRelayShellOnTabOnly( + userDataDir: string, + hostId: string, + worktreeId: string, + tabId: string, + ptyId: string +): void { + mutateStoppedProfileState(userDataDir, (state) => { + const session = recordAt(recordAt(state, 'workspaceSessionsByHostId'), hostId) + delete recordAt(recordAt(session, 'terminalLayoutsByTabId'), tabId).ptyIdsByLeafId + session.remoteSessionIdsByTabId = { [tabId]: ptyId } + session.activeWorktreeIdsOnShutdown = [worktreeId] + }) +} + +function forwardSshLogs(chunk: string): void { + if (chunk.includes('[ssh]') || chunk.includes('orcad')) { + process.stderr.write(chunk) + } +} + +function bridgedPanes(page: Page): Promise<{ ptyId: string; state: string }[]> { + return page.evaluate(async () => (await window.api.legacyRelayBridge?.listPanes()) ?? []) +} + +/** The client mirrors the server's tab as `web-terminal-`; returns its id and key. */ +async function mirroredTab(page: Page, tabId: string): Promise<{ id: string; key: string }> { + let found = { id: '', key: '' } + await expect + .poll( + async () => { + found = await page.evaluate((wanted) => { + for (const [key, tabs] of Object.entries(window.__store!.getState().tabsByWorktree)) { + for (const tab of tabs ?? []) { + const named = tab.id.startsWith('web-terminal-') + ? decodeURIComponent(tab.id.slice('web-terminal-'.length)) + : '' + if (named === wanted || named.startsWith(`${wanted}::`)) { + return { id: tab.id, key } + } + } + } + return { id: '', key: '' } + }, tabId) + return found.id + }, + { timeout: 120_000, message: 'the client never mirrored the relay-era tab' } + ) + .not.toBe('') + return found +} + +test('an SSH host converts while a relay shell named only on its tab runs, and the tab keeps it', async ({ + testRepoPath +}, testInfo: TestInfo) => { + test.setTimeout(20 * 60_000) + host = startDockerSshHostTarget(testInfo) + const targetId = `ssh-tab-only-${Date.now()}` + const relay = await installReleasedRelayOnDockerHost(host, TEMPLATE_SOURCE!) + const ours = await startDockerRelayWithShell(host, relay, targetId, 'before-move') + const bridgedPtyId = toAppSshPtyId(targetId, ours.relayPtyId) + const sshHostId = toSshExecutionHostId(targetId) + const record: Record = { relayPtyId: ours.relayPtyId, bridgedPtyId } + + const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE_SOURCE! }) + let app: ElectronApplication | null = null + try { + // The first launch only establishes the profile, with a local project to land on. + const first = await session.launch() + app = first.app + await waitForSessionReady(first.page) + await attachRepoAndOpenTerminal(first.page, testRepoPath) + await first.page.waitForTimeout(2_000) + await session.close(app) + app = null + const seeded = seedRelayEraProfile( + session.userDataDir, + { + label: `tab-only relay E2E ${Date.now()}`, + host: host.host, + port: host.port, + username: 'root', + identityFile: host.identityFile, + identitiesOnly: true, + relayGracePeriodSeconds: 1 + }, + { repoPath: DOCKER_SSH_HOST_REMOTE_REPO_PATH, folderPath: '/tmp' }, + { targetId, relayPtyId: ours.relayPtyId } + ) + const terminalTabId = seeded.terminalTabId! + nameRelayShellOnTabOnly( + session.userDataDir, + sshHostId, + seeded.worktreeId, + terminalTabId, + bridgedPtyId + ) + + // Opening the old tab dials the host, which converts while its relay shell runs. + const launched = await session.launch({ onStderr: forwardSshLogs }) + app = launched.app + const page = launched.page + await waitForSessionReady(page) + await page.evaluate( + ({ worktreeId, hostId, tabId }) => { + const state = window.__store!.getState() + state.setActiveView('terminal') + state.setActiveWorktree(worktreeId, hostId) + state.setActiveTab(tabId) + state.setActiveTabType('terminal', worktreeId) + }, + { worktreeId: seeded.worktreeId, hostId: sshHostId, tabId: terminalTabId } + ) + await expect + .poll(async () => (record.managedServer = await managedServer(page, targetId)), { + timeout: 3 * 60_000 + }) + .toMatchObject({ kind: 'managed' }) + await expect + .poll(() => bridgedPanes(page), { timeout: BRIDGE_TIMEOUT_MS }) + .toEqual([expect.objectContaining({ ptyId: bridgedPtyId, state: 'live' })]) + const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( + (entry) => getOrcadSshDeployment(entry)?.sshTargetId === targetId + ) + if (!environment) { + throw new Error('Converted host is missing from the environment catalog') + } + const runtimeHostId = toRuntimeExecutionHostId(environment.id) + await page.evaluate( + ({ worktreeId, hostId }) => { + const state = window.__store!.getState() + state.setGroupBy('none') + window.__store!.setState({ visibleWorkspaceHostIds: ['local', hostId] }) + state.setActiveView('terminal') + state.setActiveWorktree(worktreeId, hostId) + }, + { worktreeId: seeded.worktreeId, hostId: runtimeHostId } + ) + const mirrored = await mirroredTab(page, terminalTabId) + await page.evaluate( + ({ worktreeId, hostId, tabId }) => { + const state = window.__store!.getState() + state.setActiveWorktree(worktreeId, hostId) + state.setActiveTab(tabId) + state.setActiveTabType('terminal', worktreeId) + }, + { worktreeId: mirrored.key, hostId: runtimeHostId, tabId: mirrored.id } + ) + await expect + .poll(() => page.evaluate((id) => window.__paneManagers?.has(id) ?? false, mirrored.id), { + timeout: 120_000, + message: 'the relay terminal tab never mounted after the move' + }) + .toBe(true) + // The same shell: its output from before the move is on screen, and it still answers. + await expectTerminalAccessibilityText(page, mirrored.id, 'GOT:before-move', BRIDGE_TIMEOUT_MS) + await focusActiveTerminalInput(page) + await page.keyboard.type('after-move', { delay: 20 }) + await page.keyboard.press('Enter') + await expectTerminalAccessibilityText(page, mirrored.id, 'GOT:after-move') + await page.screenshot({ path: testInfo.outputPath('tab-only-shell-after-move.png') }) + // Never a second shell: the relay still runs exactly the one it started with. + expect(await listDockerRelayPtys(host, relay, ours.sockPath)).toEqual([ours.relayPtyId]) + record.bridged = await bridgedPanes(page) + } finally { + writeFileSync(testInfo.outputPath('tab-only-shell.json'), JSON.stringify(record, null, 2)) + if (app) { + await session.close(app) + } + await session.dispose() + } +})