mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
2212 lines
80 KiB
TypeScript
2212 lines
80 KiB
TypeScript
import { join } from 'node:path'
|
|
/* eslint-disable max-lines -- Why: one cohesive contract (version detect, install-locked deploy, native-deps probe, launch, GC); splitting risks install/GC drift. */
|
|
import { existsSync } from 'node:fs'
|
|
import { app } from 'electron'
|
|
import type { SshConnection } from './ssh-connection'
|
|
import { RELAY_REMOTE_DIR, type RelayPlatform } from './relay-protocol'
|
|
import type { MultiplexerTransport } from './ssh-channel-multiplexer'
|
|
import {
|
|
waitForSentinel,
|
|
execCommand,
|
|
isUnconfirmedSshCommandTermination
|
|
} from './ssh-relay-deploy-helpers'
|
|
import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers'
|
|
import { writeRelayEndpointCredential } from './ssh-relay-endpoint-credential'
|
|
import {
|
|
createRelayInstallMarkerCommand,
|
|
createRelayInstallNamespace,
|
|
createRelayUploadStageNamespace,
|
|
makeRelayInstallDirectoryCommand,
|
|
relayHomeRelativeDir,
|
|
relaySftpNamespaceMapping,
|
|
relayUploadStageSftpNamespaceMapping,
|
|
type RelayInstallNamespace,
|
|
type RelayUploadStageNamespace
|
|
} from './ssh-relay-install-namespace'
|
|
import { createRelayInstallMarkerFileName } from './ssh-relay-install-marker'
|
|
import { resolveRemoteNodePath } from './ssh-remote-node-resolution'
|
|
import {
|
|
readLocalFullVersion,
|
|
computeRemoteRelayDir,
|
|
isRelayAlreadyInstalled,
|
|
finalizeInstall,
|
|
abandonInstall,
|
|
gcOldRelayVersions
|
|
} from './ssh-relay-versioned-install'
|
|
import {
|
|
attachRelayNativeDepsCache,
|
|
promoteRelayNativeDepsCache,
|
|
resolveRelayNativeDepsCacheKey,
|
|
type RelayNativeDepsCacheContext
|
|
} from './ssh-relay-native-deps-cache-install'
|
|
import { acquireInstallLock } from './ssh-relay-install-lock'
|
|
import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock'
|
|
import {
|
|
releaseRelayGcClaimWithRetry,
|
|
tryAcquireRelayGcClaim,
|
|
waitForRelayGcClaimRelease
|
|
} from './ssh-relay-gc-claim'
|
|
import {
|
|
NATIVE_DEPS_COMMAND_TIMEOUT_MS,
|
|
RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS,
|
|
RELAY_DEPLOY_TIMEOUT_MS
|
|
} from './ssh-relay-deploy-timing'
|
|
import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils'
|
|
import {
|
|
probeBuildToolchain,
|
|
formatMissingToolchainError,
|
|
formatSkippedNodePtyWarning,
|
|
shouldProbeBuildToolchainAfterNativeDepsFailure
|
|
} from './ssh-relay-build-toolchain'
|
|
import {
|
|
commandWithNodePath,
|
|
makeRemoteExecutableCommand,
|
|
readRemoteHomeCommand,
|
|
removeRemoteFileCommand
|
|
} from './ssh-remote-commands'
|
|
import {
|
|
cleanupOwnedRelayUploadStageCommand,
|
|
parseReservedRelayUploadStage,
|
|
promoteOwnedRelayUploadStageCommand,
|
|
recoverOneStaleRelayUploadStageCommand,
|
|
relayUploadStagePromotionConfirmed,
|
|
RELAY_UPLOAD_STAGE_POOL_NAME,
|
|
reserveRelayUploadStageCommand
|
|
} from './ssh-relay-upload-stage-commands'
|
|
import {
|
|
isWindowsRemoteHost,
|
|
joinRemotePath,
|
|
normalizeRemoteHome,
|
|
validateRemoteHome,
|
|
type RemoteHostPlatform
|
|
} from './ssh-remote-platform'
|
|
import { detectRemoteHostPlatform } from './ssh-remote-platform-detection'
|
|
import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell'
|
|
import { relaySocketNameForInstanceId } from './ssh-relay-instance-id'
|
|
import { resolveRelayEndpointBeforeRelaunch } from './ssh-relay-endpoint-takeover'
|
|
import { sweepSupersededRelayEndpoints } from './ssh-relay-superseded-endpoints'
|
|
import { isSshSessionLimitError } from './ssh-session-limit-error'
|
|
import {
|
|
isWindowsRelayPipePath,
|
|
relayEndpointForHost,
|
|
relayHookEndpointDirForHost,
|
|
windowsActivePipeMarkerPath,
|
|
windowsRelayFallbackSocketName
|
|
} from './ssh-relay-endpoints'
|
|
import {
|
|
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
|
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
|
MIN_SSH_RELAY_GRACE_PERIOD_SECONDS
|
|
} from '../../shared/ssh-types'
|
|
|
|
export type RelayDeployResult = {
|
|
transport: MultiplexerTransport
|
|
serverBuildId?: string
|
|
platform: RelayPlatform
|
|
hostPlatform?: RemoteHostPlatform
|
|
remoteHome?: string
|
|
remoteRelayDir?: string
|
|
nodePath?: string
|
|
sockPath?: string
|
|
credentialFile?: string
|
|
}
|
|
|
|
class RelayDirectoryGcConflictError extends Error {
|
|
constructor(
|
|
readonly remoteRelayDir: string,
|
|
readonly hostPlatform: RemoteHostPlatform
|
|
) {
|
|
super(`Relay directory GC is in progress at ${remoteRelayDir}`)
|
|
}
|
|
}
|
|
|
|
function execHostCommand(
|
|
conn: SshConnection,
|
|
hostPlatform: RemoteHostPlatform,
|
|
command: string,
|
|
options?: { timeoutMs?: number; signal?: AbortSignal; onStderr?: (stderr: string) => void }
|
|
): Promise<string> {
|
|
return execCommand(conn, command, {
|
|
wrapCommand: !isWindowsRemoteHost(hostPlatform),
|
|
timeoutMs: options?.timeoutMs,
|
|
signal: options?.signal,
|
|
onStderr: options?.onStderr
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Deploy the relay to the remote host and launch it, returning the transport (relay's stdin/stdout) for multiplexer use.
|
|
*/
|
|
export async function deployAndLaunchRelay(
|
|
conn: SshConnection,
|
|
onProgress?: (status: string) => void,
|
|
graceTimeSeconds?: number,
|
|
relayInstanceId?: string
|
|
): Promise<RelayDeployResult> {
|
|
let timeoutHandle: ReturnType<typeof setTimeout>
|
|
const deployAbortController = new AbortController()
|
|
const timedOut = Symbol('relay-deploy-timeout')
|
|
const deployment = deployAndLaunchRelayInner(
|
|
conn,
|
|
onProgress,
|
|
graceTimeSeconds,
|
|
relayInstanceId,
|
|
deployAbortController.signal
|
|
).then(
|
|
(result) => ({ status: 'fulfilled' as const, result }),
|
|
(error: unknown) => ({ status: 'rejected' as const, error })
|
|
)
|
|
const timeoutPromise = new Promise<typeof timedOut>((resolve) => {
|
|
timeoutHandle = setTimeout(() => {
|
|
deployAbortController.abort()
|
|
resolve(timedOut)
|
|
}, RELAY_DEPLOY_TIMEOUT_MS)
|
|
})
|
|
|
|
try {
|
|
const outcome = await Promise.race([deployment, timeoutPromise])
|
|
if (outcome !== timedOut) {
|
|
if (outcome.status === 'fulfilled') {
|
|
return outcome.result
|
|
}
|
|
throw outcome.error
|
|
}
|
|
|
|
const teardownExpired = Symbol('relay-deploy-teardown-timeout')
|
|
let teardownTimeoutHandle: ReturnType<typeof setTimeout>
|
|
const teardown = await Promise.race([
|
|
deployment,
|
|
new Promise<typeof teardownExpired>((resolve) => {
|
|
teardownTimeoutHandle = setTimeout(
|
|
() => resolve(teardownExpired),
|
|
RELAY_DEPLOY_TEARDOWN_TIMEOUT_MS
|
|
)
|
|
})
|
|
]).finally(() => clearTimeout(teardownTimeoutHandle!))
|
|
const timeoutError = Object.assign(
|
|
new Error(`Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s`),
|
|
teardownConfirmation(teardown === teardownExpired ? undefined : teardown)
|
|
)
|
|
throw timeoutError
|
|
} finally {
|
|
clearTimeout(timeoutHandle!)
|
|
}
|
|
}
|
|
|
|
function teardownConfirmation(
|
|
outcome:
|
|
| { status: 'fulfilled'; result: RelayDeployResult }
|
|
| { status: 'rejected'; error: unknown }
|
|
| undefined
|
|
): { sshChannelCloseConfirmed: boolean; sshTransferTeardownConfirmed: boolean } {
|
|
if (!outcome) {
|
|
return { sshChannelCloseConfirmed: false, sshTransferTeardownConfirmed: false }
|
|
}
|
|
if (outcome.status === 'fulfilled') {
|
|
return { sshChannelCloseConfirmed: true, sshTransferTeardownConfirmed: true }
|
|
}
|
|
const error = outcome.error as {
|
|
sshChannelCloseConfirmed?: unknown
|
|
sshTransferTeardownConfirmed?: unknown
|
|
}
|
|
return {
|
|
sshChannelCloseConfirmed: error?.sshChannelCloseConfirmed === true,
|
|
sshTransferTeardownConfirmed: error?.sshTransferTeardownConfirmed === true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the remote home, derive the versioned relay dir, and check whether the relay is installed there.
|
|
*
|
|
* Why: extracted to run concurrently with node-path resolution; home and install-check stay sequential because the check needs the resolved dir.
|
|
*/
|
|
async function resolveRemoteInstallState(
|
|
conn: SshConnection,
|
|
hostPlatform: RemoteHostPlatform,
|
|
fullVersion: string,
|
|
options?: { rethrowSessionLimitErrors?: boolean; signal?: AbortSignal }
|
|
): Promise<{ remoteHome: string; remoteRelayDir: string; alreadyInstalled: boolean }> {
|
|
// Why: SFTP doesn't expand `~`, so resolve the remote home explicitly via the host's native shell and normalize it.
|
|
const remoteHome = normalizeRemoteHome(
|
|
await execHostCommand(conn, hostPlatform, readRemoteHomeCommand(hostPlatform), {
|
|
signal: options?.signal
|
|
}),
|
|
hostPlatform
|
|
)
|
|
// Why: $HOME is only used inside single-quoted shell strings, so validation only rejects control chars — spaces and non-ASCII stay valid.
|
|
if (!validateRemoteHome(remoteHome, hostPlatform)) {
|
|
throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`)
|
|
}
|
|
const remoteRelayDir = computeRemoteRelayDir(remoteHome, fullVersion, hostPlatform.pathFlavor)
|
|
const probeOptions =
|
|
options?.rethrowSessionLimitErrors || options?.signal
|
|
? {
|
|
rethrowSessionLimitErrors: options.rethrowSessionLimitErrors,
|
|
signal: options.signal
|
|
}
|
|
: undefined
|
|
const alreadyInstalled = await isRelayAlreadyInstalled(
|
|
conn,
|
|
remoteRelayDir,
|
|
hostPlatform,
|
|
probeOptions
|
|
)
|
|
return { remoteHome, remoteRelayDir, alreadyInstalled }
|
|
}
|
|
|
|
type RelayBootstrapState = {
|
|
remoteHome: string
|
|
remoteRelayDir: string
|
|
alreadyInstalled: boolean
|
|
nodePath: string
|
|
}
|
|
|
|
async function resolveRelayBootstrapStateSequentially(
|
|
conn: SshConnection,
|
|
hostPlatform: RemoteHostPlatform,
|
|
fullVersion: string,
|
|
signal?: AbortSignal
|
|
): Promise<RelayBootstrapState> {
|
|
const installState = await resolveRemoteInstallState(conn, hostPlatform, fullVersion, { signal })
|
|
const nodePath = await resolveRemoteNodePath(conn, hostPlatform, { signal })
|
|
return { ...installState, nodePath }
|
|
}
|
|
|
|
async function resolveRelayBootstrapState(
|
|
conn: SshConnection,
|
|
hostPlatform: RemoteHostPlatform,
|
|
fullVersion: string,
|
|
signal?: AbortSignal
|
|
): Promise<RelayBootstrapState> {
|
|
if (!conn.canRunConcurrentExecCommands()) {
|
|
return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion, signal)
|
|
}
|
|
const abortController = new AbortController()
|
|
const abortForDeploy = (): void => abortController.abort()
|
|
signal?.addEventListener('abort', abortForDeploy, { once: true })
|
|
if (signal?.aborted) {
|
|
abortForDeploy()
|
|
}
|
|
const installStatePromise = resolveRemoteInstallState(conn, hostPlatform, fullVersion, {
|
|
rethrowSessionLimitErrors: true,
|
|
signal: abortController.signal
|
|
})
|
|
const nodePathPromise = resolveRemoteNodePath(conn, hostPlatform, {
|
|
rethrowSessionLimitErrors: true,
|
|
signal: abortController.signal
|
|
})
|
|
try {
|
|
const [installState, nodePath] = await Promise.all([installStatePromise, nodePathPromise])
|
|
signal?.throwIfAborted()
|
|
return { ...installState, nodePath }
|
|
} catch (err) {
|
|
abortController.abort()
|
|
const settled = await Promise.allSettled([installStatePromise, nodePathPromise])
|
|
signal?.throwIfAborted()
|
|
if (!isSshSessionLimitError(err)) {
|
|
throw err
|
|
}
|
|
const nonSessionFailure = settled.find(
|
|
(result) =>
|
|
result.status === 'rejected' &&
|
|
!isSshSessionLimitError(result.reason) &&
|
|
!isAbortError(result.reason)
|
|
)
|
|
if (nonSessionFailure?.status === 'rejected') {
|
|
throw nonSessionFailure.reason
|
|
}
|
|
console.warn(
|
|
'[ssh-relay] Concurrent bootstrap probes hit the remote SSH session limit; retrying sequentially.'
|
|
)
|
|
return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion, signal)
|
|
} finally {
|
|
signal?.removeEventListener('abort', abortForDeploy)
|
|
}
|
|
}
|
|
|
|
function isAbortError(err: unknown): boolean {
|
|
return err instanceof Error && err.name === 'AbortError'
|
|
}
|
|
|
|
/**
|
|
* Detect platform, resolve install state + node path, install if absent, launch, and return the transport.
|
|
* Inner implementation wrapped by `deployAndLaunchRelay` with an overall timeout.
|
|
*/
|
|
async function deployAndLaunchRelayInner(
|
|
conn: SshConnection,
|
|
onProgress?: (status: string) => void,
|
|
graceTimeSeconds?: number,
|
|
relayInstanceId?: string,
|
|
deploySignal?: AbortSignal
|
|
): Promise<RelayDeployResult> {
|
|
while (true) {
|
|
deploySignal?.throwIfAborted()
|
|
try {
|
|
return await deployAndLaunchRelayAttempt(
|
|
conn,
|
|
onProgress,
|
|
graceTimeSeconds,
|
|
relayInstanceId,
|
|
deploySignal
|
|
)
|
|
} catch (err) {
|
|
if (!(err instanceof RelayDirectoryGcConflictError)) {
|
|
throw err
|
|
}
|
|
// Why: GC atomically moves the old install aside; wait for its sibling claim to clear, then recompute install state.
|
|
await waitForRelayGcClaimRelease(conn, err.remoteRelayDir, err.hostPlatform, deploySignal)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function deployAndLaunchRelayAttempt(
|
|
conn: SshConnection,
|
|
onProgress?: (status: string) => void,
|
|
graceTimeSeconds?: number,
|
|
relayInstanceId?: string,
|
|
deploySignal?: AbortSignal
|
|
): Promise<RelayDeployResult> {
|
|
onProgress?.('Detecting remote platform...')
|
|
console.log('[ssh-relay] Detecting remote platform...')
|
|
const hostPlatform = await detectRemoteHostPlatform(conn, { signal: deploySignal })
|
|
if (!hostPlatform) {
|
|
throw new Error(
|
|
'Unsupported remote platform. Orca relay supports: linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64, win32-arm64.'
|
|
)
|
|
}
|
|
const platform = hostPlatform.relayPlatform
|
|
console.log(`[ssh-relay] Platform: ${platform}`)
|
|
|
|
const localRelayDir = getLocalRelayPath(platform)
|
|
if (!localRelayDir) {
|
|
throw new Error(
|
|
`Relay package for ${platform} not found locally. ` +
|
|
`This may be a packaging issue — try reinstalling Orca.`
|
|
)
|
|
}
|
|
// Why: content-hashed version doubles as remote dir name and wire-handshake version; throws on missing rather than falling back (see docs/ssh-relay-versioned-install-dirs.md).
|
|
const fullVersion = readLocalFullVersion(localRelayDir)
|
|
|
|
onProgress?.('Checking existing relay...')
|
|
// Why: install-check and node resolution are independent; run concurrently to save a round trip, with sequential fallback for restrictive SSH servers.
|
|
const { remoteHome, remoteRelayDir, alreadyInstalled, nodePath } =
|
|
await resolveRelayBootstrapState(conn, hostPlatform, fullVersion, deploySignal)
|
|
console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`)
|
|
console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`)
|
|
|
|
// Why: derive the home-relative suffix once — recomputing it by stripping the shell home breaks on a split namespace.
|
|
const homeRelativeRelayDir = relayHomeRelativeDir(fullVersion)
|
|
const uploadStagePoolDir = joinRemotePath(
|
|
hostPlatform,
|
|
remoteHome,
|
|
RELAY_REMOTE_DIR,
|
|
RELAY_UPLOAD_STAGE_POOL_NAME
|
|
)
|
|
const homeRelativeUploadStagePoolDir = `${RELAY_REMOTE_DIR}/${RELAY_UPLOAD_STAGE_POOL_NAME}`
|
|
|
|
let ownsInstallLock = false
|
|
let launchGcClaimToken: string | undefined
|
|
let launchNamespace: RelayInstallNamespace | undefined
|
|
if (alreadyInstalled) {
|
|
const launchFence = await repairInstalledNativeDeps(
|
|
conn,
|
|
remoteRelayDir,
|
|
platform,
|
|
hostPlatform,
|
|
nodePath,
|
|
homeRelativeRelayDir,
|
|
deploySignal
|
|
)
|
|
ownsInstallLock = launchFence.ownsInstallLock
|
|
launchGcClaimToken = launchFence.gcClaimToken
|
|
launchNamespace = launchFence.sftpNamespace
|
|
deploySignal?.throwIfAborted()
|
|
} else {
|
|
await execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
recoverOneStaleRelayUploadStageCommand(hostPlatform, uploadStagePoolDir),
|
|
{ signal: deploySignal }
|
|
)
|
|
const uploadStageOwner = createRelayInstallMarkerFileName()
|
|
const reservation = await execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
reserveRelayUploadStageCommand(hostPlatform, uploadStagePoolDir, uploadStageOwner),
|
|
{ signal: deploySignal }
|
|
)
|
|
const uploadStage = parseReservedRelayUploadStage(
|
|
hostPlatform,
|
|
uploadStagePoolDir,
|
|
uploadStageOwner,
|
|
reservation
|
|
)
|
|
const uploadStagePayloadDir = joinRemotePath(hostPlatform, uploadStage.slotDir, 'payload')
|
|
const uploadStageNamespace = createRelayUploadStageNamespace(
|
|
`${homeRelativeUploadStagePoolDir}/${uploadStage.slotName}`,
|
|
uploadStageOwner
|
|
)
|
|
const uploadStageSftpNamespace = uploadStageNamespaceIfSupported(
|
|
conn,
|
|
hostPlatform,
|
|
uploadStageNamespace
|
|
)
|
|
let uploadStageCleanupAllowed = true
|
|
onProgress?.('Uploading relay...')
|
|
console.log('[ssh-relay] Uploading relay...')
|
|
try {
|
|
try {
|
|
await uploadRelay(
|
|
conn,
|
|
platform,
|
|
uploadStagePayloadDir,
|
|
fullVersion,
|
|
hostPlatform,
|
|
deploySignal,
|
|
{ rootDir: uploadStage.slotDir, namespace: uploadStageSftpNamespace }
|
|
)
|
|
} catch (err) {
|
|
if (isUnconfirmedSshCommandTermination(err)) {
|
|
uploadStageCleanupAllowed = false
|
|
}
|
|
throw err
|
|
}
|
|
|
|
try {
|
|
await acquireInstallLock(conn, remoteRelayDir, hostPlatform, { signal: deploySignal })
|
|
ownsInstallLock = true
|
|
} catch (err) {
|
|
if (isUnconfirmedSshCommandTermination(err)) {
|
|
ownsInstallLock = true
|
|
}
|
|
throw err
|
|
}
|
|
try {
|
|
// Re-probe after acquiring the lock — a sibling installer may have finished while we waited.
|
|
if (
|
|
!(await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform, {
|
|
signal: deploySignal
|
|
}))
|
|
) {
|
|
launchNamespace = await createRelayLaunchNamespace(
|
|
conn,
|
|
hostPlatform,
|
|
remoteRelayDir,
|
|
homeRelativeRelayDir,
|
|
deploySignal
|
|
)
|
|
try {
|
|
const promotion = await execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
promoteOwnedRelayUploadStageCommand(
|
|
hostPlatform,
|
|
uploadStage,
|
|
uploadStageOwner,
|
|
remoteRelayDir
|
|
),
|
|
{ signal: deploySignal }
|
|
)
|
|
if (!relayUploadStagePromotionConfirmed(uploadStageOwner, promotion)) {
|
|
throw new Error('Relay upload stage ownership was lost before promotion')
|
|
}
|
|
} catch (err) {
|
|
if (isUnconfirmedSshCommandTermination(err)) {
|
|
uploadStageCleanupAllowed = false
|
|
}
|
|
throw err
|
|
}
|
|
console.log('[ssh-relay] Upload complete')
|
|
|
|
onProgress?.('Installing native dependencies...')
|
|
console.log('[ssh-relay] Installing native dependencies...')
|
|
await installNativeDeps(
|
|
conn,
|
|
remoteRelayDir,
|
|
platform,
|
|
hostPlatform,
|
|
nodePath,
|
|
deploySignal,
|
|
[],
|
|
launchNamespace,
|
|
remoteHome
|
|
)
|
|
console.log('[ssh-relay] Native deps installed')
|
|
|
|
// Why: mark complete but retain the lock until launch makes daemon liveness observable to cross-version GC.
|
|
await finalizeInstall(conn, remoteRelayDir, hostPlatform, {
|
|
signal: deploySignal,
|
|
releaseLock: false
|
|
})
|
|
}
|
|
} catch (err) {
|
|
if (!isUnconfirmedSshCommandTermination(err)) {
|
|
await abandonInstall(conn, remoteRelayDir, hostPlatform)
|
|
ownsInstallLock = false
|
|
}
|
|
throw err
|
|
}
|
|
} finally {
|
|
if (uploadStageCleanupAllowed) {
|
|
await execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
cleanupOwnedRelayUploadStageCommand(hostPlatform, uploadStage, uploadStageOwner)
|
|
).catch(() => {})
|
|
}
|
|
}
|
|
}
|
|
|
|
let launched: Awaited<ReturnType<typeof launchRelay>>
|
|
let launchLivenessObserved = false
|
|
try {
|
|
deploySignal?.throwIfAborted()
|
|
onProgress?.('Starting relay...')
|
|
console.log('[ssh-relay] Launching relay...')
|
|
launched = await launchRelay(
|
|
conn,
|
|
remoteRelayDir,
|
|
hostPlatform,
|
|
nodePath,
|
|
graceTimeSeconds,
|
|
relayInstanceId,
|
|
deploySignal
|
|
)
|
|
launchLivenessObserved = true
|
|
} finally {
|
|
// Why: older clients understand only the install lock; if launch never goes live, keep it so their GC can't race a caller waiting behind this owner.
|
|
if (ownsInstallLock && launchLivenessObserved) {
|
|
await abandonInstall(conn, remoteRelayDir, hostPlatform)
|
|
}
|
|
// The detached start may outlive a timed-out SSH command; keep the fence on failed launch until stale recovery proves the handoff ended.
|
|
if (launchGcClaimToken && launchLivenessObserved) {
|
|
await releaseRelayGcClaimWithRetry(conn, remoteRelayDir, launchGcClaimToken, hostPlatform)
|
|
}
|
|
}
|
|
console.log('[ssh-relay] Relay started successfully')
|
|
|
|
void execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
recoverOneStaleRelayUploadStageCommand(hostPlatform, uploadStagePoolDir)
|
|
)
|
|
.catch(() => {})
|
|
// Why before GC: a superseded relay pins its version dir via the live-socket probe, so the
|
|
// sweep has to settle first or GC keeps every orphan's tree forever.
|
|
.then(() =>
|
|
sweepSupersededRelayEndpoints(conn, hostPlatform, {
|
|
remoteHome,
|
|
currentRelayDir: remoteRelayDir,
|
|
sockName: relaySocketNameForInstanceId(relayInstanceId),
|
|
nodePath: launched.nodePath
|
|
})
|
|
)
|
|
.catch(() => {})
|
|
.then(() =>
|
|
gcOldRelayVersions(conn, remoteHome, remoteRelayDir, hostPlatform, {
|
|
windowsNodePath: launched.nodePath,
|
|
windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)],
|
|
// Why pin rather than rely on the symlink alone: a deploy that fell back to a
|
|
// per-directory install has no reference to show, and its key must still survive.
|
|
nativeDepsCacheKeys: [
|
|
resolveRelayNativeDepsCacheKey({
|
|
platform,
|
|
localRelayDir,
|
|
deps: RELAY_NATIVE_DEPS
|
|
})
|
|
].filter((key): key is string => key !== null)
|
|
})
|
|
)
|
|
.catch(() => {})
|
|
|
|
return {
|
|
transport: launched.transport,
|
|
serverBuildId: fullVersion,
|
|
platform,
|
|
hostPlatform,
|
|
remoteHome,
|
|
remoteRelayDir,
|
|
nodePath: launched.nodePath,
|
|
sockPath: launched.sockPath,
|
|
credentialFile: launched.credentialFile
|
|
}
|
|
}
|
|
|
|
async function uploadRelay(
|
|
conn: SshConnection,
|
|
platform: RelayPlatform,
|
|
remoteDir: string,
|
|
fullVersion: string,
|
|
hostPlatform: RemoteHostPlatform,
|
|
signal?: AbortSignal,
|
|
stage?: { rootDir: string; namespace?: RelayUploadStageNamespace }
|
|
): Promise<void> {
|
|
const localRelayDir = getLocalRelayPath(platform)
|
|
if (!localRelayDir || !existsSync(localRelayDir)) {
|
|
throw new Error(
|
|
`Relay package for ${platform} not found. Searched: ${getLocalRelayCandidates(platform).join(', ')}. ` +
|
|
`This may be a packaging issue — try reinstalling Orca.`
|
|
)
|
|
}
|
|
|
|
if (!stage) {
|
|
await execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
makeRelayInstallDirectoryCommand(hostPlatform, remoteDir),
|
|
{ signal }
|
|
)
|
|
}
|
|
|
|
await uploadRelayDirectory(conn, localRelayDir, remoteDir, hostPlatform, {
|
|
signal,
|
|
sftpNamespace: stage?.namespace
|
|
? relayUploadStageSftpNamespaceMapping(stage.namespace, hostPlatform, stage.rootDir)
|
|
: undefined
|
|
})
|
|
|
|
if (!isWindowsRemoteHost(hostPlatform)) {
|
|
await execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
makeRemoteExecutableCommand(hostPlatform, joinRemotePath(hostPlatform, remoteDir, 'node')),
|
|
{ signal }
|
|
)
|
|
}
|
|
|
|
// Why: write .version via SFTP not shell to avoid quoting content-hashed versions; the daemon reads it to validate the wire handshake.
|
|
await writeRelayFile(
|
|
conn,
|
|
hostPlatform,
|
|
joinRemotePath(hostPlatform, remoteDir, '.version'),
|
|
fullVersion,
|
|
{
|
|
signal,
|
|
sftpNamespace: stage?.namespace
|
|
? relayUploadStageSftpNamespaceMapping(
|
|
stage.namespace,
|
|
hostPlatform,
|
|
stage.rootDir,
|
|
'.version'
|
|
)
|
|
: undefined
|
|
}
|
|
)
|
|
}
|
|
|
|
/**
|
|
* A marker is only meaningful where a split namespace can occur and where Orca
|
|
* owns the SFTP session: POSIX hosts reached over the bundled ssh2 transport.
|
|
*/
|
|
function createInstallNamespaceIfSupported(
|
|
conn: SshConnection,
|
|
hostPlatform: RemoteHostPlatform,
|
|
homeRelativeRelayDir: string
|
|
): RelayInstallNamespace | undefined {
|
|
if (isWindowsRemoteHost(hostPlatform)) {
|
|
return undefined
|
|
}
|
|
// A connection double without the transport accessor is an ssh2 connection.
|
|
const usesSystemSsh =
|
|
typeof conn.usesSystemSshTransport === 'function' ? conn.usesSystemSshTransport() : false
|
|
return usesSystemSsh ? undefined : createRelayInstallNamespace(homeRelativeRelayDir)
|
|
}
|
|
|
|
function uploadStageNamespaceIfSupported(
|
|
conn: SshConnection,
|
|
hostPlatform: RemoteHostPlatform,
|
|
namespace: RelayUploadStageNamespace
|
|
): RelayUploadStageNamespace | undefined {
|
|
if (isWindowsRemoteHost(hostPlatform)) {
|
|
return undefined
|
|
}
|
|
const usesSystemSsh =
|
|
typeof conn.usesSystemSshTransport === 'function' ? conn.usesSystemSshTransport() : false
|
|
return usesSystemSsh ? undefined : namespace
|
|
}
|
|
|
|
const NODE_PTY_VERSION = '1.1.0'
|
|
const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs'
|
|
const NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME = 'node-pty-1.1.0-master-cloexec-patch.cjs'
|
|
const NODE_PTY_CLOEXEC_STATUS_PREFIX = 'ORCA-NPTY-CLOEXEC:'
|
|
/**
|
|
* Whether the tree the patch left behind still leaks the pty master into every later child.
|
|
* `fixed` is the only outcome a shared cache entry may be published from.
|
|
*/
|
|
type NodePtyMasterCloexecOutcome = 'fixed' | 'unfixed'
|
|
/**
|
|
* The statuses that leave a non-leaking tree. Deliberately an allowlist, not a `failed:` denylist:
|
|
* the script's `skipped:` family is mixed. `skipped:not-linux` is a platform that never leaks, but
|
|
* `skipped:earlier-attempt-failed`, `skipped:no-compiled-build`, `skipped:unexpected-source` and
|
|
* the two `skipped:<errno>` forms all mean the patch was refused and the leaky build is still on
|
|
* disk -- indistinguishable from `failed:` as far as what gets published.
|
|
*/
|
|
const NODE_PTY_CLOEXEC_FIXED_STATUSES: ReadonlySet<string> = new Set([
|
|
'patched',
|
|
// The rebuild ran from patched source; only the isolation check could not observe the result.
|
|
// An unobservable check is not a failed patch, and treating it as one would disable the shared
|
|
// cache on every host without `lsof`.
|
|
'patched-unverified',
|
|
'already-patched',
|
|
// Unreachable while the platform gate below short-circuits first, but it is the one `skipped:`
|
|
// that means "nothing to fix" rather than "would not fix it".
|
|
'skipped:not-linux'
|
|
])
|
|
// Exported for the relay-native-dependency-coverage test, which asserts every
|
|
// native addon the relay bundle imports is either installed here or explicitly
|
|
// declared as degrading without it.
|
|
export const RELAY_NATIVE_DEPS = {
|
|
'node-pty': NODE_PTY_VERSION,
|
|
'@parcel/watcher': '2.5.6'
|
|
} as const
|
|
|
|
type RelayNativeDepName = keyof typeof RELAY_NATIVE_DEPS
|
|
const RELAY_NATIVE_DEP_NAMES = Object.keys(RELAY_NATIVE_DEPS) as RelayNativeDepName[]
|
|
const NATIVE_DEPS_MISSING_PREFIX = 'ORCA-NATIVE-DEPS-MISSING:'
|
|
|
|
// Why: npm 12 blocks dependency lifecycle scripts unless each exact package version is approved, even with ignore-scripts disabled.
|
|
const RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST = Object.fromEntries(
|
|
Object.entries(RELAY_NATIVE_DEPS).map(([name, version]) => [`${name}@${version}`, true])
|
|
)
|
|
|
|
function nativeDepsProbeJs(successToken: string): string {
|
|
// Why: node-pty's Windows wrapper defers conpty.node until first spawn, so require("node-pty") alone can't prove the binding is healthy.
|
|
const loadNodePty =
|
|
'require("node-pty"); require("node-pty/lib/utils").loadNativeModule(process.platform==="win32"&&Number(require("os").release().split(".")[2])>=18309?"conpty":"pty");' +
|
|
`if(process.platform==="win32"){require("./${NODE_PTY_CONSOLE_LIST_PATCH_FILENAME}").assertPatchedNodePtyConsoleListAgent(process.cwd())}`
|
|
return `(()=>{const missing=[];try{${loadNodePty}}catch{missing.push("node-pty")}try{require("@parcel/watcher")}catch{missing.push("@parcel/watcher")}if(missing.length){console.log("${NATIVE_DEPS_MISSING_PREFIX}"+missing.join(","));process.exitCode=1}else{console.log(${JSON.stringify(successToken)})}})()`
|
|
}
|
|
|
|
/**
|
|
* Which deps the probe *named* as unloadable, or `undefined` when the answer names none.
|
|
*
|
|
* Only the probe's own marker line is evidence about the deps. An answer without one (node never
|
|
* ran, was killed, exited before the script) says nothing, so it must not be read as "all of them" —
|
|
* that inference deleted both native modules on every reconnect of an affected host.
|
|
*/
|
|
function missingNativeDepsFromProbe(output: string): RelayNativeDepName[] | undefined {
|
|
const marker = output
|
|
.split(/\r?\n/)
|
|
.find((line) => line.trim().startsWith(NATIVE_DEPS_MISSING_PREFIX))
|
|
if (!marker) {
|
|
return undefined
|
|
}
|
|
const reported = marker.trim().slice(NATIVE_DEPS_MISSING_PREFIX.length).split(',')
|
|
const named = RELAY_NATIVE_DEP_NAMES.filter((name) => reported.includes(name))
|
|
return named.length > 0 ? named : undefined
|
|
}
|
|
|
|
/**
|
|
* `ok` — the probe answered and both deps loaded. `blocked` — the probe answered with a marker
|
|
* naming deps that failed to load. `unverifiable` — the probe never answered, or answered nothing
|
|
* that names a dep; both are evidence about the probe, not about the deps.
|
|
*
|
|
* Why `unverifiable` is not `blocked`: repairing on it does `rm -rf node_modules/node-pty` and a
|
|
* node-gyp source build (no Linux prebuild) against a relay that was never shown to be broken. An
|
|
* unparseable answer is the worse half of that — it is deterministic and per-host, so a node that
|
|
* cannot start (bad NODE_OPTIONS, OOM, exit 127) deleted both modules on every reconnect forever.
|
|
* Same verdict discipline as `src/main/orcad/node-pty-precondition.ts` and
|
|
* docs/reference/ssh-execution-boundary.md — loss of contact is not evidence.
|
|
*/
|
|
type RelayNativeDepsProbeStatus = 'ok' | 'blocked' | 'unverifiable'
|
|
|
|
async function probeRequiredNativeDeps(
|
|
conn: SshConnection,
|
|
remoteDir: string,
|
|
hostPlatform: RemoteHostPlatform,
|
|
nodePath: string,
|
|
signal?: AbortSignal
|
|
): Promise<{ status: RelayNativeDepsProbeStatus; missing: RelayNativeDepName[] }> {
|
|
const escapedNode = shellEscape(nodePath)
|
|
const probeJs = nativeDepsProbeJs('ORCA-NATIVE-DEPS-OK')
|
|
let probeStderr = ''
|
|
try {
|
|
const command = isWindowsRemoteHost(hostPlatform)
|
|
? commandWithNodePath(
|
|
hostPlatform,
|
|
nodePath,
|
|
remoteDir,
|
|
`try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(probeJs)}; if ($LASTEXITCODE -ne 0) { 'MISSING' } } catch { 'MISSING' }`
|
|
)
|
|
: // Why: no `2>/dev/null` — it discarded the only line that says why node never reached the
|
|
// script. stderr stays its own stream so it can't be mistaken for the verdict, mirroring
|
|
// src/main/orcad/node-pty-precondition.ts.
|
|
commandWithNodePath(
|
|
hostPlatform,
|
|
nodePath,
|
|
remoteDir,
|
|
`(${escapedNode} -e ${shellEscape(probeJs)} || echo MISSING)`
|
|
)
|
|
const probe = await execHostCommand(conn, hostPlatform, command, {
|
|
signal,
|
|
onStderr: (text) => {
|
|
probeStderr = text
|
|
}
|
|
})
|
|
if (probe.includes('ORCA-NATIVE-DEPS-OK')) {
|
|
return { status: 'ok', missing: [] }
|
|
}
|
|
const missing = missingNativeDepsFromProbe(probe)
|
|
if (!missing) {
|
|
console.warn(
|
|
`[ssh-relay][NATIVE-DEPS-PROBE-UNPARSEABLE] Probe at ${remoteDir} answered without naming a dep; launching as-is. stdout=${probe.trim().slice(-200)} stderr=${probeStderr.trim().slice(-500)}`
|
|
)
|
|
return { status: 'unverifiable', missing: [] }
|
|
}
|
|
return { status: 'blocked', missing }
|
|
} catch {
|
|
signal?.throwIfAborted()
|
|
// Why: an unanswered probe says nothing about the deps; reporting MISSING here reset and
|
|
// recompiled healthy relays, turning one dropped exec channel into a multi-minute reconnect.
|
|
return { status: 'unverifiable', missing: [] }
|
|
}
|
|
}
|
|
|
|
async function repairInstalledNativeDeps(
|
|
conn: SshConnection,
|
|
remoteDir: string,
|
|
platform: RelayPlatform,
|
|
hostPlatform: RemoteHostPlatform,
|
|
nodePath: string,
|
|
homeRelativeRelayDir: string,
|
|
signal?: AbortSignal
|
|
): Promise<{
|
|
ownsInstallLock: boolean
|
|
gcClaimToken?: string
|
|
sftpNamespace?: RelayInstallNamespace
|
|
}> {
|
|
const initialProbe = await probeRequiredNativeDeps(
|
|
conn,
|
|
remoteDir,
|
|
hostPlatform,
|
|
nodePath,
|
|
signal
|
|
)
|
|
const lockResult = await tryAcquireRelayRepairLock(conn, remoteDir, hostPlatform, { signal })
|
|
if (lockResult === 'gc') {
|
|
throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform)
|
|
}
|
|
if (lockResult === 'acquired') {
|
|
let stillInstalled: boolean
|
|
try {
|
|
stillInstalled = await isRelayAlreadyInstalled(conn, remoteDir, hostPlatform, {
|
|
rethrowSessionLimitErrors: true,
|
|
signal
|
|
})
|
|
} catch (err) {
|
|
await abandonInstall(conn, remoteDir, hostPlatform)
|
|
throw err
|
|
}
|
|
if (!stillInstalled) {
|
|
// Why: GC may finish its rename before our lock recreates the path; never trust probes made before this locked recheck.
|
|
await abandonInstall(conn, remoteDir, hostPlatform)
|
|
throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform)
|
|
}
|
|
}
|
|
const gcClaimToken =
|
|
lockResult === 'busy' || lockResult === 'error'
|
|
? await acquireRelayLaunchGcFence(conn, remoteDir, hostPlatform, signal)
|
|
: undefined
|
|
// Why: only a probe that answered may trigger repair; an unverifiable one launches as-is and the next reconnect re-probes.
|
|
if (initialProbe.status !== 'blocked') {
|
|
// Why: even a healthy reconnect stays fenced until launch liveness is observable, or cross-version GC can rename after this probe.
|
|
if (lockResult !== 'acquired') {
|
|
return { ownsInstallLock: false, gcClaimToken }
|
|
}
|
|
try {
|
|
return {
|
|
ownsInstallLock: true,
|
|
sftpNamespace: await createRelayLaunchNamespace(
|
|
conn,
|
|
hostPlatform,
|
|
remoteDir,
|
|
homeRelativeRelayDir,
|
|
signal
|
|
)
|
|
}
|
|
} catch (err) {
|
|
signal?.throwIfAborted()
|
|
console.warn(
|
|
`[ssh-relay] Launch namespace marker is unconfirmed at ${remoteDir}; deferring lock ownership to stale recovery`
|
|
)
|
|
return { ownsInstallLock: !isUnconfirmedSshCommandTermination(err) }
|
|
}
|
|
}
|
|
|
|
// Why: an already-installed relay can launch degraded, so native-deps repair is best-effort — lock contention and failures must not abort the connection.
|
|
console.warn(`[ssh-relay] Repairing missing native deps at ${remoteDir}`)
|
|
if (lockResult === 'busy' || lockResult === 'error') {
|
|
console.warn(
|
|
`[ssh-relay] Native-deps repair lock is ${lockResult} at ${remoteDir}; launching degraded`
|
|
)
|
|
return { ownsInstallLock: false, gcClaimToken }
|
|
}
|
|
try {
|
|
// Why: older complete relay dirs predate @parcel/watcher; re-probe under the lock so only one reconnect mutates the dir.
|
|
const probe = await probeRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal)
|
|
let repairNamespace: RelayInstallNamespace | undefined
|
|
if (probe.status !== 'ok') {
|
|
// Why: the locked re-probe can only narrow the repair; when it can't answer, the initial probe's answered evidence still stands.
|
|
const resetDeps = probe.status === 'unverifiable' ? initialProbe.missing : probe.missing
|
|
// Why: only stamp ownership once the locked recheck proves this connection is the one about to write.
|
|
repairNamespace = await createRelayLaunchNamespace(
|
|
conn,
|
|
hostPlatform,
|
|
remoteDir,
|
|
homeRelativeRelayDir,
|
|
signal
|
|
)
|
|
await installNativeDeps(
|
|
conn,
|
|
remoteDir,
|
|
platform,
|
|
hostPlatform,
|
|
nodePath,
|
|
signal,
|
|
resetDeps,
|
|
repairNamespace
|
|
)
|
|
await finalizeInstall(conn, remoteDir, hostPlatform, { signal, releaseLock: false })
|
|
}
|
|
return { ownsInstallLock: true, sftpNamespace: repairNamespace }
|
|
} catch (err) {
|
|
const terminationUnconfirmed = isUnconfirmedSshCommandTermination(err)
|
|
// Why: hold a confirmed-failure lock through degraded launch so GC can't move the relay before liveness is visible.
|
|
// Why: unconfirmed remote mutation keeps its stale-recoverable lock beyond this connection.
|
|
console.warn(
|
|
`[ssh-relay] Native deps repair failed at ${remoteDir}; launching degraded: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}`
|
|
)
|
|
return { ownsInstallLock: !terminationUnconfirmed }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stamp this connection as the launch writer while it owns the install lock.
|
|
* Confirmed marker failures fall back to shell-path credential generation.
|
|
*/
|
|
async function createRelayLaunchNamespace(
|
|
conn: SshConnection,
|
|
hostPlatform: RemoteHostPlatform,
|
|
remoteDir: string,
|
|
homeRelativeRelayDir: string,
|
|
signal?: AbortSignal
|
|
): Promise<RelayInstallNamespace | undefined> {
|
|
const namespace = createInstallNamespaceIfSupported(conn, hostPlatform, homeRelativeRelayDir)
|
|
if (!namespace) {
|
|
return undefined
|
|
}
|
|
try {
|
|
await execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
createRelayInstallMarkerCommand(namespace, hostPlatform, remoteDir),
|
|
{ signal }
|
|
)
|
|
return namespace
|
|
} catch (err) {
|
|
// Why: an unconfirmed termination still owes the caller its lock semantics; only a confirmed failure degrades to shell paths.
|
|
if (isUnconfirmedSshCommandTermination(err)) {
|
|
throw err
|
|
}
|
|
signal?.throwIfAborted()
|
|
console.warn(
|
|
`[ssh-relay] SFTP namespace marker unavailable at ${remoteDir}; retaining shell paths`
|
|
)
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
async function acquireRelayLaunchGcFence(
|
|
conn: SshConnection,
|
|
remoteDir: string,
|
|
hostPlatform: RemoteHostPlatform,
|
|
signal?: AbortSignal
|
|
): Promise<string> {
|
|
const token = await tryAcquireRelayGcClaim(conn, remoteDir, hostPlatform, signal)
|
|
if (!token) {
|
|
signal?.throwIfAborted()
|
|
throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform)
|
|
}
|
|
try {
|
|
signal?.throwIfAborted()
|
|
const stillInstalled = await isRelayAlreadyInstalled(conn, remoteDir, hostPlatform, {
|
|
rethrowSessionLimitErrors: true,
|
|
signal
|
|
})
|
|
if (!stillInstalled) {
|
|
throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform)
|
|
}
|
|
// Why: a caller without the install lock still needs its own durable fence; never borrow another connection's lock through launch.
|
|
return token
|
|
} catch (err) {
|
|
await releaseRelayGcClaimWithRetry(conn, remoteDir, token, hostPlatform)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// Why: node-pty and @parcel/watcher are native addons esbuild can't bundle; install them on the remote against its Node/OS.
|
|
// TODO(#1693): ship per-platform tarballs with node-pty prebuilt from CI to skip remote npm install.
|
|
async function installNativeDeps(
|
|
conn: SshConnection,
|
|
remoteDir: string,
|
|
platform: RelayPlatform,
|
|
hostPlatform: RemoteHostPlatform,
|
|
nodePath: string,
|
|
signal?: AbortSignal,
|
|
resetDeps: RelayNativeDepName[] = [],
|
|
namespace?: RelayInstallNamespace,
|
|
remoteHome?: string
|
|
): Promise<void> {
|
|
// Why a repair opts out: reset does `rm -rf node_modules/node-pty`, and through a shared
|
|
// symlink that is every relay on the host losing its addon. Repairs detach and install
|
|
// privately instead (the install command's own prefix drops the link).
|
|
const localRelayDir = resetDeps.length === 0 && remoteHome ? getLocalRelayPath(platform) : null
|
|
const cacheContext: RelayNativeDepsCacheContext | null =
|
|
remoteHome && localRelayDir
|
|
? {
|
|
hostPlatform,
|
|
remoteHome,
|
|
relayDir: remoteDir,
|
|
platform,
|
|
localRelayDir,
|
|
deps: RELAY_NATIVE_DEPS,
|
|
signal
|
|
}
|
|
: null
|
|
const cache = cacheContext ? await attachRelayNativeDepsCache(conn, cacheContext) : null
|
|
|
|
const writeRelayPackageJson = async (deps: Record<string, string>): Promise<void> => {
|
|
await writeRelayFile(
|
|
conn,
|
|
hostPlatform,
|
|
joinRemotePath(hostPlatform, remoteDir, 'package.json'),
|
|
`${JSON.stringify({
|
|
name: 'orca-relay',
|
|
version: '1.0.0',
|
|
private: true,
|
|
type: 'commonjs',
|
|
dependencies: deps,
|
|
allowScripts: RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST
|
|
})}\n`,
|
|
{
|
|
signal,
|
|
sftpNamespace: namespace
|
|
? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir, 'package.json')
|
|
: undefined
|
|
}
|
|
)
|
|
}
|
|
|
|
// Why: node-pty's prebuild spawns `node` as a child, so node must be in PATH (commandWithNodePath) or it fails exit 127.
|
|
// Why: npm init -y rejects '+' in content-hashed dir names, so write a fixed minimal package.json instead.
|
|
// Why: type:commonjs pins module resolution against Node default flips or a remote ~/.npmrc type=module.
|
|
await writeRelayPackageJson(RELAY_NATIVE_DEPS)
|
|
|
|
if (cache?.mode === 'linked') {
|
|
await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal)
|
|
const linkedProbe = await probeInstalledNativeDeps(
|
|
conn,
|
|
remoteDir,
|
|
hostPlatform,
|
|
nodePath,
|
|
signal
|
|
)
|
|
if (linkedProbe.available) {
|
|
return
|
|
}
|
|
// Why fall through rather than repair the entry: it is shared, and something else on this
|
|
// host may be running out of it right now. This directory installs its own copy instead.
|
|
console.warn(
|
|
`[ssh-relay][NATIVE-CACHE-UNUSABLE] shared entry ${cache.key} did not load at ${remoteDir} (${platform}); installing per-directory. stderr=${linkedProbe.stderr.trim().slice(-500)}`
|
|
)
|
|
}
|
|
|
|
try {
|
|
const installArgs = Object.entries(RELAY_NATIVE_DEPS)
|
|
.map(([dep, version]) => shellEscape(`${dep}@${version}`))
|
|
.join(' ')
|
|
// Why: npm reports a present package as up to date even if a native file was deleted; reset only deps the probe found broken.
|
|
const resetCommand = resetNativeDepsCommand(hostPlatform, resetDeps)
|
|
const resetPrefix = `${detachSharedNativeDepsCommand(hostPlatform)}${resetCommand ? `${resetCommand}; ` : ''}`
|
|
const command = isWindowsRemoteHost(hostPlatform)
|
|
? commandWithNodePath(
|
|
hostPlatform,
|
|
nodePath,
|
|
remoteDir,
|
|
`${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${Object.entries(
|
|
RELAY_NATIVE_DEPS
|
|
)
|
|
.map(([dep, version]) => powerShellLiteral(`${dep}@${version}`))
|
|
.join(
|
|
' '
|
|
)}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; ${windowsNodePtyPatchCommand(nodePath)}`
|
|
)
|
|
: commandWithNodePath(
|
|
hostPlatform,
|
|
nodePath,
|
|
remoteDir,
|
|
`${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1`
|
|
)
|
|
await execHostCommand(conn, hostPlatform, command, {
|
|
timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS,
|
|
signal
|
|
})
|
|
} catch (err) {
|
|
if (isUnconfirmedSshCommandTermination(err)) {
|
|
throw err
|
|
}
|
|
signal?.throwIfAborted()
|
|
// Don't write .install-complete on hard fail so reconnect retries the partial install; greppable token aids bug reports.
|
|
const msg = (err as Error).message
|
|
console.warn(
|
|
`[ssh-relay][NATIVE-DEPS-INSTALL-FAIL] npm install native deps failed at ${remoteDir} (${platform}): ${msg}`
|
|
)
|
|
// Why: on Linux node-pty compiles, so a missing C/C++ toolchain is the dominant first-connect failure (#1693); probe to give an actionable install hint.
|
|
if (platform.startsWith('linux') && shouldProbeBuildToolchainAfterNativeDepsFailure(msg)) {
|
|
const toolchain = await probeBuildToolchain(conn, hostPlatform, signal)
|
|
if (toolchain?.toolchainMissing) {
|
|
// Why: node-pty is the only dep that needs a compiler, and it only backs terminals. Retry
|
|
// without it so files/git/editor still connect instead of failing the host outright; a
|
|
// missing native dep is already non-fatal below. Rethrow the actionable error if even that
|
|
// fails, so a host broken for some other reason still reports the toolchain gap.
|
|
console.warn(
|
|
`[ssh-relay][NPTY-SKIP-NO-TOOLCHAIN] ${remoteDir} (${platform}): ${formatSkippedNodePtyWarning(toolchain)}`
|
|
)
|
|
try {
|
|
await installNativeDepsWithoutNodePty(
|
|
conn,
|
|
remoteDir,
|
|
hostPlatform,
|
|
nodePath,
|
|
writeRelayPackageJson,
|
|
resetDeps,
|
|
signal
|
|
)
|
|
} catch (retryErr) {
|
|
if (isUnconfirmedSshCommandTermination(retryErr)) {
|
|
throw retryErr
|
|
}
|
|
signal?.throwIfAborted()
|
|
// The thrown toolchain message is built from the original error, so log the retry's own
|
|
// cause (registry, ENOSPC, EACCES) rather than losing it.
|
|
console.warn(
|
|
`[ssh-relay][NPTY-SKIP-RETRY-FAIL] node-pty-less reinstall failed at ${remoteDir} (${platform}): ${(retryErr as Error).message}`
|
|
)
|
|
throw new Error(formatMissingToolchainError(toolchain, msg), { cause: retryErr })
|
|
}
|
|
// Why: this early return skips the probe below, so verify the dep that does have a prebuilt —
|
|
// a @parcel/watcher that installs but can't load would leave file watching silently dead.
|
|
await warnIfWatcherUnloadableWithoutNodePty(
|
|
conn,
|
|
remoteDir,
|
|
platform,
|
|
hostPlatform,
|
|
nodePath,
|
|
signal
|
|
)
|
|
return
|
|
}
|
|
}
|
|
throw err
|
|
}
|
|
|
|
await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal)
|
|
|
|
let probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal)
|
|
if (!probe.available) {
|
|
// Why: npm treats an already-present package as up to date, so re-enabling lifecycle scripts on install can't repair a skipped binding.
|
|
console.warn(`[ssh-relay] Rebuilding unloadable native deps at ${remoteDir}`)
|
|
let rebuilt = false
|
|
try {
|
|
await rebuildNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal)
|
|
rebuilt = true
|
|
} catch (err) {
|
|
if (isUnconfirmedSshCommandTermination(err)) {
|
|
throw err
|
|
}
|
|
signal?.throwIfAborted()
|
|
console.warn(
|
|
`[ssh-relay][NATIVE-DEPS-REBUILD-FAIL] npm rebuild native deps failed at ${remoteDir} (${platform}): ${(err as Error).message}`
|
|
)
|
|
}
|
|
signal?.throwIfAborted()
|
|
if (rebuilt) {
|
|
await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal)
|
|
probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal)
|
|
}
|
|
}
|
|
|
|
// Why this precedes promotion: the patch renames `node-pty/build/Release`, runs `npm rebuild`
|
|
// and rolls back inside `node_modules`, and promotion turns that directory into a symlink to a
|
|
// published -- and by contract immutable -- shared cache entry. Patching afterwards would write
|
|
// through the link, and `.deps-complete` would already have published an unpatched tree that
|
|
// every later host links and skips.
|
|
const cloexec = probe.available
|
|
? await applyNodePtyMasterCloexecPatch(
|
|
conn,
|
|
remoteDir,
|
|
platform,
|
|
hostPlatform,
|
|
nodePath,
|
|
signal
|
|
)
|
|
: 'unfixed'
|
|
|
|
// Why promotion is gated on the probe and not on npm's exit code: an entry is shared, so the
|
|
// only evidence worth publishing is this host having loaded both addons out of that tree.
|
|
// Why it is gated on the patch too: a refused or rolled-back patch leaves the pre-patch leaky
|
|
// build in place, and the cache key hashes this patch's bytes -- so publishing it would hand
|
|
// every later host on the machine a tree that links, probes loadable, and skips patching.
|
|
if (probe.available && cacheContext && cache) {
|
|
if (cloexec === 'fixed') {
|
|
await promoteRelayNativeDepsCache(conn, cacheContext, cache.key)
|
|
} else {
|
|
console.warn(
|
|
`[ssh-relay][NPTY-CLOEXEC-UNSHARED] keeping the native deps at ${remoteDir} (${platform}) private; the tree still leaks the pty master, so it is not publishable as ${cache.key}`
|
|
)
|
|
}
|
|
}
|
|
|
|
// MISSING is non-fatal by design: the relay still serves fs/git/preflight; only native-backed ops fail on hosts that can't build the addons.
|
|
if (!probe.available) {
|
|
console.warn(
|
|
`[ssh-relay][NPTY-MISSING] native deps installed but require() failed at ${remoteDir} (${platform}). stdout=${probe.output.trim().slice(-200)} stderr=${probe.stderr.trim().slice(-500)}`
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-apply the pty-master FD_CLOEXEC patch the app gets from pnpm to the host's npm copy (#17915).
|
|
*
|
|
* Why it is safe to rebuild under a live relay: this only runs from installNativeDeps, so only on a
|
|
* freshly created directory or a locked repair, and a relay already serving PTYs has pty.node mapped
|
|
* -- replacing the file on disk does not touch the running process. It keeps the build it started
|
|
* with and picks up the patched one when it restarts.
|
|
*
|
|
* Why it is bounded: the remote script attempts the compile at most once per relay directory, and
|
|
* the directory is content-hashed over the relay manifest -- so at most one compile per bundle.
|
|
*
|
|
* Why a shared cache entry never reaches here: the caller returns as soon as a linked tree probes
|
|
* loadable, so this only ever rewrites a `node_modules` the relay directory still owns privately.
|
|
*
|
|
* Returns whether the tree that is left behind still leaks, which is what decides publishability.
|
|
* The script exits 0 on every outcome by design, so the status line is the only evidence there is.
|
|
*/
|
|
async function applyNodePtyMasterCloexecPatch(
|
|
conn: SshConnection,
|
|
remoteDir: string,
|
|
platform: RelayPlatform,
|
|
hostPlatform: RemoteHostPlatform,
|
|
nodePath: string,
|
|
signal?: AbortSignal
|
|
): Promise<NodePtyMasterCloexecOutcome> {
|
|
// Linux is the only relay platform that takes forkpty()'s no-O_CLOEXEC path; macOS and Windows
|
|
// ship prebuilds, so forcing a rebuild there would add a first compile to fix nothing.
|
|
if (isWindowsRemoteHost(hostPlatform) || !platform.startsWith('linux')) {
|
|
return 'fixed'
|
|
}
|
|
try {
|
|
const command = commandWithNodePath(
|
|
hostPlatform,
|
|
nodePath,
|
|
remoteDir,
|
|
`${shellEscape(nodePath)} ${shellEscape(NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME)} 2>&1`
|
|
)
|
|
const output = await execHostCommand(conn, hostPlatform, command, {
|
|
timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS,
|
|
signal
|
|
})
|
|
const status =
|
|
output
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.find((line) => line.startsWith(NODE_PTY_CLOEXEC_STATUS_PREFIX))
|
|
?.slice(NODE_PTY_CLOEXEC_STATUS_PREFIX.length) ?? 'no-status'
|
|
if (!NODE_PTY_CLOEXEC_FIXED_STATUSES.has(status)) {
|
|
// Warn, not log: the script exits 0 on a refusal too, so this line is the only thing that
|
|
// says the relay directory will leak a master into every child for its whole life.
|
|
console.warn(
|
|
`[ssh-relay][NPTY-CLOEXEC-UNFIXED] pty master still leaks at ${remoteDir} (${platform}): ${status}`
|
|
)
|
|
return 'unfixed'
|
|
}
|
|
console.log(`[ssh-relay][NPTY-CLOEXEC] ${remoteDir} (${platform}): ${status}`)
|
|
return 'fixed'
|
|
} catch (err) {
|
|
signal?.throwIfAborted()
|
|
// Never fatal: the script restores the working build itself, and a leaky relay beats none. An
|
|
// interrupted rebuild leaves node-pty unloadable, which the existing repair path reinstalls.
|
|
console.warn(
|
|
`[ssh-relay][NPTY-CLOEXEC-FAIL] pty master cloexec patch failed at ${remoteDir} (${platform}): ${(err as Error).message}`
|
|
)
|
|
// An exec that never answered cannot say which build is on disk, and a tree nobody can vouch
|
|
// for is exactly the one not to share.
|
|
return 'unfixed'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drop a shared-cache symlink before anything writes into `node_modules`.
|
|
*
|
|
* Why it prefixes every install rather than living in its own exec: `rm -rf node_modules/node-pty`
|
|
* and `npm install` both follow the link, so a repair on one relay directory would otherwise
|
|
* rewrite the tree every other relay on the host is running out of.
|
|
*/
|
|
function detachSharedNativeDepsCommand(hostPlatform: RemoteHostPlatform): string {
|
|
if (isWindowsRemoteHost(hostPlatform)) {
|
|
return ''
|
|
}
|
|
return 'if [ -L node_modules ]; then rm -f node_modules; fi; '
|
|
}
|
|
|
|
function resetNativeDepsCommand(
|
|
hostPlatform: RemoteHostPlatform,
|
|
resetDeps: RelayNativeDepName[]
|
|
): string {
|
|
if (resetDeps.length === 0) {
|
|
return ''
|
|
}
|
|
const resetNodePty = resetDeps.includes('node-pty')
|
|
const resetWatcher = resetDeps.includes('@parcel/watcher')
|
|
if (isWindowsRemoteHost(hostPlatform)) {
|
|
const commands: string[] = []
|
|
if (resetNodePty) {
|
|
commands.push(
|
|
`Remove-Item -LiteralPath ${powerShellLiteral('node_modules/node-pty')} -Recurse -Force -ErrorAction SilentlyContinue`
|
|
)
|
|
}
|
|
if (resetWatcher) {
|
|
commands.push(
|
|
`Remove-Item -LiteralPath ${powerShellLiteral('node_modules/@parcel/watcher')} -Recurse -Force -ErrorAction SilentlyContinue`,
|
|
`$parcelScope = ${powerShellLiteral('node_modules/@parcel')}`,
|
|
`Get-ChildItem -LiteralPath $parcelScope -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name.StartsWith('watcher-', [StringComparison]::Ordinal) } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue`
|
|
)
|
|
}
|
|
return commands.join('; ')
|
|
}
|
|
const commands: string[] = []
|
|
if (resetNodePty) {
|
|
commands.push(`rm -rf ${shellEscape('node_modules/node-pty')}`)
|
|
}
|
|
if (resetWatcher) {
|
|
commands.push(
|
|
`rm -rf ${shellEscape('node_modules/@parcel/watcher')}`,
|
|
`find ${shellEscape('node_modules/@parcel')} -maxdepth 1 -name 'watcher-*' -exec rm -rf {} + 2>/dev/null || true`
|
|
)
|
|
}
|
|
return commands.join('; ')
|
|
}
|
|
|
|
/**
|
|
* Reinstall the relay's native deps with node-pty dropped, for hosts that cannot compile it.
|
|
*
|
|
* Why: npm reconciles every dependency in package.json, not just the ones named on the command
|
|
* line, so node-pty has to leave the manifest too — naming only @parcel/watcher still rebuilds it.
|
|
*/
|
|
async function installNativeDepsWithoutNodePty(
|
|
conn: SshConnection,
|
|
remoteDir: string,
|
|
hostPlatform: RemoteHostPlatform,
|
|
nodePath: string,
|
|
writeRelayPackageJson: (deps: Record<string, string>) => Promise<void>,
|
|
resetDeps: RelayNativeDepName[],
|
|
signal?: AbortSignal
|
|
): Promise<void> {
|
|
const deps = Object.fromEntries(
|
|
Object.entries(RELAY_NATIVE_DEPS).filter(([dep]) => dep !== 'node-pty')
|
|
)
|
|
await writeRelayPackageJson(deps)
|
|
const installArgs = Object.entries(deps)
|
|
.map(([dep, version]) => shellEscape(`${dep}@${version}`))
|
|
.join(' ')
|
|
// Why: the failed attempt leaves an unbuildable node-pty behind; clear it so npm prunes rather
|
|
// than rebuilds it. Keep the caller's resets too — a repair reconnect still needs them.
|
|
const resetCommand = resetNativeDepsCommand(hostPlatform, [
|
|
...new Set<RelayNativeDepName>([...resetDeps, 'node-pty'])
|
|
])
|
|
// Why: POSIX-only command shape (`;` chaining, `2>&1`) — safe because the only caller is gated on a
|
|
// linux platform. Widen that gate and this needs the Windows branch installNativeDeps already has.
|
|
await execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
commandWithNodePath(
|
|
hostPlatform,
|
|
nodePath,
|
|
remoteDir,
|
|
`${detachSharedNativeDepsCommand(hostPlatform)}${resetCommand}; npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1`
|
|
),
|
|
{ timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, signal }
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Report an unloadable @parcel/watcher after node-pty was skipped, without failing the connection.
|
|
*
|
|
* Why: node-pty is expected missing here, but a @parcel/watcher prebuilt that installs and still
|
|
* can't require() (glibc below the floor — docs/reference/linux-glibc-compatibility.md) means dead
|
|
* file watching. No rebuild: node-pty provably can't compile on this host, so it would only fail.
|
|
*/
|
|
async function warnIfWatcherUnloadableWithoutNodePty(
|
|
conn: SshConnection,
|
|
remoteDir: string,
|
|
platform: RelayPlatform,
|
|
hostPlatform: RemoteHostPlatform,
|
|
nodePath: string,
|
|
signal?: AbortSignal
|
|
): Promise<void> {
|
|
try {
|
|
const probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal)
|
|
if (probe.missing.includes('@parcel/watcher')) {
|
|
console.warn(
|
|
`[ssh-relay][WATCHER-MISSING-NPTY-SKIPPED] @parcel/watcher installed but require() failed at ${remoteDir} (${platform}); remote file watching is unavailable. stdout=${probe.output.trim().slice(-200)} stderr=${probe.stderr.trim().slice(-500)}`
|
|
)
|
|
}
|
|
} catch (err) {
|
|
if (isUnconfirmedSshCommandTermination(err)) {
|
|
throw err
|
|
}
|
|
signal?.throwIfAborted()
|
|
// Degraded-mode diagnostics must never cost the connection the retry just salvaged.
|
|
console.warn(
|
|
`[ssh-relay][WATCHER-PROBE-FAIL] native deps probe failed after skipping node-pty at ${remoteDir} (${platform}): ${(err as Error).message}`
|
|
)
|
|
}
|
|
}
|
|
|
|
async function rebuildNativeDeps(
|
|
conn: SshConnection,
|
|
remoteDir: string,
|
|
hostPlatform: RemoteHostPlatform,
|
|
nodePath: string,
|
|
signal?: AbortSignal
|
|
): Promise<void> {
|
|
const depNames = Object.keys(RELAY_NATIVE_DEPS)
|
|
const command = isWindowsRemoteHost(hostPlatform)
|
|
? commandWithNodePath(
|
|
hostPlatform,
|
|
nodePath,
|
|
remoteDir,
|
|
`npm rebuild --ignore-scripts=false ${depNames.map(powerShellLiteral).join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; ${windowsNodePtyPatchCommand(nodePath)}`
|
|
)
|
|
: commandWithNodePath(
|
|
hostPlatform,
|
|
nodePath,
|
|
remoteDir,
|
|
`npm rebuild --ignore-scripts=false ${depNames.map(shellEscape).join(' ')} 2>&1`
|
|
)
|
|
await execHostCommand(conn, hostPlatform, command, {
|
|
timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS,
|
|
signal
|
|
})
|
|
}
|
|
|
|
function windowsNodePtyPatchCommand(nodePath: string): string {
|
|
// Why: pnpm patches do not cross the SSH boundary; apply the version-checked fallback to the remote npm package.
|
|
return `& ${powerShellLiteral(nodePath)} ${powerShellLiteral(NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }`
|
|
}
|
|
|
|
async function makeNodePtySpawnHelperExecutable(
|
|
conn: SshConnection,
|
|
remoteDir: string,
|
|
hostPlatform: RemoteHostPlatform,
|
|
signal?: AbortSignal
|
|
): Promise<void> {
|
|
if (isWindowsRemoteHost(hostPlatform)) {
|
|
return
|
|
}
|
|
// SFTP doesn't preserve execute bits; node-pty's spawn-helper must be +x for posix_spawnp.
|
|
await execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
`find ${shellEscape(joinRemotePath(hostPlatform, remoteDir, 'node_modules/node-pty/prebuilds'))} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true`,
|
|
{ signal }
|
|
)
|
|
}
|
|
|
|
async function probeInstalledNativeDeps(
|
|
conn: SshConnection,
|
|
remoteDir: string,
|
|
hostPlatform: RemoteHostPlatform,
|
|
nodePath: string,
|
|
signal?: AbortSignal
|
|
): Promise<{
|
|
available: boolean
|
|
missing: RelayNativeDepName[]
|
|
output: string
|
|
stderr: string
|
|
}> {
|
|
// require() catches unloadable installs (wrong arch, missing prebuild, skipped lifecycle script) that require.resolve() and test -d miss.
|
|
const PROBE_OK = 'ORCA-NPTY-PROBE-OK'
|
|
const stderrFile = joinRemotePath(hostPlatform, remoteDir, '.npty-probe.stderr')
|
|
const escapedStderr = shellEscape(stderrFile)
|
|
const probeJs = nativeDepsProbeJs(PROBE_OK)
|
|
const probeCommand = isWindowsRemoteHost(hostPlatform)
|
|
? commandWithNodePath(
|
|
hostPlatform,
|
|
nodePath,
|
|
remoteDir,
|
|
`try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(probeJs)} ${powerShellLiteral(PROBE_OK)}; if ($LASTEXITCODE -ne 0) { 'MISSING' } } catch { 'MISSING' }`
|
|
)
|
|
: commandWithNodePath(
|
|
hostPlatform,
|
|
nodePath,
|
|
remoteDir,
|
|
`(${shellEscape(nodePath)} -e ${shellEscape(probeJs)} ${shellEscape(PROBE_OK)} 2>${escapedStderr} || echo MISSING)`
|
|
)
|
|
const probeOutput = await execHostCommand(conn, hostPlatform, probeCommand, { signal })
|
|
const remoteStderr =
|
|
probeOutput.includes(PROBE_OK) || isWindowsRemoteHost(hostPlatform)
|
|
? ''
|
|
: await execHostCommand(conn, hostPlatform, `cat ${escapedStderr} 2>/dev/null; true`, {
|
|
signal
|
|
}).catch(() => '')
|
|
signal?.throwIfAborted()
|
|
if (!isWindowsRemoteHost(hostPlatform)) {
|
|
// The POSIX probe redirects stderr to this file; the Windows probe does not.
|
|
await execHostCommand(conn, hostPlatform, removeRemoteFileCommand(hostPlatform, stderrFile), {
|
|
signal
|
|
}).catch(() => {})
|
|
signal?.throwIfAborted()
|
|
}
|
|
return {
|
|
available: probeOutput.includes(PROBE_OK),
|
|
// A markerless answer names no dep, so it reports none; `available` already carries the failure.
|
|
missing: probeOutput.includes(PROBE_OK) ? [] : (missingNativeDepsFromProbe(probeOutput) ?? []),
|
|
output: probeOutput,
|
|
stderr: remoteStderr
|
|
}
|
|
}
|
|
|
|
function getLocalRelayPath(platform: RelayPlatform): string | null {
|
|
for (const candidate of getLocalRelayCandidates(platform)) {
|
|
if (existsSync(candidate)) {
|
|
return candidate
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function getLocalRelayCandidates(platform: RelayPlatform): string[] {
|
|
const candidates: string[] = []
|
|
if (process.env.ORCA_RELAY_PATH) {
|
|
candidates.push(join(process.env.ORCA_RELAY_PATH, platform))
|
|
}
|
|
|
|
// Why: electron-builder copies extraResources next to the app bundle, but app.getAppPath() points at app.asar in packaged builds.
|
|
if (process.resourcesPath) {
|
|
candidates.push(join(process.resourcesPath, 'relay', platform))
|
|
candidates.push(join(process.resourcesPath, 'app.asar.unpacked', 'out', 'relay', platform))
|
|
}
|
|
|
|
const appPath = app.getAppPath()
|
|
candidates.push(
|
|
join(appPath, 'resources', 'relay', platform),
|
|
join(appPath, 'out', 'relay', platform)
|
|
)
|
|
|
|
return [...new Set(candidates)]
|
|
}
|
|
|
|
async function launchRelay(
|
|
conn: SshConnection,
|
|
remoteDir: string,
|
|
hostPlatform: RemoteHostPlatform,
|
|
nodePath: string,
|
|
graceTimeSeconds?: number,
|
|
relayInstanceId?: string,
|
|
signal?: AbortSignal
|
|
): Promise<{
|
|
transport: MultiplexerTransport
|
|
nodePath: string
|
|
sockPath: string
|
|
credentialFile: string
|
|
}> {
|
|
// Why: graceTimeSeconds comes from user-editable SshTarget config; floor+clamp to an integer prevents shell injection if the type ever loosened.
|
|
const requestedGraceTime = Math.floor(graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS)
|
|
const graceTime =
|
|
requestedGraceTime === 0
|
|
? 0
|
|
: Math.max(
|
|
MIN_SSH_RELAY_GRACE_PERIOD_SECONDS,
|
|
Math.min(MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, requestedGraceTime)
|
|
)
|
|
const escapedDir = shellEscape(remoteDir)
|
|
const escapedNode = shellEscape(nodePath)
|
|
// Why: remoteRelayDir is shared across Orca targets for one account; hashing the target ID into the socket name stops cross-target attach.
|
|
const sockName = relaySocketNameForInstanceId(relayInstanceId)
|
|
const sockFile = relayEndpointForHost(hostPlatform, remoteDir, sockName)
|
|
const endpointDir = relayHookEndpointDirForHost(hostPlatform, remoteDir, sockFile)
|
|
const credentialFile = joinRemotePath(hostPlatform, remoteDir, `${sockName}.credential`)
|
|
|
|
if (isWindowsRemoteHost(hostPlatform)) {
|
|
const activePipeMarkerPath = windowsActivePipeMarkerPath(hostPlatform, remoteDir, sockName)
|
|
const discoveredActiveEndpoint = await readWindowsActiveRelayEndpoint(
|
|
conn,
|
|
hostPlatform,
|
|
remoteDir,
|
|
activePipeMarkerPath,
|
|
signal
|
|
)
|
|
const activeEndpoint = discoveredActiveEndpoint ?? {
|
|
sockPath: sockFile,
|
|
endpointDir
|
|
}
|
|
const fallbackEndpoint = buildWindowsRelayFallbackEndpoint(hostPlatform, remoteDir, sockName)
|
|
const launched = await launchWindowsRelay(
|
|
conn,
|
|
hostPlatform,
|
|
{
|
|
remoteDir,
|
|
nodePath,
|
|
sockPath: activeEndpoint.sockPath,
|
|
endpointDir: activeEndpoint.endpointDir,
|
|
graceTime,
|
|
activePipeMarkerPath,
|
|
reconnectFallback: fallbackEndpoint,
|
|
credentialFile
|
|
},
|
|
signal
|
|
)
|
|
return { ...launched, credentialFile }
|
|
}
|
|
|
|
// Why: after a restart the relay may still be alive in its grace period; --connect to its socket preserves PTY state and scrollback.
|
|
try {
|
|
const probeOutput = await execCommand(
|
|
conn,
|
|
`test -S ${shellEscape(sockFile)} && echo ALIVE || echo DEAD`,
|
|
{ signal }
|
|
)
|
|
console.warn(`[ssh-relay] Socket probe result: "${probeOutput.trim()}"`)
|
|
if (probeOutput.trim() === 'ALIVE') {
|
|
console.log('[ssh-relay] Existing relay socket found, attempting reconnect...')
|
|
try {
|
|
const channel = await conn.exec(
|
|
`cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)} --credential-file ${shellEscape(credentialFile)}`,
|
|
{ signal }
|
|
)
|
|
const transport = await waitForSentinel(channel, signal)
|
|
console.log('[ssh-relay] Reconnected to existing relay via socket')
|
|
return { transport, nodePath, sockPath: sockFile, credentialFile }
|
|
} catch (err) {
|
|
signal?.throwIfAborted()
|
|
console.warn(
|
|
'[ssh-relay] Socket reconnect failed, establishing what owns the endpoint:',
|
|
err instanceof Error ? err.message : String(err)
|
|
)
|
|
// Why not `rm -f`: unlinking does not close the listener the incumbent already holds,
|
|
// so a refused --connect (version mismatch, rotated credential) used to leave a live
|
|
// relay running forever with its PTYs while a replacement bound the same path (#8585).
|
|
await resolveRelayEndpointBeforeRelaunch(conn, hostPlatform, nodePath, sockFile, err, {
|
|
signal
|
|
})
|
|
signal?.throwIfAborted()
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (isUnconfirmedSshCommandTermination(err)) {
|
|
throw err
|
|
}
|
|
signal?.throwIfAborted()
|
|
// Probe failed — fall through to fresh launch
|
|
}
|
|
|
|
// Why: relay must outlive the SSH connection so PTY sessions survive app restarts — nohup + </dev/null + & detach it from the exec channel.
|
|
// Why: execCommand would block on channel close that backgrounded children never allow; fire-and-forget via conn.exec, the socket poll detects readiness.
|
|
const logFile = `${remoteDir}/relay.log`
|
|
await writeRelayEndpointCredential(conn, hostPlatform, nodePath, credentialFile, {
|
|
signal
|
|
})
|
|
// Why: --log-file lets the relay rotate relay.log in-process; the shell redirect stays to capture pre-JS boot/crash output.
|
|
const launchCmd = `cd ${escapedDir} && chmod 600 ${shellEscape(credentialFile)} && nohup ${escapedNode} relay.js --detached --grace-time ${graceTime} --sock-path ${shellEscape(sockFile)} --credential-file ${shellEscape(credentialFile)} --log-file ${shellEscape(logFile)} > ${shellEscape(logFile)} 2>&1 </dev/null &`
|
|
const launchChannel = await conn.exec(launchCmd, { signal })
|
|
launchChannel.on('data', () => {})
|
|
launchChannel.on('error', () => {})
|
|
launchChannel.stderr.on('data', () => {})
|
|
launchChannel.stderr.on('error', () => {})
|
|
// Why: the SSH channel stays open until all child fds close; close it after the poll or channels accumulate and hit the server's MaxSessions limit.
|
|
launchChannel.on('close', () => {})
|
|
|
|
// Why: poll rather than fixed sleep — remote host speed varies widely (CI vs. Raspberry Pi).
|
|
// Why: test -S only proves the inode exists, not that the relay is listening; a connect-and-close confirms it accepts connections.
|
|
const POLL_INTERVAL_MS = 200
|
|
const POLL_TIMEOUT_MS = 10_000
|
|
const pollStart = Date.now()
|
|
let socketReady = false
|
|
try {
|
|
while (Date.now() - pollStart < POLL_TIMEOUT_MS) {
|
|
try {
|
|
// Why: probe via node (guaranteed present) not python3/socat/perl; pass the socket path as argv[1] to dodge -e quoting issues.
|
|
const result = await execCommand(
|
|
conn,
|
|
`${escapedNode} -e 'var s=require("net").connect(process.argv[1]);s.on("connect",function(){s.destroy();process.stdout.write("READY")});s.on("error",function(){process.stdout.write("WAITING")})' ${shellEscape(sockFile)} 2>/dev/null || (test -S ${shellEscape(sockFile)} && echo READY || echo WAITING)`,
|
|
{ signal }
|
|
)
|
|
if (result.trim() === 'READY') {
|
|
socketReady = true
|
|
break
|
|
}
|
|
} catch {
|
|
signal?.throwIfAborted()
|
|
/* exec failed, retry */
|
|
}
|
|
await waitForRelayPoll(POLL_INTERVAL_MS, signal)
|
|
}
|
|
} finally {
|
|
launchChannel.close()
|
|
}
|
|
|
|
if (!socketReady) {
|
|
const logOutput = await execCommand(
|
|
conn,
|
|
`tail -20 ${shellEscape(logFile)} 2>/dev/null || echo "(no log)"`,
|
|
{ signal }
|
|
).catch(() => '(could not read log)')
|
|
signal?.throwIfAborted()
|
|
throw new Error(`Relay failed to start within ${POLL_TIMEOUT_MS / 1000}s. Log:\n${logOutput}`)
|
|
}
|
|
|
|
// Why: backgrounded relay's stdout goes to a log file, not the exec channel; --connect bridges this channel to its Unix socket.
|
|
const channel = await conn.exec(
|
|
`cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)} --credential-file ${shellEscape(credentialFile)}`,
|
|
{ signal }
|
|
)
|
|
return {
|
|
transport: await waitForSentinel(channel, signal),
|
|
nodePath,
|
|
sockPath: sockFile,
|
|
credentialFile
|
|
}
|
|
}
|
|
|
|
function waitForRelayPoll(delayMs: number, signal?: AbortSignal): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const onAbort = (): void => {
|
|
clearTimeout(timeout)
|
|
signal?.removeEventListener('abort', onAbort)
|
|
reject(createSshOperationAbortError())
|
|
}
|
|
const timeout = setTimeout(() => {
|
|
signal?.removeEventListener('abort', onAbort)
|
|
resolve()
|
|
}, delayMs)
|
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
if (signal?.aborted) {
|
|
onAbort()
|
|
}
|
|
})
|
|
}
|
|
|
|
function buildWindowsRelayFallbackEndpoint(
|
|
hostPlatform: RemoteHostPlatform,
|
|
remoteDir: string,
|
|
sockName: string
|
|
): WindowsRelayEndpoint {
|
|
const fallbackSockName = windowsRelayFallbackSocketName(sockName)
|
|
const sockPath = relayEndpointForHost(hostPlatform, remoteDir, fallbackSockName)
|
|
return {
|
|
sockPath,
|
|
endpointDir: relayHookEndpointDirForHost(hostPlatform, remoteDir, sockPath)
|
|
}
|
|
}
|
|
|
|
async function readWindowsActiveRelayEndpoint(
|
|
conn: SshConnection,
|
|
hostPlatform: RemoteHostPlatform,
|
|
remoteDir: string,
|
|
markerPath: string,
|
|
signal?: AbortSignal
|
|
): Promise<WindowsRelayEndpoint | null> {
|
|
const output = await execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
powerShellCommand(
|
|
`if (Test-Path -LiteralPath ${powerShellLiteral(markerPath)} -PathType Leaf) { Get-Content -LiteralPath ${powerShellLiteral(markerPath)} -Raw -ErrorAction SilentlyContinue }`
|
|
),
|
|
{ signal }
|
|
).catch(() => {
|
|
signal?.throwIfAborted()
|
|
return ''
|
|
})
|
|
const sockPath = output.trim()
|
|
if (!isWindowsRelayPipePath(sockPath)) {
|
|
return null
|
|
}
|
|
return {
|
|
sockPath,
|
|
endpointDir: relayHookEndpointDirForHost(hostPlatform, remoteDir, sockPath)
|
|
}
|
|
}
|
|
|
|
async function rememberWindowsActiveRelayEndpoint(
|
|
conn: SshConnection,
|
|
hostPlatform: RemoteHostPlatform,
|
|
markerPath: string,
|
|
sockPath: string,
|
|
signal?: AbortSignal
|
|
): Promise<void> {
|
|
await execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
powerShellCommand(
|
|
`Set-Content -LiteralPath ${powerShellLiteral(markerPath)} -Value ${powerShellLiteral(sockPath)} -NoNewline`
|
|
),
|
|
{ signal }
|
|
).catch((err) => {
|
|
signal?.throwIfAborted()
|
|
// Why: fallback pipe names are deterministic, so losing this marker won't orphan an undiscoverable relay.
|
|
console.warn(
|
|
`[ssh-relay] Failed to persist Windows active relay pipe at ${markerPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
)
|
|
})
|
|
}
|
|
|
|
type WindowsRelayEndpoint = {
|
|
sockPath: string
|
|
endpointDir: string
|
|
}
|
|
|
|
type WindowsRelayLaunchOptions = {
|
|
remoteDir: string
|
|
nodePath: string
|
|
graceTime: number
|
|
activePipeMarkerPath: string
|
|
credentialFile: string
|
|
} & WindowsRelayEndpoint & {
|
|
reconnectFallback?: WindowsRelayEndpoint
|
|
}
|
|
|
|
async function launchWindowsRelay(
|
|
conn: SshConnection,
|
|
hostPlatform: RemoteHostPlatform,
|
|
opts: WindowsRelayLaunchOptions,
|
|
signal?: AbortSignal
|
|
): Promise<{ transport: MultiplexerTransport; nodePath: string; sockPath: string }> {
|
|
let launchOpts = opts
|
|
if ((await probeWindowsRelayPipe(conn, hostPlatform, opts, signal)) === 'READY') {
|
|
try {
|
|
const transport = await connectWindowsRelay(conn, hostPlatform, opts, signal)
|
|
await rememberWindowsActiveRelayEndpoint(
|
|
conn,
|
|
hostPlatform,
|
|
opts.activePipeMarkerPath,
|
|
opts.sockPath,
|
|
signal
|
|
)
|
|
return {
|
|
transport,
|
|
nodePath: opts.nodePath,
|
|
sockPath: opts.sockPath
|
|
}
|
|
} catch (err) {
|
|
signal?.throwIfAborted()
|
|
console.warn(
|
|
'[ssh-relay] Windows named pipe reconnect failed, launching fresh relay:',
|
|
err instanceof Error ? err.message : String(err)
|
|
)
|
|
if (opts.reconnectFallback) {
|
|
// Why: a Windows named pipe can't be unlinked like a Unix socket; a deterministic fallback pipe keeps the next deploy recoverable.
|
|
// Why: spread keeps activePipeMarkerPath at the original target sock name — the marker records that target's active pipe, fallback or not.
|
|
launchOpts = { ...opts, ...opts.reconnectFallback }
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
launchOpts !== opts &&
|
|
(await probeWindowsRelayPipe(conn, hostPlatform, launchOpts, signal)) === 'READY'
|
|
) {
|
|
try {
|
|
const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts, signal)
|
|
await rememberWindowsActiveRelayEndpoint(
|
|
conn,
|
|
hostPlatform,
|
|
launchOpts.activePipeMarkerPath,
|
|
launchOpts.sockPath,
|
|
signal
|
|
)
|
|
return {
|
|
transport,
|
|
nodePath: launchOpts.nodePath,
|
|
sockPath: launchOpts.sockPath
|
|
}
|
|
} catch (err) {
|
|
signal?.throwIfAborted()
|
|
console.warn(
|
|
'[ssh-relay] Windows fallback pipe reconnect failed, relaunching relay:',
|
|
err instanceof Error ? err.message : String(err)
|
|
)
|
|
}
|
|
}
|
|
|
|
const logFile = joinRemotePath(hostPlatform, launchOpts.remoteDir, 'relay.log')
|
|
const errFile = joinRemotePath(hostPlatform, launchOpts.remoteDir, 'relay.err.log')
|
|
await writeRelayEndpointCredential(
|
|
conn,
|
|
hostPlatform,
|
|
launchOpts.nodePath,
|
|
launchOpts.credentialFile,
|
|
{ signal }
|
|
)
|
|
await execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
windowsRelayLaunchCommand(
|
|
hostPlatform,
|
|
launchOpts.nodePath,
|
|
launchOpts.remoteDir,
|
|
launchOpts.sockPath,
|
|
launchOpts.endpointDir,
|
|
launchOpts.graceTime,
|
|
logFile,
|
|
errFile,
|
|
launchOpts.credentialFile
|
|
),
|
|
{ signal }
|
|
)
|
|
|
|
const POLL_INTERVAL_MS = 200
|
|
const POLL_TIMEOUT_MS = 10_000
|
|
if (
|
|
await waitForWindowsRelayPipe(
|
|
conn,
|
|
hostPlatform,
|
|
launchOpts,
|
|
POLL_TIMEOUT_MS,
|
|
POLL_INTERVAL_MS,
|
|
signal
|
|
)
|
|
) {
|
|
const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts, signal)
|
|
await rememberWindowsActiveRelayEndpoint(
|
|
conn,
|
|
hostPlatform,
|
|
launchOpts.activePipeMarkerPath,
|
|
launchOpts.sockPath,
|
|
signal
|
|
)
|
|
return {
|
|
transport,
|
|
nodePath: launchOpts.nodePath,
|
|
sockPath: launchOpts.sockPath
|
|
}
|
|
}
|
|
|
|
const logOutput = await execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
windowsRelayTailLogCommand(logFile, errFile),
|
|
{ signal }
|
|
).catch(() => {
|
|
signal?.throwIfAborted()
|
|
return '(could not read log)'
|
|
})
|
|
throw new Error(`Relay failed to start within ${POLL_TIMEOUT_MS / 1000}s. Log:\n${logOutput}`)
|
|
}
|
|
|
|
async function connectWindowsRelay(
|
|
conn: SshConnection,
|
|
hostPlatform: RemoteHostPlatform,
|
|
opts: {
|
|
remoteDir: string
|
|
nodePath: string
|
|
sockPath: string
|
|
credentialFile: string
|
|
},
|
|
signal?: AbortSignal
|
|
): Promise<MultiplexerTransport> {
|
|
const channel = await conn.exec(
|
|
windowsRelayConnectCommand(
|
|
hostPlatform,
|
|
opts.nodePath,
|
|
opts.remoteDir,
|
|
opts.sockPath,
|
|
opts.credentialFile
|
|
),
|
|
{ wrapCommand: false, signal }
|
|
)
|
|
return waitForSentinel(channel, signal)
|
|
}
|
|
|
|
function windowsRelayConnectCommand(
|
|
hostPlatform: RemoteHostPlatform,
|
|
nodePath: string,
|
|
remoteDir: string,
|
|
sockPath: string,
|
|
credentialFile: string
|
|
): string {
|
|
return commandWithNodePath(
|
|
hostPlatform,
|
|
nodePath,
|
|
remoteDir,
|
|
`& ${powerShellLiteral(nodePath)} relay.js --connect --sock-path ${powerShellLiteral(sockPath)} --credential-file ${powerShellLiteral(credentialFile)}`
|
|
)
|
|
}
|
|
|
|
function windowsRelayLaunchCommand(
|
|
hostPlatform: RemoteHostPlatform,
|
|
nodePath: string,
|
|
remoteDir: string,
|
|
sockPath: string,
|
|
endpointDir: string,
|
|
graceTime: number,
|
|
logFile: string,
|
|
errFile: string,
|
|
credentialFile: string
|
|
): string {
|
|
const relayScript = joinRemotePath(hostPlatform, remoteDir, 'relay.js')
|
|
// Why: Windows sshd kills the exec channel's process tree on close; WMI re-parents the detached relay to survive.
|
|
const quoted = (value: string): string => `"${value.replace(/"/g, '\\"')}"`
|
|
const relayCommandLine = [
|
|
quoted(nodePath),
|
|
quoted(relayScript),
|
|
'--detached',
|
|
'--grace-time',
|
|
String(graceTime),
|
|
'--sock-path',
|
|
quoted(sockPath),
|
|
'--credential-file',
|
|
quoted(credentialFile),
|
|
'--endpoint-dir',
|
|
quoted(endpointDir),
|
|
// Why: --log-file owns rotation; shell redirects still capture pre-JS boot/crash output.
|
|
'--log-file',
|
|
quoted(logFile),
|
|
`1>${quoted(logFile)}`,
|
|
`2>${quoted(errFile)}`
|
|
].join(' ')
|
|
const wmiCommandLine = `cmd.exe /d /s /c "${relayCommandLine}"`
|
|
return commandWithNodePath(
|
|
hostPlatform,
|
|
nodePath,
|
|
remoteDir,
|
|
[
|
|
`& icacls.exe ${powerShellLiteral(credentialFile)} /inheritance:r /grant:r "$($env:USERNAME):(R,W)" | Out-Null`,
|
|
`$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powerShellLiteral(wmiCommandLine)}; CurrentDirectory = ${powerShellLiteral(remoteDir)} }`,
|
|
`if ($result.ReturnValue -ne 0) { throw "Win32_Process.Create failed with $($result.ReturnValue)" }`
|
|
].join('; ')
|
|
)
|
|
}
|
|
|
|
async function probeWindowsRelayPipe(
|
|
conn: SshConnection,
|
|
hostPlatform: RemoteHostPlatform,
|
|
opts: {
|
|
remoteDir: string
|
|
nodePath: string
|
|
sockPath: string
|
|
},
|
|
signal?: AbortSignal
|
|
): Promise<'READY' | 'WAITING'> {
|
|
const result = await execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
windowsRelayProbeCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath),
|
|
{ signal }
|
|
)
|
|
return result.trim() === 'READY' ? 'READY' : 'WAITING'
|
|
}
|
|
|
|
async function waitForWindowsRelayPipe(
|
|
conn: SshConnection,
|
|
hostPlatform: RemoteHostPlatform,
|
|
opts: {
|
|
remoteDir: string
|
|
nodePath: string
|
|
sockPath: string
|
|
},
|
|
timeoutMs: number,
|
|
intervalMs: number,
|
|
signal?: AbortSignal
|
|
): Promise<boolean> {
|
|
try {
|
|
const result = await execHostCommand(
|
|
conn,
|
|
hostPlatform,
|
|
windowsRelayWaitCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath, {
|
|
timeoutMs,
|
|
intervalMs
|
|
}),
|
|
{ signal }
|
|
)
|
|
return result.trim() === 'READY'
|
|
} catch {
|
|
signal?.throwIfAborted()
|
|
return false
|
|
}
|
|
}
|
|
|
|
function windowsRelayProbeCommand(
|
|
hostPlatform: RemoteHostPlatform,
|
|
nodePath: string,
|
|
remoteDir: string,
|
|
sockPath: string
|
|
): string {
|
|
const js = [
|
|
'const net=require("net");',
|
|
'const s=net.connect(process.argv[1]);',
|
|
's.on("connect",()=>{s.destroy();process.stdout.write("READY")});',
|
|
's.on("error",()=>{process.stdout.write("WAITING")});'
|
|
].join('')
|
|
return commandWithNodePath(
|
|
hostPlatform,
|
|
nodePath,
|
|
remoteDir,
|
|
`& ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(js)} ${powerShellNativeArg(sockPath)}`
|
|
)
|
|
}
|
|
|
|
function windowsRelayWaitCommand(
|
|
hostPlatform: RemoteHostPlatform,
|
|
nodePath: string,
|
|
remoteDir: string,
|
|
sockPath: string,
|
|
opts: { timeoutMs: number; intervalMs: number }
|
|
): string {
|
|
const js = [
|
|
'const net=require("net");',
|
|
'const pipe=process.argv[1];',
|
|
'const timeoutMs=Number(process.argv[2]);',
|
|
'const intervalMs=Number(process.argv[3]);',
|
|
'const deadline=Date.now()+timeoutMs;',
|
|
'function finish(value){process.stdout.write(value);process.exit(0)}',
|
|
'function attempt(){',
|
|
'const s=net.connect(pipe);',
|
|
'let settled=false;',
|
|
'function retry(){if(settled)return;settled=true;s.destroy();',
|
|
'if(Date.now()>=deadline)finish("WAITING");else setTimeout(attempt,intervalMs)}',
|
|
's.setTimeout(Math.min(intervalMs,500));',
|
|
's.on("connect",()=>{if(settled)return;settled=true;s.destroy();finish("READY")});',
|
|
's.on("timeout",retry);',
|
|
's.on("error",retry);',
|
|
'}',
|
|
'attempt();'
|
|
].join('')
|
|
return commandWithNodePath(
|
|
hostPlatform,
|
|
nodePath,
|
|
remoteDir,
|
|
[
|
|
`& ${powerShellLiteral(nodePath)}`,
|
|
'-e',
|
|
powerShellNativeArg(js),
|
|
powerShellNativeArg(sockPath),
|
|
powerShellLiteral(String(opts.timeoutMs)),
|
|
powerShellLiteral(String(opts.intervalMs))
|
|
].join(' ')
|
|
)
|
|
}
|
|
|
|
function windowsRelayTailLogCommand(logFile: string, errFile: string): string {
|
|
const script = [
|
|
`$out = if (Test-Path -LiteralPath ${powerShellLiteral(logFile)}) { Get-Content -LiteralPath ${powerShellLiteral(logFile)} -Tail 20 -ErrorAction SilentlyContinue } else { '(no stdout log)' }`,
|
|
`$err = if (Test-Path -LiteralPath ${powerShellLiteral(errFile)}) { Get-Content -LiteralPath ${powerShellLiteral(errFile)} -Tail 20 -ErrorAction SilentlyContinue } else { '(no stderr log)' }`,
|
|
'Write-Output $out',
|
|
"Write-Output '--- stderr ---'",
|
|
'Write-Output $err'
|
|
].join('; ')
|
|
return powerShellCommand(script)
|
|
}
|