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 <e2e@test.local>
This commit is contained in:
Jinwoo Hong
2026-10-20 14:18:04 -04:00
committed by GitHub
co-authored by E2E Test
parent 6c7a6ddd54
commit 893c4e24a4
6 changed files with 294 additions and 14 deletions
@@ -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
+5 -2
View File
@@ -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
))
},
@@ -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)
})
})
@@ -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
}
@@ -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)}`)
@@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function recordAt(parent: Record<string, unknown>, key: string): Record<string, unknown> {
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-<tab::leaf>`; 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<string, unknown> = { 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()
}
})