diff --git a/src/utils/preflightChecks.test.ts b/src/utils/preflightChecks.test.ts new file mode 100644 index 000000000..65ce70153 --- /dev/null +++ b/src/utils/preflightChecks.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' + +// MACRO is normally substituted at build time. The test runs without the +// bundler, so stub the build-time globals before importing the module under +// test (which transitively imports utils/http.ts → MACRO.VERSION). +;(globalThis as unknown as { MACRO?: unknown }).MACRO ??= { + VERSION: '0.0.0-test', + DISPLAY_VERSION: '0.0.0-test', + BUILD_TIME: 'test', + ISSUES_EXPLAINER: '', + PACKAGE_URL: '', + NATIVE_PACKAGE_URL: undefined, +} + +describe('checkEndpoints (preflight)', () => { + afterEach(() => { + mock.restore() + }) + + test('passes a bounded timeout to axios so a hung probe cannot freeze onboarding (#1017)', async () => { + const calls: Array<{ url: string; options: { timeout?: number } }> = [] + mock.module('axios', () => ({ + default: { + get: async ( + url: string, + options: { timeout?: number } = {}, + ): Promise<{ status: number }> => { + calls.push({ url, options }) + return { status: 200 } + }, + isAxiosError: () => false, + }, + })) + + const { checkEndpoints, PREFLIGHT_REQUEST_TIMEOUT_MS } = await import( + './preflightChecks.js' + ) + + const result = await checkEndpoints() + + expect(result.success).toBe(true) + expect(calls.length).toBeGreaterThan(0) + for (const call of calls) { + expect(call.options.timeout).toBe(PREFLIGHT_REQUEST_TIMEOUT_MS) + } + expect(PREFLIGHT_REQUEST_TIMEOUT_MS).toBeGreaterThan(0) + expect(PREFLIGHT_REQUEST_TIMEOUT_MS).toBeLessThanOrEqual(15_000) + }) + + test('returns a failure result (instead of throwing or hanging) when axios rejects with ECONNABORTED', async () => { + mock.module('axios', () => ({ + default: { + get: async (): Promise => { + const err = new Error('timeout of 5000ms exceeded') as Error & { + code?: string + } + err.code = 'ECONNABORTED' + throw err + }, + isAxiosError: () => false, + }, + })) + + const { checkEndpoints } = await import('./preflightChecks.js') + + const result = await checkEndpoints() + + expect(result.success).toBe(false) + expect(result.error).toBeDefined() + expect(result.error).toContain('Failed to connect to') + }) + + test('returns success when all probes return 200', async () => { + mock.module('axios', () => ({ + default: { + get: async (): Promise<{ status: number }> => ({ status: 200 }), + isAxiosError: () => false, + }, + })) + + const { checkEndpoints } = await import('./preflightChecks.js') + + const result = await checkEndpoints() + expect(result.success).toBe(true) + expect(result.error).toBeUndefined() + }) +}) diff --git a/src/utils/preflightChecks.tsx b/src/utils/preflightChecks.tsx index 0da71a442..5d9812aba 100644 --- a/src/utils/preflightChecks.tsx +++ b/src/utils/preflightChecks.tsx @@ -1,151 +1,179 @@ -import { c as _c } from "react-compiler-runtime"; -import axios from 'axios'; -import React, { useEffect, useState } from 'react'; -import { logEvent } from 'src/services/analytics/index.js'; -import { Spinner } from '../components/Spinner.js'; -import { getOauthConfig } from '../constants/oauth.js'; -import { useTimeout } from '../hooks/useTimeout.js'; -import { Box, Text } from '../ink.js'; -import { getSSLErrorHint } from '../services/api/errorUtils.js'; -import { getUserAgent } from './http.js'; -import { logError } from './log.js'; -import { getAPIProvider } from './model/providers.js'; +import axios from 'axios' +import React, { useEffect, useState } from 'react' +import { logEvent } from 'src/services/analytics/index.js' +import { Spinner } from '../components/Spinner.js' +import { getOauthConfig } from '../constants/oauth.js' +import { useTimeout } from '../hooks/useTimeout.js' +import { Box, Text } from '../ink.js' +import { getSSLErrorHint } from '../services/api/errorUtils.js' +import { getUserAgent } from './http.js' +import { logError } from './log.js' +import { getAPIProvider } from './model/providers.js' + +// Bound the connectivity probe so users on networks that can't reach +// api.anthropic.com (country blocks, corporate firewalls, captive portals) +// don't sit forever on the onboarding spinner. See #1017. +export const PREFLIGHT_REQUEST_TIMEOUT_MS = 5000 + +// Brief hold after a failed probe so the user can read the error before the +// onboarding flow advances. Failed connectivity does not block onboarding — +// third-party providers (Ollama, OpenAI, etc.) are configured later via +// /provider and don't need api.anthropic.com to be reachable. +export const PREFLIGHT_ERROR_HOLD_MS = 4000 + export interface PreflightCheckResult { - success: boolean; - error?: string; - sslHint?: string; + success: boolean + error?: string + sslHint?: string } -async function checkEndpoints(): Promise { + +export async function checkEndpoints(): Promise { try { - const oauthConfig = getOauthConfig(); - const tokenUrl = new URL(oauthConfig.TOKEN_URL); - const endpoints = [`${oauthConfig.BASE_API_URL}/api/hello`, `${tokenUrl.origin}/v1/oauth/hello`]; - const checkEndpoint = async (url: string): Promise => { + const oauthConfig = getOauthConfig() + const tokenUrl = new URL(oauthConfig.TOKEN_URL) + const endpoints = [ + `${oauthConfig.BASE_API_URL}/api/hello`, + `${tokenUrl.origin}/v1/oauth/hello`, + ] + + const checkEndpoint = async ( + url: string, + ): Promise => { try { const response = await axios.get(url, { - headers: { - 'User-Agent': getUserAgent() - } - }); + headers: { 'User-Agent': getUserAgent() }, + timeout: PREFLIGHT_REQUEST_TIMEOUT_MS, + }) if (response.status !== 200) { - const hostname = new URL(url).hostname; + const hostname = new URL(url).hostname return { success: false, - error: `Failed to connect to ${hostname}: Status ${response.status}` - }; + error: `Failed to connect to ${hostname}: Status ${response.status}`, + } } - return { - success: true - }; + return { success: true } } catch (error) { - const hostname = new URL(url).hostname; - const sslHint = getSSLErrorHint(error); + const hostname = new URL(url).hostname + const sslHint = getSSLErrorHint(error) return { success: false, error: `Failed to connect to ${hostname}: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}`, - sslHint: sslHint ?? undefined - }; + sslHint: sslHint ?? undefined, + } } - }; - const results = await Promise.all(endpoints.map(checkEndpoint)); - const failedResult = results.find(result => !result.success); + } + + const results = await Promise.all(endpoints.map(checkEndpoint)) + const failedResult = results.find(result => !result.success) + if (failedResult) { - // Log failure to Statsig logEvent('tengu_preflight_check_failed', { isConnectivityError: false, hasErrorMessage: !!failedResult.error, - isSSLError: !!failedResult.sslHint - }); + isSSLError: !!failedResult.sslHint, + }) } - return failedResult || { - success: true - }; - } catch (error) { - logError(error as Error); - // Log to Statsig + return failedResult || { success: true } + } catch (error) { + logError(error as Error) logEvent('tengu_preflight_check_failed', { - isConnectivityError: true - }); + isConnectivityError: true, + }) return { success: false, - error: `Connectivity check error: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}` - }; + error: `Connectivity check error: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}`, + } } } + interface PreflightStepProps { - onSuccess: () => void; + onSuccess: () => void } -export function PreflightStep(t0) { - const $ = _c(12); - const { - onSuccess - } = t0; - const [result, setResult] = useState(null); - const [isChecking, setIsChecking] = useState(true); - const showSpinner = useTimeout(1000) && isChecking; - let t1; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = () => { - const run = async function run() { - const checkResult = await checkEndpoints(); - setResult(checkResult); - setIsChecking(false); - }; - run(); - }; - t2 = []; - $[0] = t1; - $[1] = t2; - } else { - t1 = $[0]; - t2 = $[1]; + +export function PreflightStep({ + onSuccess, +}: PreflightStepProps): React.ReactNode { + const [result, setResult] = useState(null) + const [isChecking, setIsChecking] = useState(true) + + // delay showing the check since it's so fast that we normally + // want to just immediately show the next step without a flash + const showSpinner = useTimeout(1000) && isChecking + + useEffect(() => { + async function run() { + const checkResult = await checkEndpoints() + setResult(checkResult) + setIsChecking(false) + } + void run() + }, []) + + useEffect(() => { + if (!result) return + if (result.success) { + onSuccess() + return + } + // Connectivity failure used to call process.exit(1), which left users on + // networks that couldn't reach Anthropic — including those who only ever + // intended to use a third-party provider — unable to complete onboarding. + // Surface the error briefly, then advance so the rest of onboarding (theme, + // security notes) can finish and the user reaches a prompt where they can + // run /provider. + const timer = setTimeout(onSuccess, PREFLIGHT_ERROR_HOLD_MS) + return () => clearTimeout(timer) + }, [result, onSuccess]) + + if (isChecking && showSpinner) { + return ( + + + + Checking connectivity... + + + ) } - useEffect(t1, t2); - let t3; - let t4; - if ($[2] !== onSuccess || $[3] !== result) { - t3 = () => { - if (result?.success) { - onSuccess(); - } else { - if (result && !result.success) { - const timer = setTimeout(_temp, 100); - return () => clearTimeout(timer); - } - } - }; - t4 = [result, onSuccess]; - $[2] = onSuccess; - $[3] = result; - $[4] = t3; - $[5] = t4; - } else { - t3 = $[4]; - t4 = $[5]; + + if (!isChecking && result && !result.success) { + return ( + + + Unable to connect to Anthropic services + {result.error} + {result.sslHint ? ( + + {result.sslHint} + + See https://code.claude.com/docs/en/network-config + + + ) : ( + + + Please check your internet connection and network settings. + + {getAPIProvider() === 'firstParty' && ( + + Note: Claude Code might not be available in your country. + Check supported countries at{' '} + + https://anthropic.com/supported-countries + + + )} + + )} + + Continuing — you can configure a non-Anthropic provider later with + /provider. + + + + ) } - useEffect(t3, t4); - let t5; - if ($[6] !== isChecking || $[7] !== result || $[8] !== showSpinner) { - t5 = isChecking && showSpinner ? Checking connectivity... : !result?.success && !isChecking && Unable to connect to Anthropic services{result?.error}{result?.sslHint ? {result.sslHint}See https://code.claude.com/docs/en/network-config : Please check your internet connection and network settings.{getAPIProvider() === 'firstParty' && Note: Claude Code might not be available in your country. Check supported countries at{" "}https://anthropic.com/supported-countries}}; - $[6] = isChecking; - $[7] = result; - $[8] = showSpinner; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== t5) { - t6 = {t5}; - $[10] = t5; - $[11] = t6; - } else { - t6 = $[11]; - } - return t6; -} -function _temp() { - return process.exit(1); + + return }