fix(ssh): keep a terminal the previous Orca version's relay still runs instead of replacing it (#25124)

After an app update the new relay answers "not found" for a PTY the previous build's relay still
runs, because the old relay refuses this build's handshake. The client read that as absence: it
expired the lease and the pane cold-restored into an empty shell while the user's shell kept
running, unreachable. Each deploy now takes a census of this target's older relay endpoints; while
one is live or unverifiable, a not-found reattach keeps the lease and the pane binding, and the
pane says the terminal is still running under the previous Orca version. A detached lease also
keeps blocking managed-server conversion until that terminal exits.

The cross-version harness now extracts src/relay, and a new test drives v1.4.218's relay socket and
grace lifecycle with this build's endpoint probe: the probe leaves no grace deadline and reads the
old relay as live work.

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-03 21:16:41 -07:00
committed by GitHub
co-authored by m4air
parent 6c2acc013e
commit f1303dfd17
17 changed files with 771 additions and 3 deletions
+12
View File
@@ -8,6 +8,9 @@ export const SSH_PTY_IDENTITY_MISMATCH_ERROR = 'SSH_PTY_IDENTITY_MISMATCH'
* reply is host evidence of the opposite).
*/
export const SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR = 'SSH_PTY_SOURCE_RESTORE_REQUIRED'
/** The id is unknown to this relay while an older build's relay for the target is still live.
* Deliberately not `SSH_SESSION_EXPIRED`, so the pane keeps its binding instead of respawning. */
export const SSH_PTY_HELD_BY_PREVIOUS_RELAY_ERROR = 'SSH_PTY_HELD_BY_PREVIOUS_RELAY'
export function isSshPtyNotFoundError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
@@ -65,3 +68,12 @@ export class SshPtyProvenExitedOnRelayError extends SshPtyAbsentFromRelayError {
export function isSshPtyProvenExitedOnRelayError(error: unknown): boolean {
return error instanceof SshPtyProvenExitedOnRelayError
}
/** Raised in place of {@link SshPtyAbsentFromRelayError} while an older build's relay for the
* target may still run the PTY, so neither the lease nor the pane binding is retired. */
export class SshPtyHeldByPreviousRelayError extends Error {
constructor(relayPtyId: string) {
super(`${SSH_PTY_HELD_BY_PREVIOUS_RELAY_ERROR}: ${relayPtyId}`)
this.name = 'SshPtyHeldByPreviousRelayError'
}
}
@@ -0,0 +1,89 @@
// After an app update the previous build's relay can still run a pane's PTY, and the new relay
// answers "not found" for an id it never minted. That answer must not reach the pane as
// `SSH_SESSION_EXPIRED`: the renderer cold-restores on that token, and spawn-execute expires the
// lease, so the user's running terminal would be silently replaced by an empty shell.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { PTY_ATTACH_PROVEN_EXITED_MARKER } from '../../shared/pty-attach-absence-evidence'
import {
isSshPtyAbsentFromRelayError,
SSH_PTY_HELD_BY_PREVIOUS_RELAY_ERROR,
SSH_SESSION_EXPIRED_ERROR,
SshPtyHeldByPreviousRelayError
} from './ssh-pty-errors'
const { previousRelayMayHoldTerminals } = vi.hoisted(() => ({
previousRelayMayHoldTerminals: vi.fn()
}))
vi.mock('../ssh/ssh-previous-relay-terminals', () => ({ previousRelayMayHoldTerminals }))
import { reattachSshPtySessionForSpawn } from './ssh-pty-session-reattach'
import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race'
const CONNECTION = 'conn-1'
const SESSION = 'pty2:old-epoch:1'
function refusingMux(message: string): SshChannelMultiplexer {
// Only `request` is reached on this path; the untyped base keeps the stub free of a cast.
return Object.assign(Object.create(null), {
request: vi.fn(async () => {
throw new Error(message)
})
})
}
async function refusalFrom(message: string): Promise<Error> {
try {
await reattachSshPtySessionForSpawn({
mux: refusingMux(message),
connectionId: CONNECTION,
sessionId: SESSION,
options: { cols: 80, rows: 24 },
exitRaceTracker: new SshPtySpawnExitRaceTracker(),
acceptLivePty: () => {}
})
} catch (error) {
if (error instanceof Error) {
return error
}
throw error
}
throw new Error('expected the reattach to be refused')
}
describe('a not-found reattach while an older Orca relay is live on the host', () => {
beforeEach(() => {
previousRelayMayHoldTerminals.mockReset()
})
it('keeps the pane bound instead of reporting the session expired', async () => {
previousRelayMayHoldTerminals.mockResolvedValue(true)
const error = await refusalFrom(`PTY "${SESSION}" not found`)
expect(error).toBeInstanceOf(SshPtyHeldByPreviousRelayError)
expect(error.message).toContain(SSH_PTY_HELD_BY_PREVIOUS_RELAY_ERROR)
expect(error.message).not.toContain(SSH_SESSION_EXPIRED_ERROR)
expect(isSshPtyAbsentFromRelayError(error)).toBe(false)
expect(previousRelayMayHoldTerminals).toHaveBeenCalledWith(CONNECTION)
})
it('reports absence as before when no older relay may hold it', async () => {
previousRelayMayHoldTerminals.mockResolvedValue(false)
const error = await refusalFrom(`PTY "${SESSION}" not found`)
expect(isSshPtyAbsentFromRelayError(error)).toBe(true)
})
it('does not hold an id the current relay proved exited', async () => {
previousRelayMayHoldTerminals.mockResolvedValue(true)
const error = await refusalFrom(
`PTY "${SESSION}" not found (${PTY_ATTACH_PROVEN_EXITED_MARKER})`
)
expect(isSshPtyAbsentFromRelayError(error)).toBe(true)
expect(previousRelayMayHoldTerminals).not.toHaveBeenCalled()
})
})
@@ -5,11 +5,13 @@ import {
SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR,
SSH_SESSION_EXPIRED_ERROR,
SshPtyAbsentFromRelayError,
SshPtyHeldByPreviousRelayError,
SshPtyProvenExitedOnRelayError,
isSshPtyIdentityMismatchError,
isSshPtyNotFoundError
} from './ssh-pty-errors'
import { isProvenExitedPtyAttachRefusal } from '../../shared/pty-attach-absence-evidence'
import { previousRelayMayHoldTerminals } from '../ssh/ssh-previous-relay-terminals'
import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id'
import type { PtySpawnOptions, PtySpawnResult } from './types'
import type { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race'
@@ -247,6 +249,9 @@ export async function reattachSshPtySession(args: {
if (isProvenExitedPtyAttachRefusal(error)) {
throw new SshPtyProvenExitedOnRelayError(`${SSH_SESSION_EXPIRED_ERROR}: ${relaySessionId}`)
}
if (await previousRelayMayHoldTerminals(args.connectionId)) {
throw new SshPtyHeldByPreviousRelayError(relaySessionId)
}
throw new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: ${relaySessionId}`)
}
throw error
@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshConnection } from './ssh-connection'
import type { RelayEndpointIncumbent } from './ssh-relay-endpoint-incumbent'
import { getRemoteHostPlatform } from './ssh-remote-platform'
const { execCommand, probeRelayEndpointIncumbent } = vi.hoisted(() => ({
execCommand: vi.fn(),
probeRelayEndpointIncumbent: vi.fn()
}))
vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand }))
vi.mock('./ssh-relay-endpoint-incumbent', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
probeRelayEndpointIncumbent
}))
import {
censusPreviousRelays,
clearPreviousRelayCensus,
isReattachHeldByPreviousRelay,
mayHoldTerminals,
startPreviousRelayCensus
} from './ssh-previous-relay-terminals'
// The census only hands the connection to the mocked exec and probe.
const conn: SshConnection = Object.create(null)
const OLD_SOCK = '/home/dev/.orca-remote/relay-0.1.0+old/relay-abc.sock'
const deployed = {
hostPlatform: getRemoteHostPlatform('linux-x64'),
remoteHome: '/home/dev',
remoteRelayDir: '/home/dev/.orca-remote/relay-0.1.0+new',
nodePath: '/usr/bin/node',
sockPath: '/home/dev/.orca-remote/relay-0.1.0+new/relay-abc.sock'
}
function incumbent(overrides: Partial<RelayEndpointIncumbent>): RelayEndpointIncumbent {
return {
sockPath: OLD_SOCK,
verdict: 'live',
evidence: 'accepted-connection',
socketPresent: true,
holders: [],
holdersEnumerable: false,
...overrides
}
}
const notFound = new Error('PTY "pty2:old-epoch:1" not found')
describe('previous relay terminals', () => {
beforeEach(() => {
execCommand.mockReset()
probeRelayEndpointIncumbent.mockReset()
clearPreviousRelayCensus('target-1')
})
it('treats a live or unverifiable older relay as possibly holding terminals', () => {
expect(mayHoldTerminals(incumbent({}))).toBe(true)
expect(mayHoldTerminals(incumbent({ verdict: 'unverifiable' }))).toBe(true)
expect(mayHoldTerminals(incumbent({ verdict: 'exited', socketPresent: false }))).toBe(false)
})
it('does not hold for an older relay proven to hold nothing', () => {
const husk = incumbent({
holdersEnumerable: true,
holders: [{ pid: 42, matchesRelayArgv: true, childCount: 0, unrecognizedChildCount: 0 }]
})
expect(mayHoldTerminals(husk)).toBe(false)
})
it('lists older endpoints for this target only, excluding the relay just launched', async () => {
execCommand.mockResolvedValue(`${OLD_SOCK}\n`)
probeRelayEndpointIncumbent.mockResolvedValue(incumbent({}))
await expect(censusPreviousRelays(conn, 'target-1', deployed)).resolves.toBe(true)
const listing = execCommand.mock.calls[0][1]
expect(listing).toContain("current='/home/dev/.orca-remote/relay-0.1.0+new'")
expect(probeRelayEndpointIncumbent).toHaveBeenCalledWith(
conn,
deployed.hostPlatform,
'/usr/bin/node',
OLD_SOCK
)
})
it('finds nothing to hold on a host with no older relay', async () => {
execCommand.mockResolvedValue('')
await expect(censusPreviousRelays(conn, 'target-1', deployed)).resolves.toBe(false)
expect(probeRelayEndpointIncumbent).not.toHaveBeenCalled()
})
it('leaves Windows hosts on the existing path', async () => {
await expect(
censusPreviousRelays(conn, 'target-1', {
...deployed,
hostPlatform: getRemoteHostPlatform('win32-x64')
})
).resolves.toBe(false)
expect(execCommand).not.toHaveBeenCalled()
})
it('holds a not-found reattach while an older relay may run it', async () => {
execCommand.mockResolvedValue(`${OLD_SOCK}\n`)
probeRelayEndpointIncumbent.mockResolvedValue(incumbent({}))
startPreviousRelayCensus(conn, 'target-1', deployed)
await expect(isReattachHeldByPreviousRelay('target-1', notFound)).resolves.toBe(true)
})
it('does not hold a refusal that is not a plain not-found', async () => {
execCommand.mockResolvedValue(`${OLD_SOCK}\n`)
probeRelayEndpointIncumbent.mockResolvedValue(incumbent({}))
startPreviousRelayCensus(conn, 'target-1', deployed)
const mismatch = new Error('PTY "pty2:old-epoch:1" not found (identity mismatch)')
await expect(isReattachHeldByPreviousRelay('target-1', mismatch)).resolves.toBe(false)
await expect(
isReattachHeldByPreviousRelay('target-1', new Error('Request timed out'))
).resolves.toBe(false)
})
it('keeps the existing path when the census could not run or never started', async () => {
await expect(isReattachHeldByPreviousRelay('target-1', notFound)).resolves.toBe(false)
execCommand.mockRejectedValue(new Error('channel closed'))
startPreviousRelayCensus(conn, 'target-1', deployed)
await expect(isReattachHeldByPreviousRelay('target-1', notFound)).resolves.toBe(false)
})
})
@@ -0,0 +1,118 @@
/**
* Whether a relay from an earlier Orca build may still run this target's terminals.
*
* An app update installs a new relay beside the old one, and the old one refuses this build's
* handshake, so a PTY it owns answers "not found" from the new relay while it keeps running. That
* answer is the union "never minted here" — not absence — so while an older relay endpoint for this
* target is live or unverifiable, a not-found reattach must not retire the lease or respawn the pane
* (docs/reference/ssh-execution-boundary.md). Asked once per deploy, before reattach can need it.
*/
import { isProvenExitedPtyAttachRefusal } from '../../shared/pty-attach-absence-evidence'
import { isSshPtyIdentityMismatchError, isSshPtyNotFoundError } from '../providers/ssh-pty-errors'
import type { SshConnection } from './ssh-connection'
import { execCommand } from './ssh-relay-deploy-helpers'
import { SHORT_RELAY_SOCKET_DIR_PREFIX } from './relay-socket-path-limit'
import {
isReapableRelayHusk,
probeRelayEndpointIncumbent,
type RelayEndpointIncumbent
} from './ssh-relay-endpoint-incumbent'
import { relaySocketNameForInstanceId } from './ssh-relay-instance-id'
import { supersededRelayEndpointListCommand } from './ssh-relay-superseded-endpoints'
import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform'
export type PreviousRelayCensusInput = {
hostPlatform?: RemoteHostPlatform
remoteHome?: string
remoteRelayDir?: string
nodePath?: string
sockPath?: string
}
const MAX_CENSUS_ENDPOINTS = 32
const censusByTarget = new Map<string, Promise<boolean>>()
/** An older relay that holds nothing, or is gone, cannot be running this target's terminals. */
export function mayHoldTerminals(incumbent: RelayEndpointIncumbent): boolean {
return incumbent.verdict !== 'exited' && !isReapableRelayHusk(incumbent)
}
export async function censusPreviousRelays(
conn: SshConnection,
targetId: string,
input: PreviousRelayCensusInput
): Promise<boolean> {
const { hostPlatform, remoteHome, remoteRelayDir, nodePath, sockPath } = input
// Windows pipes are not enumerable (see the superseded sweep), so those hosts keep today's path.
if (!hostPlatform || isWindowsRemoteHost(hostPlatform) || !remoteHome || !remoteRelayDir) {
return false
}
if (!nodePath) {
return false
}
const listing = await execCommand(
conn,
supersededRelayEndpointListCommand({
remoteHome,
currentRelayDir: remoteRelayDir,
sockName: relaySocketNameForInstanceId(targetId),
...(sockPath?.startsWith(SHORT_RELAY_SOCKET_DIR_PREFIX)
? { currentShortSocketDir: sockPath.slice(0, sockPath.lastIndexOf('/')) }
: {})
}),
{ wrapCommand: true }
)
const sockPaths = listing
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('/'))
.slice(0, MAX_CENSUS_ENDPOINTS)
for (const endpoint of sockPaths) {
const incumbent = await probeRelayEndpointIncumbent(conn, hostPlatform, nodePath, endpoint)
if (mayHoldTerminals(incumbent)) {
return true
}
}
return false
}
/** Starts this deploy's census; a newer deploy for the target replaces it. */
export function startPreviousRelayCensus(
conn: SshConnection,
targetId: string,
input: PreviousRelayCensusInput
): void {
const census = censusPreviousRelays(conn, targetId, input).catch((error: unknown) => {
// A census that could not run leaves the reattach on today's path, which never kills anything.
console.warn(
`[ssh-relay] Previous relay census did not run for ${targetId}: ${
error instanceof Error ? error.message : String(error)
}`
)
return false
})
censusByTarget.set(targetId, census)
}
export function previousRelayMayHoldTerminals(targetId: string): Promise<boolean> {
return censusByTarget.get(targetId) ?? Promise.resolve(false)
}
export function clearPreviousRelayCensus(targetId: string): void {
censusByTarget.delete(targetId)
}
/** A not-found reattach this target must keep, because an older build's relay may run the PTY. */
export async function isReattachHeldByPreviousRelay(
targetId: string,
error: unknown
): Promise<boolean> {
if (
!isSshPtyNotFoundError(error) ||
isSshPtyIdentityMismatchError(error) ||
isProvenExitedPtyAttachRefusal(error)
) {
return false
}
return previousRelayMayHoldTerminals(targetId)
}
@@ -0,0 +1,203 @@
// After an app update the new relay answers "not found" for a PTY the previous build's relay still
// runs. While that older relay is live, the session must keep the lease and the pane instead of
// disowning the id, which is what replaced a user's running terminal with an empty shell.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SshRelaySession } from './ssh-relay-session'
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
const { acceptOutputDataMock, muxRequestMock, openConsumerSessionMock, pauseAdapterMock } =
vi.hoisted(() => ({
acceptOutputDataMock: vi.fn().mockResolvedValue(undefined),
muxRequestMock: vi.fn(),
openConsumerSessionMock: vi.fn(),
pauseAdapterMock: vi.fn()
}))
vi.mock('./ssh-relay-deploy', () => ({
deployAndLaunchRelay: vi.fn()
}))
vi.mock('./ssh-pty-consumer-session', () => ({
openSshPtyConsumerSession: openConsumerSessionMock
}))
vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({
acceptSshPtyOutputData: acceptOutputDataMock,
acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined),
allocateSshPtyProviderGeneration: vi.fn(() => 41),
beginSshPtyOutputGenerationMigration: vi.fn(() => ({
byPty: new Map(),
completion: Promise.resolve()
})),
closeSshPtyOutputGeneration: vi.fn(),
getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []),
applySshPtySourceCancellationProof: vi.fn(() => true),
applySshPtySourceRecoveryCancellationProof: vi.fn(() => true),
installSshPtySourceAckPublisher: vi.fn(() => () => {}),
installSshPtySourceCancellationPublisher: vi.fn(() => () => {})
}))
vi.mock('./ssh-relay-deploy-helpers', () => ({
execCommand: vi.fn().mockResolvedValue('')
}))
vi.mock('./ssh-channel-multiplexer', () => {
return {
SshChannelMultiplexer: class MockSshChannelMultiplexer {
notify = vi.fn()
notifyWithSettlement = vi.fn()
request = muxRequestMock
onNotification = vi.fn().mockReturnValue(() => {})
onNotificationByMethod = vi.fn().mockReturnValue(() => {})
onRequest = vi.fn().mockReturnValue(() => {})
onDispose = vi.fn().mockReturnValue(() => {})
dispose = vi.fn()
isDisposed = vi.fn().mockReturnValue(false)
}
}
})
vi.mock('../providers/ssh-pty-provider', () => ({
isSshPtyNotFoundError: (err: unknown) =>
(err instanceof Error ? err.message : String(err)).includes('not found'),
isSshPtyIdentityMismatchError: (err: unknown) =>
(err instanceof Error ? err.message : String(err)).includes('identity mismatch'),
SshPtyProvider: class MockSshPtyProvider {
onData = vi.fn().mockReturnValue(() => {})
onReplay = vi.fn().mockReturnValue(() => {})
onExit = vi.fn().mockReturnValue(() => {})
attach = vi.fn().mockResolvedValue(undefined)
attachForReconnect = vi.fn().mockResolvedValue({})
setPtyDeliveryPauseAdapter = pauseAdapterMock
dispose = vi.fn()
}
}))
vi.mock('../providers/ssh-filesystem-provider', () => ({
SshFilesystemProvider: class MockSshFilesystemProvider {
dispose = vi.fn()
}
}))
vi.mock('../providers/ssh-git-provider', () => ({
SshGitProvider: class MockSshGitProvider {}
}))
vi.mock('../ipc/pty', () => ({
registerSshPtyProvider: vi.fn(),
unregisterSshPtyProvider: vi.fn(),
getSshPtyProvider: vi.fn().mockReturnValue({
dispose: vi.fn(),
attach: vi.fn().mockResolvedValue(undefined),
attachForReconnect: vi.fn().mockResolvedValue({})
}),
getPtyIdsForConnection: vi.fn().mockReturnValue([]),
clearPtyOwnershipForConnection: vi.fn(),
clearProviderPtyState: vi.fn(),
deletePtyOwnership: vi.fn(),
setPtyOwnership: vi.fn(),
restorePtyIncarnation: vi.fn(),
isCurrentPtyExit: vi.fn(() => true)
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
registerSshFilesystemProvider: vi.fn(),
unregisterSshFilesystemProvider: vi.fn(),
getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() })
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
registerSshGitProvider: vi.fn(),
unregisterSshGitProvider: vi.fn()
}))
const { isReattachHeldByPreviousRelay, startPreviousRelayCensus } = vi.hoisted(() => ({
isReattachHeldByPreviousRelay: vi.fn(),
startPreviousRelayCensus: vi.fn()
}))
vi.mock('./ssh-previous-relay-terminals', () => ({
isReattachHeldByPreviousRelay,
startPreviousRelayCensus
}))
const { getSshPtyProvider, getPtyIdsForConnection, clearProviderPtyState, deletePtyOwnership } =
await import('../ipc/pty')
describe('SshRelaySession reattach while a previous relay is live', () => {
beforeEach(() => {
vi.clearAllMocks()
openConsumerSessionMock.mockImplementation(async (_mux, options) => ({
mode: 'legacy-fallback',
clientInstanceId: options.clientInstanceId,
serverBuildId: 'test-relay-build'
}))
muxRequestMock.mockReset()
muxRequestMock.mockResolvedValue([])
mockDeploySuccess()
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
})
async function reconnectWithStalePty(holds: boolean) {
const deps = createMockDeps()
const session = new SshRelaySession(
'target-1',
deps.getMainWindow,
deps.mockStore,
deps.mockPortForward
)
await session.establish(deps.mockConn)
vi.clearAllMocks()
mockDeploySuccess()
isReattachHeldByPreviousRelay.mockResolvedValue(holds)
// The reconnect reaches only these two members of the provider.
vi.mocked(getSshPtyProvider).mockReturnValue(
Object.assign(Object.create(null), {
attachForReconnect: vi.fn().mockRejectedValue(new Error('PTY "pty-old" not found')),
dispose: vi.fn()
})
)
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-old'])
await session.reconnect(deps.mockConn)
return deps
}
it('starts a census of older relays on every deploy', async () => {
await reconnectWithStalePty(false)
expect(startPreviousRelayCensus).toHaveBeenCalledWith(
expect.anything(),
'target-1',
expect.objectContaining({ platform: 'linux-x64' })
)
})
it('keeps the lease and the pane when an older relay may run the PTY', async () => {
const { mockStore, mockWindow } = await reconnectWithStalePty(true)
expect(isReattachHeldByPreviousRelay).toHaveBeenCalledWith(
'target-1',
expect.objectContaining({ message: 'PTY "pty-old" not found' })
)
expect(clearProviderPtyState).not.toHaveBeenCalledWith('ssh:target-1@@pty-old')
expect(deletePtyOwnership).not.toHaveBeenCalledWith('ssh:target-1@@pty-old')
expect(mockStore.markSshRemotePtyLease).not.toHaveBeenCalledWith(
'target-1',
'pty-old',
'expired'
)
expect(mockWindow.webContents.send).not.toHaveBeenCalledWith(
'pty:exit',
expect.objectContaining({ id: 'ssh:target-1@@pty-old' })
)
})
it('disowns the id as before when no older relay may run it', async () => {
const { mockStore, mockWindow } = await reconnectWithStalePty(false)
expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-old', 'expired')
expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', {
id: 'ssh:target-1@@pty-old',
code: -1,
ptySourceDisowned: true
})
})
})
+16 -1
View File
@@ -15,6 +15,10 @@ import { forgetRelayNodePtyRepairs, recoverRelayNodePtyForSpawn } from './ssh-re
import type { TerminalUnavailableCause } from '../../shared/terminal-unavailable-cause'
import { replayPendingSshPtyKills } from './ssh-pending-pty-kill-replay'
import { sweepOrphanedRelayPtys } from './ssh-orphan-relay-pty-sweep'
import {
isReattachHeldByPreviousRelay,
startPreviousRelayCensus
} from './ssh-previous-relay-terminals'
import { SshChannelMultiplexer } from './ssh-channel-multiplexer'
import { SshPtyProvider } from '../providers/ssh-pty-provider'
import type { SshPtyAttachResult } from '../providers/ssh-pty-session-reattach'
@@ -1050,7 +1054,9 @@ export class SshRelaySession {
isAttemptCurrent: () => boolean
): Promise<Awaited<ReturnType<typeof deployAndLaunchRelay>> | null> {
try {
return await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId)
const deployed = await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId)
startPreviousRelayCensus(conn, this.targetId, deployed)
return deployed
} catch (err) {
// Why system SSH is excluded: it has no ssh2 shell or SFTP channel to degrade onto.
if (
@@ -2787,6 +2793,15 @@ export class SshRelaySession {
if (!shouldContinue()) {
return
}
if (await isReattachHeldByPreviousRelay(this.targetId, error)) {
console.warn(
`[ssh-relay-session] Keeping PTY ${ptyId} for ${this.targetId}: an older Orca relay on this host may still run it`
)
return
}
if (!shouldContinue()) {
return
}
this.handlePtyReattachFailure(ptyId, appPtyId, pendingReattach, error)
} finally {
recoveryActivationLease?.retire()
@@ -194,6 +194,17 @@ describe('humanizeTerminalError', () => {
expect(humanized).toContain('still running')
})
it('says a terminal held by the previous Orca version is still running', () => {
const raw =
"Error invoking remote method 'pty:spawn': SshPtyHeldByPreviousRelayError: SSH_PTY_HELD_BY_PREVIOUS_RELAY: pty2:old-epoch:1"
const humanized = humanizeTerminalError(raw)
expect(humanized).not.toContain('SSH_PTY_HELD_BY_PREVIOUS_RELAY')
expect(humanized).not.toContain('pty2:old-epoch:1')
expect(humanized).toContain('previous Orca version')
expect(humanized).toContain('still running')
expect(isExplainedTerminalError(raw)).toBe(true)
})
it('replaces only the unreattachable line in an aggregated error', () => {
const humanized = humanizeTerminalError('Paste failed.\nSSH_SESSION_EXPIRED: orca:2f1c@@pty-7')
expect(humanized.startsWith('Paste failed.\n')).toBe(true)
@@ -57,6 +57,11 @@ const SOURCE_RESTORE_REQUIRED_SOURCE =
'SSH_PTY_SOURCE_RESTORE_REQUIRED(?::[ \\t]*\\S*(?:[ \\t]+\\S+)?)?'
const SOURCE_RESTORE_REQUIRED_PATTERN = new RegExp(SOURCE_RESTORE_REQUIRED_SOURCE)
const SOURCE_RESTORE_REQUIRED_REPLACE_PATTERN = new RegExp(SOURCE_RESTORE_REQUIRED_SOURCE, 'g')
// An older Orca build's relay may still run this terminal, and this build cannot reach it. Not one of
// the sources above: that copy implies the session is gone.
const HELD_BY_PREVIOUS_RELAY_SOURCE = 'SSH_PTY_HELD_BY_PREVIOUS_RELAY(?::[ \\t]*\\S*)?'
const HELD_BY_PREVIOUS_RELAY_PATTERN = new RegExp(HELD_BY_PREVIOUS_RELAY_SOURCE)
const HELD_BY_PREVIOUS_RELAY_REPLACE_PATTERN = new RegExp(HELD_BY_PREVIOUS_RELAY_SOURCE, 'g')
const UNREATTACHABLE_SESSION_PATTERNS = UNREATTACHABLE_SESSION_SOURCES.map(
(source) => new RegExp(source)
)
@@ -100,6 +105,7 @@ export function isExplainedTerminalError(error: string): boolean {
TERMINAL_HOST_GONE_PATTERN.test(line) ||
LEGACY_TERMINAL_HOST_GONE_PATTERN.test(line) ||
SOURCE_RESTORE_REQUIRED_PATTERN.test(line) ||
HELD_BY_PREVIOUS_RELAY_PATTERN.test(line) ||
UNREATTACHABLE_SESSION_PATTERNS.some((pattern) => pattern.test(line))
)
}
@@ -155,6 +161,12 @@ export function humanizeTerminalError(error: string): string {
'Reconnecting this terminal — its output is being re-established. The session is still running.'
)
)
humanized = humanized.replace(HELD_BY_PREVIOUS_RELAY_REPLACE_PATTERN, () =>
translate(
'auto.components.terminal.pane.TerminalErrorToast.heldByPreviousRelay',
'This terminal is still running on the host under the previous Orca version, which this version cannot connect to. It keeps running until it exits. Open a new terminal to keep working here.'
)
)
if (humanized.includes(REMOTE_TERMINAL_CLOSED_MARKER)) {
humanized = humanized.replaceAll(REMOTE_TERMINAL_CLOSED_MARKER, () =>
translate(
+1
View File
@@ -3281,6 +3281,7 @@
"42b283ecfc": "Orca couldn't safely reconnect this terminal because the host couldn't verify its saved session. Orca left the saved session unchanged. Click Retry to try reconnecting now. If it still cannot reconnect, open a new terminal.",
"e16012e31e": "The terminal daemon that owned this session exited, so the session and its scrollback could not be recovered. Open a new terminal to continue.",
"sessionUnavailable": "Orca couldn't reattach to this pane's terminal session on the host. Open a new terminal to continue.",
"heldByPreviousRelay": "This terminal is still running on the host under the previous Orca version, which this version cannot connect to. It keeps running until it exits. Open a new terminal to keep working here.",
"sourceRestoring": "Reconnecting this terminal — its output is being re-established. The session is still running.",
"remoteTerminalClosed": "Remote terminal was closed.",
"ptyAllocationLimit": "Your system cannot allocate any more pty devices. Close some terminals you are not using, in Orca or another program, then try again.",
+1
View File
@@ -3193,6 +3193,7 @@
"retryUnavailable": "Réessayez mais impossible de vous reconnecter pour l'instant. Réessayez sous peu.",
"ownerUnknown": "Orca n'a pas pu vérifier le propriétaire de ce terminal.",
"42b283ecfc": "Orca n'a pas pu reconnecter ce terminal en toute sécurité car l'hôte n'a pas pu vérifier sa session enregistrée. Orca a laissé la session enregistrée inchangée. Cliquez sur Réessayer pour essayer de vous reconnecter maintenant. S'il ne parvient toujours pas à se reconnecter, ouvrez un nouveau terminal.",
"heldByPreviousRelay": "Ce terminal s'exécute toujours sur l'hôte sous la version précédente d'Orca, à laquelle cette version ne peut pas se connecter. Il continue jusqu'à sa fin. Ouvrez un nouveau terminal pour continuer à travailler ici.",
"sourceRestoring": "Reconnexion de ce terminal - sa sortie est en cours de rétablissement. La session est toujours en cours.",
"remoteTerminalClosed": "Le terminal distant était fermé.",
"ptyAllocationLimit": "Votre système ne peut plus allouer de périphériques pty. Fermez quelques terminaux inutilisés, dans Orca ou dans un autre programme, puis réessayez.",
+1
View File
@@ -3092,6 +3092,7 @@
"42b283ecfc": "ホストが保存されたセッションを確認できなかったため、Orca はこのターミナルを安全に再接続できませんでした。 Orca は保存されたセッションを変更せずに残しました。今すぐ再接続を試みるには、「再試行」をクリックします。それでも再接続できない場合は、新規ターミナルを開きます。",
"e16012e31e": "このセッションを所有していたターミナルデーモンが終了したため、セッションとそのスクロールバックを回復できませんでした。続行するには、新規ターミナルを開いてください。",
"sessionUnavailable": "Orca はホスト上のこのペインのターミナルセッションに再接続できませんでした。続行するには、新規ターミナルを開いてください。",
"heldByPreviousRelay": "このターミナルは以前のバージョンの Orca のもとでホスト上でまだ実行中ですが、このバージョンからは接続できません。終了するまで実行され続けます。ここで作業を続けるには新しいターミナルを開いてください。",
"sourceRestoring": "このターミナルを再接続しています — その出力が再確立されています。セッションはまだ実行中です。",
"ptyAllocationLimit": "システムはこれ以上 pty デバイスを割り当てられません。Orca または他のプログラムで使っていないターミナルをいくつか閉じてから、もう一度お試しください。",
"terminalProcessLimit": "システムはこれ以上ターミナルプロセスを起動できません。使っていないターミナルを閉じるか、不要なプロセスを終了してから、もう一度お試しください。"
+1
View File
@@ -3092,6 +3092,7 @@
"42b283ecfc": "호스트가 저장된 세션을 확인할 수 없기 때문에 Orca가 이 터미널을 안전하게 다시 연결할 수 없습니다. 오르카는 저장된 세션을 변경하지 않고 그대로 두었습니다. 지금 다시 연결을 시도하려면 재시도를 클릭하세요. 그래도 다시 연결할 수 없으면 새 터미널을 엽니다.",
"e16012e31e": "이 세션을 소유한 터미널 데몬이 종료되었으므로 세션과 해당 스크롤백을 복구할 수 없습니다. 계속하려면 새 터미널을 여세요.",
"sessionUnavailable": "Orca가 호스트에서 이 창의 터미널 세션에 다시 연결할 수 없습니다. 계속하려면 새 터미널을 여세요.",
"heldByPreviousRelay": "이 터미널은 이전 버전의 Orca에서 호스트에서 계속 실행 중이며, 이 버전에서는 연결할 수 없습니다. 종료될 때까지 계속 실행됩니다. 여기서 계속 작업하려면 새 터미널을 여세요.",
"sourceRestoring": "이 터미널을 다시 연결하는 중 - 출력이 다시 설정되고 있습니다. 세션이 아직 실행 중입니다.",
"ptyAllocationLimit": "시스템에서 더 이상 pty 장치를 할당할 수 없습니다. Orca 또는 다른 프로그램에서 사용하지 않는 터미널을 몇 개 닫은 후 다시 시도하세요.",
"terminalProcessLimit": "시스템에서 터미널 프로세스를 더 시작할 수 없습니다. 사용하지 않는 터미널을 닫거나 불필요한 프로세스를 종료한 후 다시 시도하세요."
+1
View File
@@ -3092,6 +3092,7 @@
"42b283ecfc": "Orca 无法安全地重新连接此终端,因为主机无法验证其保存的会话。 Orca 保持保存的会话不变。单击“重试”立即尝试重新连接。如果仍然无法重新连接,请打开一个新终端。",
"e16012e31e": "拥有该会话的终端守护程序已退出,因此该会话及其回滚无法恢复。打开新终端以继续。",
"sessionUnavailable": "Orca 无法重新连接到主机上此窗格的终端会话。打开新终端以继续。",
"heldByPreviousRelay": "该终端仍在主机上以先前版本的 Orca 运行,当前版本无法连接到它。它会一直运行直到退出。请打开新终端以继续在此工作。",
"sourceRestoring": "重新连接该终端 — 正在重新建立其输出。会话仍在进行。",
"ptyAllocationLimit": "系统无法再分配 pty 设备。请在 Orca 或其他程序中关闭一些不用的终端,然后重试。",
"terminalProcessLimit": "系统无法再启动终端进程。请关闭不用的终端或退出不用的进程,然后重试。"
@@ -0,0 +1,166 @@
import { execFile } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { connect } from 'node:net'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import {
parseRelayEndpointIncumbentProbe,
relayEndpointIncumbentProbeCommand,
type RelayEndpointIncumbent
} from '../../../src/main/ssh/ssh-relay-endpoint-incumbent'
import { mayHoldTerminals } from '../../../src/main/ssh/ssh-previous-relay-terminals'
import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout'
/**
* An app update leaves the previous build's relay running its terminals (#13852). This build probes
* that relay's socket on every connect, and the old relay restarts its grace window when the probe
* hangs up. With the `--grace-time 0` it was launched with, that window has no deadline, so the old
* relay and its terminal must outlive the probe — and the probe must read it as holding live work.
*/
const PREVIOUS_RELAY_REF = 'v1.4.218'
const SUITE_TIMEOUT_MS = 180_000
type Constructor = new (...args: unknown[]) => Record<string, unknown>
function isConstructor(value: unknown): value is Constructor {
return typeof value === 'function'
}
function runProbe(command: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile('sh', ['-c', command], { timeout: 20_000 }, (error, stdout) =>
error ? reject(error) : resolve(stdout)
)
})
}
function accepts(sockPath: string): Promise<boolean> {
return new Promise((resolve) => {
const socket = connect(sockPath)
socket.once('connect', () => {
socket.destroy()
resolve(true)
})
socket.once('error', () => resolve(false))
})
}
describe.skipIf(process.platform === 'win32')(
`a ${PREVIOUS_RELAY_REF} relay holding a live terminal, probed by this build`,
() => {
let dir: string
let sockPath: string
const graceTimeouts: number[] = []
const dispose = vi.fn(() => new Promise<void>(() => {}))
let incumbent: RelayEndpointIncumbent
let stopListener: () => void = () => {}
beforeAll(async () => {
const checkout = await materializeReleaseCheckout(PREVIOUS_RELAY_REF)
const [ownershipModule, listenerModule, lifecycleModule] = await Promise.all(
[
'src/relay/relay-socket-ownership.ts',
'src/relay/relay-reconnect-listener.ts',
'src/relay/relay-grace-lifecycle.ts'
].map((path) => importReleaseCheckoutModule(checkout, path))
)
const { RelaySocketOwnership } = ownershipModule
const { RelayReconnectListener } = listenerModule
const { RelayGraceLifecycle } = lifecycleModule
if (
!isConstructor(RelaySocketOwnership) ||
!isConstructor(RelayReconnectListener) ||
!isConstructor(RelayGraceLifecycle)
) {
throw new Error(`${PREVIOUS_RELAY_REF} no longer exports the relay socket lifecycle`)
}
// Why /tmp: macOS's per-user tmpdir pushes a socket path past sun_path.
dir = await mkdtemp('/tmp/orca-xv-relay-')
sockPath = join(dir, 'relay-target.sock')
// The daemon's PTY pool, holding the one live terminal the relay was left running.
const ptyHandler = {
configuredGraceTimeMs: 0,
activePtyCount: 1,
pendingPtyCreationCount: 0,
graceTimerActive: false,
startGraceTimer: (_onExpire: () => void, timeoutMs: number) => {
graceTimeouts.push(timeoutMs)
},
cancelGraceTimer: () => {},
dispose
}
const dispatcher = { onNotification: () => {}, onRequest: () => {} }
const ownership = new RelaySocketOwnership(sockPath)
let listener: Record<string, unknown> | null = null
const lifecycle = new RelayGraceLifecycle({
dispatcher,
ptyHandler,
detached: true,
emptyDetachedStartupGraceMs: 60_000,
idleRelayGraceMs: 60_000,
readSocketClientCount: () => Number(listener?.clientCount ?? 0),
hasAcceptedSocketClient: () => true,
ownsSocketPath: () => true,
disposeOwnedProcesses: async () => {},
disposeRuntime: () => {}
})
const start = lifecycle.start
const cancel = lifecycle.cancel
if (typeof start !== 'function' || typeof cancel !== 'function') {
throw new Error(`${PREVIOUS_RELAY_REF} grace lifecycle lost start/cancel`)
}
listener = new RelayReconnectListener(
dispatcher,
ownership,
`0.1.0+${PREVIOUS_RELAY_REF}`,
undefined,
{
detachPrimaryInput: () => {},
cancelGrace: (reason: string) => cancel.call(lifecycle, reason),
onLastClientClosed: () => start.call(lifecycle, 'socket client closed')
}
)
const listen = listener.start
if (typeof listen !== 'function') {
throw new Error(`${PREVIOUS_RELAY_REF} reconnect listener lost start`)
}
await listen.call(listener)
stopListener = () => {
const close = ownership.closeAndCleanup
if (typeof close === 'function') {
close.call(ownership)
}
}
start.call(lifecycle, 'socket client closed')
incumbent = parseRelayEndpointIncumbentProbe(
sockPath,
await runProbe(relayEndpointIncumbentProbeCommand(process.execPath, sockPath))
)
// Let the old relay handle the probe's hang-up.
await new Promise((resolve) => setTimeout(resolve, 200))
}, SUITE_TIMEOUT_MS)
afterAll(async () => {
stopListener()
if (dir) {
await rm(dir, { recursive: true, force: true })
}
})
it('reads the old relay as live work this build must not replace', () => {
expect(incumbent.verdict).toBe('live')
expect(mayHoldTerminals(incumbent)).toBe(true)
})
it('leaves the old relay with no grace deadline after the probe hangs up', () => {
expect(graceTimeouts.length).toBeGreaterThanOrEqual(2)
expect(graceTimeouts.every((timeoutMs) => timeoutMs === 0)).toBe(true)
expect(dispose).not.toHaveBeenCalled()
})
it('keeps the old relay accepting connections', async () => {
expect(await accepts(sockPath)).toBe(true)
})
}
)
@@ -5,12 +5,14 @@ const CHECKOUT_PROCESS_TIMEOUT_MS = 45_000
const CHECKOUT_MAX_OUTPUT_BYTES = 1024 * 1024
// Why: the wire endpoints only need the runtime RPC host, the renderer client, and
// the shared codec. Skipping cli/relay keeps a cold CI extraction a few seconds.
// the shared codec, plus the relay an app update leaves running. Skipping cli keeps a cold CI
// extraction a few seconds.
// The phone's `worktree ps` row reader is one self-contained file, so it rides along alone.
const ARCHIVE_PATHS = [
'src/main',
'src/shared',
'src/preload',
'src/relay',
'src/renderer',
'src/types',
'mobile/src/worktree/agent-row-display.ts'
@@ -15,7 +15,7 @@ export const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..')
const DEFAULT_CACHE_ROOT = join(REPO_ROOT, 'tests', 'e2e', '.cross-version-checkouts')
// Bump when extraction or the alias rewrite changes so cached trees are rebuilt.
const CHECKOUT_FORMAT = 4
const CHECKOUT_FORMAT = 5
const BASELINE_REF_ENV = 'ORCA_CROSS_VERSION_BASELINE_REF'