diff --git a/src/main/ipc/preflight.test.ts b/src/main/ipc/preflight.test.ts index 5aee3a6f956..58e4090031f 100644 --- a/src/main/ipc/preflight.test.ts +++ b/src/main/ipc/preflight.test.ts @@ -145,10 +145,12 @@ describe('preflight', () => { gitea: defaultGiteaStatus }) expect(execFileAsyncMock).toHaveBeenNthCalledWith(4, 'gh', ['auth', 'status'], { - encoding: 'utf-8' + encoding: 'utf-8', + timeout: 5000 }) expect(execFileAsyncMock).toHaveBeenNthCalledWith(5, 'glab', ['auth', 'status'], { - encoding: 'utf-8' + encoding: 'utf-8', + timeout: 5000 }) }) @@ -206,6 +208,47 @@ describe('preflight', () => { expect(status.glab).toEqual({ installed: true, authenticated: false }) }) + it('times out hung local preflight probes', async () => { + vi.useFakeTimers() + try { + execFileAsyncMock.mockImplementation((command, args) => { + if (command === 'git') { + return Promise.resolve({ stdout: 'git version 2.0.0\n' }) + } + if (command === 'gh' && Array.isArray(args) && args[0] === '--version') { + return new Promise(() => {}) + } + if (command === 'glab') { + return Promise.reject(new Error('command not found: glab')) + } + throw new Error(`unexpected command ${String(command)}`) + }) + + const statusPromise = runPreflightCheck() + let settled = false + void statusPromise.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + + await vi.advanceTimersByTimeAsync(5000) + await Promise.resolve() + + expect(settled).toBe(true) + await expect(statusPromise).resolves.toMatchObject({ + git: { installed: true }, + gh: { installed: false }, + glab: { installed: false } + }) + } finally { + vi.useRealTimers() + } + }) + it('prefers the selected WSL distro when checking gh for a WSL workspace', async () => { Object.defineProperty(process, 'platform', { configurable: true, diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 8821a65ff48..739d0b3f5d4 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -11,6 +11,7 @@ import { getGiteaAuthStatus } from '../gitea/client' import { _resetKnownHostsCache } from '../gitlab/gl-utils' import { getActiveMultiplexer } from './ssh' const execFileAsync = promisify(execFile) +const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 type PreflightRuntimeContext = { wslDistro?: string | null @@ -59,6 +60,42 @@ function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } +type PreflightCommandResult = { stdout: string; stderr: string } + +// Why: a broken PATH shim or auth helper should not keep startup/settings +// preflight IPC pending forever; WSL probes already use the same deadline. +async function execLocalPreflightCommand( + command: string, + args: string[] +): Promise { + const commandPromise = execFileAsync(command, args, { + encoding: 'utf-8', + timeout: PREFLIGHT_COMMAND_TIMEOUT_MS + }) as Promise + + let timeout: ReturnType | null = null + try { + return await Promise.race([ + commandPromise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + const error = Object.assign(new Error(`Timed out running ${command}`), { + code: 'ETIMEDOUT' + }) + reject(error) + }, PREFLIGHT_COMMAND_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + async function execCommandInWsl( target: WslPreflightTarget, command: string @@ -66,7 +103,7 @@ async function execCommandInWsl( const distroArgs = target.distro ? ['-d', target.distro] : [] return execFileAsync('wsl.exe', [...distroArgs, '--', 'bash', '-lc', command], { encoding: 'utf-8', - timeout: 5000 + timeout: PREFLIGHT_COMMAND_TIMEOUT_MS }) as Promise<{ stdout: string; stderr: string }> } @@ -77,7 +114,7 @@ async function isCommandAvailable( try { await (wslTarget ? execCommandInWsl(wslTarget, `${shellQuote(command)} --version`) - : execFileAsync(command, ['--version'])) + : execLocalPreflightCommand(command, ['--version'])) return true } catch { return false @@ -92,7 +129,7 @@ async function isCommandOnPath(command: string, wslTarget?: WslPreflightTarget): try { const { stdout } = wslTarget ? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`) - : await execFileAsync(finder, [command], { encoding: 'utf-8' }) + : await execLocalPreflightCommand(finder, [command]) return stdout .split(/\r?\n/) .map((line) => line.trim()) @@ -198,9 +235,7 @@ async function isGhAuthenticated(wslTarget?: WslPreflightTarget): Promise