diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index fc65af35c0a..18c013ed779 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -826,7 +826,7 @@ async function probeRequiredNativeDeps( hostPlatform, nodePath, remoteDir, - `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(probeJs)} } catch { 'MISSING' }` + `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 diff --git a/src/main/ssh/ssh-relay-exec-command.ts b/src/main/ssh/ssh-relay-exec-command.ts index fb0a4fee012..bec41c8d43e 100644 --- a/src/main/ssh/ssh-relay-exec-command.ts +++ b/src/main/ssh/ssh-relay-exec-command.ts @@ -24,6 +24,16 @@ type SshCommandTerminationError = Error & { sshChannelCloseConfirmed: boolean } +// Why: callers must tell "the host answered no" from "the host never answered". Matching the +// message text is what let an unanswered probe be read as a definitive negative. +export const SSH_EXEC_TIMEOUT_CODE = 'SSH_EXEC_TIMEOUT' + +export function isSshExecTimeout(error: unknown): boolean { + return ( + error instanceof Error && (error as Partial<{ code: string }>).code === SSH_EXEC_TIMEOUT_CODE + ) +} + export function isUnconfirmedSshCommandTermination( error: unknown ): error is SshCommandTerminationError { @@ -164,8 +174,13 @@ export async function execCommand( } const timeout = setTimeout(() => { requestTermination( - new Error( - `Command "${redactRelayInstallMarkerTokens(command)}" timed out after ${timeoutMs / 1000}s` + Object.assign( + new Error( + `Command "${redactRelayInstallMarkerTokens(command)}" timed out after ${ + timeoutMs / 1000 + }s` + ), + { code: SSH_EXEC_TIMEOUT_CODE } ) ) }, timeoutMs) diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index 01625816170..edf19b1251b 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -394,6 +394,31 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { } }) + it('does not rewrite node_modules when the health probe never answered', async () => { + // Why (#14830): a wedged `require("node-pty")` makes the probe time out. Reading that silence + // as "every native dep is missing" sent a healthy install through npm install + rebuild that + // could not help, and the retry loop burned the whole deploy budget at "Deploying relay…". + const conn = makeMockConnection(sftpCapture) + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + { reject: 'Command "node -e ..." timed out after 30s' } // health probe never answered + ]) + + await deployAndLaunchRelay(conn).catch(() => {}) + + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) + expect(execCalls.some((c) => c.includes('npm rebuild'))).toBe(false) + + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('Repairing missing native deps'))).toBe(false) + // Why no log assertion: the behavioural claim above is the real one. Asserting on warn text + // pinned wording that main's landed probe verdict does not use, and #18000 adds its own. + expect(execCalls.some((c) => c.includes("rm -rf 'node_modules/node-pty'"))).toBe(false) + }) + it('lets a probe SSH-channel failure bubble up rather than silently mapping to MISSING', async () => { const conn = makeMockConnection(sftpCapture) feed(