diff --git a/packages/cli/src/commands/update.test.ts b/packages/cli/src/commands/update.test.ts index edf42633807..5ad88cec937 100644 --- a/packages/cli/src/commands/update.test.ts +++ b/packages/cli/src/commands/update.test.ts @@ -43,7 +43,10 @@ const initializeI18n = vi.fn(); const resolveLanguageSetting = vi.fn((language?: string) => language || 'auto'); vi.mock('../config/settings.js', () => ({ loadSettings })); -vi.mock('../ui/utils/updateCheck.js', () => ({ checkForUpdatesDetailed })); +vi.mock('../ui/utils/updateCheck.js', async (importOriginal) => ({ + ...(await importOriginal()), + checkForUpdatesDetailed, +})); vi.mock('../utils/installationInfo.js', () => ({ formatUpdateInstructions, getInstallationInfo, @@ -235,7 +238,7 @@ describe('update command', () => { await updateCommand.handler(updateArgs); expect(writeStderrLine).toHaveBeenCalledWith( - 'Failed to check for updates. Please check your network or registry configuration.', + 'Failed to check for updates (registry error). Please check your network or registry configuration.', ); expect(process.exitCode).toBe(1); expect(getInstallationInfo).not.toHaveBeenCalled(); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index daeeacd9b52..2e81513fac4 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -15,7 +15,7 @@ export const updateCommand: CommandModule = { handler: async () => { const [ { loadSettings }, - { checkForUpdatesDetailed }, + { checkForUpdatesDetailed, describeUpdateCheckFailure }, installationInfoModule, standaloneUpdate, stdioHelpers, @@ -54,7 +54,8 @@ export const updateCommand: CommandModule = { if (updateCheck.status === 'error') { writeStderrLine( t( - 'Failed to check for updates. Please check your network or registry configuration.', + 'Failed to check for updates ({{reason}}). Please check your network or registry configuration.', + { reason: describeUpdateCheckFailure(updateCheck.error) }, ), ); process.exitCode = 1; diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 700cc1d90a4..9ca3bd1dd55 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -2098,8 +2098,14 @@ export default { 'Hi ha una versió nova de Qwen Code disponible! {{current}} → {{latest}}', 'Qwen Code {{version}} is up to date!': 'Qwen Code {{version}} està actualitzat!', - 'Failed to check for updates. Please check your network or registry configuration.': - "No s'han pogut comprovar les actualitzacions. Comproveu la xarxa o la configuració del registre.", + 'Failed to check for updates ({{reason}}). Please check your network or registry configuration.': + "No s'han pogut comprovar les actualitzacions ({{reason}}). Comproveu la xarxa o la configuració del registre.", + 'Update check skipped ({{reason}}) — run /update to retry.': + "S'ha omès la comprovació d'actualitzacions ({{reason}}) — executeu /update per tornar-ho a provar.", + 'registry did not respond within {{seconds}}s': + 'el registre no ha respost en {{seconds}}s', + 'registry unreachable': 'no es pot accedir al registre', + 'registry error': 'error del registre', 'Unable to check for updates: {{reason}}': 'No es poden comprovar les actualitzacions: {{reason}}', 'Update successful! The new version will be used on your next run.': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index d9c91153ed4..33117f1b018 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -2146,8 +2146,14 @@ export default { 'A new version of Qwen Code is available! {{current}} → {{latest}}': 'Eine neue Version von Qwen Code ist verfügbar! {{current}} → {{latest}}', 'Qwen Code {{version}} is up to date!': 'Qwen Code {{version}} ist aktuell!', - 'Failed to check for updates. Please check your network or registry configuration.': - 'Suche nach Updates fehlgeschlagen. Bitte Netzwerk- oder Registry-Konfiguration prüfen.', + 'Failed to check for updates ({{reason}}). Please check your network or registry configuration.': + 'Suche nach Updates fehlgeschlagen ({{reason}}). Bitte Netzwerk- oder Registry-Konfiguration prüfen.', + 'Update check skipped ({{reason}}) — run /update to retry.': + 'Update-Prüfung übersprungen ({{reason}}) — mit /update erneut versuchen.', + 'registry did not respond within {{seconds}}s': + 'Registry hat nicht innerhalb von {{seconds}}s geantwortet', + 'registry unreachable': 'Registry nicht erreichbar', + 'registry error': 'Registry-Fehler', 'Unable to check for updates: {{reason}}': 'Updates können nicht geprüft werden: {{reason}}', 'Update successful! The new version will be used on your next run.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 78fb3242f95..14675a5571d 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2604,8 +2604,14 @@ export default { 'A new version of Qwen Code is available! {{current}} → {{latest}}', 'Qwen Code {{version}} is up to date!': 'Qwen Code {{version}} is up to date!', - 'Failed to check for updates. Please check your network or registry configuration.': - 'Failed to check for updates. Please check your network or registry configuration.', + 'Failed to check for updates ({{reason}}). Please check your network or registry configuration.': + 'Failed to check for updates ({{reason}}). Please check your network or registry configuration.', + 'Update check skipped ({{reason}}) — run /update to retry.': + 'Update check skipped ({{reason}}) — run /update to retry.', + 'registry did not respond within {{seconds}}s': + 'registry did not respond within {{seconds}}s', + 'registry unreachable': 'registry unreachable', + 'registry error': 'registry error', 'Unable to check for updates: {{reason}}': 'Unable to check for updates: {{reason}}', 'Update successful! The new version will be used on your next run.': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 0a3d33039e1..525d35d2b54 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -2148,8 +2148,14 @@ export default { 'A new version of Qwen Code is available! {{current}} → {{latest}}': 'Une nouvelle version de Qwen Code est disponible ! {{current}} → {{latest}}', 'Qwen Code {{version}} is up to date!': 'Qwen Code {{version}} est à jour !', - 'Failed to check for updates. Please check your network or registry configuration.': - 'Échec de la vérification des mises à jour. Vérifiez votre réseau ou la configuration du registre.', + 'Failed to check for updates ({{reason}}). Please check your network or registry configuration.': + 'Échec de la vérification des mises à jour ({{reason}}). Vérifiez votre réseau ou la configuration du registre.', + 'Update check skipped ({{reason}}) — run /update to retry.': + 'Vérification des mises à jour ignorée ({{reason}}) — exécutez /update pour réessayer.', + 'registry did not respond within {{seconds}}s': + "le registre n'a pas répondu en {{seconds}}s", + 'registry unreachable': 'registre inaccessible', + 'registry error': 'erreur du registre', 'Unable to check for updates: {{reason}}': 'Impossible de vérifier les mises à jour : {{reason}}', 'Update successful! The new version will be used on your next run.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 34741bb2f5e..8d33534df11 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1912,8 +1912,14 @@ export default { 'A new version of Qwen Code is available! {{current}} → {{latest}}': 'Qwen Code の新しいバージョンがあります!{{current}} → {{latest}}', 'Qwen Code {{version}} is up to date!': 'Qwen Code {{version}} は最新です!', - 'Failed to check for updates. Please check your network or registry configuration.': - 'アップデートの確認に失敗しました。ネットワークまたはレジストリ設定を確認してください。', + 'Failed to check for updates ({{reason}}). Please check your network or registry configuration.': + 'アップデートの確認に失敗しました({{reason}})。ネットワークまたはレジストリ設定を確認してください。', + 'Update check skipped ({{reason}}) — run /update to retry.': + 'アップデートの確認をスキップしました({{reason}})— /update で再試行できます。', + 'registry did not respond within {{seconds}}s': + 'レジストリが {{seconds}} 秒以内に応答しませんでした', + 'registry unreachable': 'レジストリに接続できません', + 'registry error': 'レジストリエラー', 'Unable to check for updates: {{reason}}': 'アップデートを確認できません: {{reason}}', 'Update successful! The new version will be used on your next run.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 54866ad2099..6ffb096d4a5 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -2133,8 +2133,14 @@ export default { 'Uma nova versão do Qwen Code está disponível! {{current}} → {{latest}}', 'Qwen Code {{version}} is up to date!': 'Qwen Code {{version}} está atualizado!', - 'Failed to check for updates. Please check your network or registry configuration.': - 'Falha ao verificar atualizações. Verifique sua rede ou configuração do registro.', + 'Failed to check for updates ({{reason}}). Please check your network or registry configuration.': + 'Falha ao verificar atualizações ({{reason}}). Verifique sua rede ou configuração do registro.', + 'Update check skipped ({{reason}}) — run /update to retry.': + 'Verificação de atualização ignorada ({{reason}}) — execute /update para tentar novamente.', + 'registry did not respond within {{seconds}}s': + 'o registro não respondeu em {{seconds}}s', + 'registry unreachable': 'registro inacessível', + 'registry error': 'erro no registro', 'Unable to check for updates: {{reason}}': 'Não foi possível verificar atualizações: {{reason}}', 'Update successful! The new version will be used on your next run.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 6747f4e87e5..ad8953a5c70 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -2120,8 +2120,14 @@ export default { 'A new version of Qwen Code is available! {{current}} → {{latest}}': 'Доступна новая версия Qwen Code! {{current}} → {{latest}}', 'Qwen Code {{version}} is up to date!': 'Qwen Code {{version}} актуален!', - 'Failed to check for updates. Please check your network or registry configuration.': - 'Не удалось проверить обновления. Проверьте сеть или настройки registry.', + 'Failed to check for updates ({{reason}}). Please check your network or registry configuration.': + 'Не удалось проверить обновления ({{reason}}). Проверьте сеть или настройки registry.', + 'Update check skipped ({{reason}}) — run /update to retry.': + 'Проверка обновлений пропущена ({{reason}}) — выполните /update для повторной попытки.', + 'registry did not respond within {{seconds}}s': + 'registry не ответил за {{seconds}} с', + 'registry unreachable': 'registry недоступен', + 'registry error': 'ошибка registry', 'Unable to check for updates: {{reason}}': 'Невозможно проверить обновления: {{reason}}', 'Update successful! The new version will be used on your next run.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 294b51bbfc2..d306d30fc5b 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -2197,8 +2197,14 @@ export default { 'A new version of Qwen Code is available! {{current}} → {{latest}}': 'Qwen Code 有新版本可用!{{current}} → {{latest}}', 'Qwen Code {{version}} is up to date!': 'Qwen Code {{version}} 已是最新!', - 'Failed to check for updates. Please check your network or registry configuration.': - '檢查更新失敗。請檢查網路或 registry 設定。', + 'Failed to check for updates ({{reason}}). Please check your network or registry configuration.': + '檢查更新失敗({{reason}})。請檢查網路或 registry 設定。', + 'Update check skipped ({{reason}}) — run /update to retry.': + '已略過更新檢查({{reason}})— 可執行 /update 重試。', + 'registry did not respond within {{seconds}}s': + 'registry 在 {{seconds}} 秒內未回應', + 'registry unreachable': 'registry 無法連線', + 'registry error': 'registry 錯誤', 'Unable to check for updates: {{reason}}': '無法檢查更新:{{reason}}', 'Update successful! The new version will be used on your next run.': '更新成功!新版本將在下次執行時生效。', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 113c84e44ff..7753bbd85e2 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -2399,8 +2399,14 @@ export default { 'A new version of Qwen Code is available! {{current}} → {{latest}}': 'Qwen Code 有新版本可用!{{current}} → {{latest}}', 'Qwen Code {{version}} is up to date!': 'Qwen Code {{version}} 已是最新!', - 'Failed to check for updates. Please check your network or registry configuration.': - '检查更新失败。请检查网络或 registry 配置。', + 'Failed to check for updates ({{reason}}). Please check your network or registry configuration.': + '检查更新失败({{reason}})。请检查网络或 registry 配置。', + 'Update check skipped ({{reason}}) — run /update to retry.': + '已跳过更新检查({{reason}})— 可运行 /update 重试。', + 'registry did not respond within {{seconds}}s': + 'registry 在 {{seconds}} 秒内未响应', + 'registry unreachable': 'registry 无法连接', + 'registry error': 'registry 错误', 'Unable to check for updates: {{reason}}': '无法检查更新:{{reason}}', 'Update successful! The new version will be used on your next run.': '更新成功!新版本将在下次运行时生效。', diff --git a/packages/cli/src/startup/startup-prefetch.test.ts b/packages/cli/src/startup/startup-prefetch.test.ts index 21bf507dbce..364dc3f4ddc 100644 --- a/packages/cli/src/startup/startup-prefetch.test.ts +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -51,7 +51,8 @@ vi.mock('../utils/startupProfiler.js', () => ({ recordStartupEvent: (...args: unknown[]) => mockRecordStartupEvent(...args), })); -vi.mock('../ui/utils/updateCheck.js', () => ({ +vi.mock('../ui/utils/updateCheck.js', async (importOriginal) => ({ + ...(await importOriginal()), checkForUpdatesDetailed: (...args: unknown[]) => mockCheckForUpdatesDetailed(...args), })); @@ -78,7 +79,12 @@ vi.mock('../utils/updateEventEmitter.js', () => ({ })); vi.mock('../i18n/index.js', () => ({ - t: (key: string) => key, + t: (key: string, params?: Record) => + params + ? key.replace(/\{\{(\w+)\}\}/g, (match, name) => + name in params ? String(params[name]) : match, + ) + : key, })); vi.mock('../core/initializer.js', () => ({ @@ -415,7 +421,7 @@ describe('startupPrefetch', () => { expect(mockConnectIdeForStartup).toHaveBeenCalledWith(config); }); - it('surfaces update check errors through the update event emitter', async () => { + it('surfaces update check errors as a warning through the update event emitter', async () => { const config = makeConfig(); mockCheckForUpdatesDetailed.mockResolvedValue({ status: 'error', @@ -427,12 +433,52 @@ describe('startupPrefetch', () => { await vi.dynamicImportSettled(); expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-failed', { - message: - 'Failed to check for updates. Please check your network or registry configuration.', + message: 'Update check skipped (registry error) — run /update to retry.', + severity: 'warning', }); expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); }); + it('reports a timeout-specific reason when the update check times out', async () => { + const config = makeConfig(); + const { UpdateCheckTimeoutError, FETCH_TIMEOUT_MS } = await import( + '../ui/utils/updateCheck.js' + ); + mockCheckForUpdatesDetailed.mockResolvedValue({ + status: 'error', + error: new UpdateCheckTimeoutError(FETCH_TIMEOUT_MS, 'latest'), + }); + + startPostRenderPrefetches(config, makeSettings()); + + await vi.dynamicImportSettled(); + + expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-failed', { + message: `Update check skipped (registry did not respond within ${Math.round(FETCH_TIMEOUT_MS / 1000)}s) — run /update to retry.`, + severity: 'warning', + }); + }); + + it('reports an offline-specific reason when the registry is unreachable', async () => { + const config = makeConfig(); + const error = new Error('getaddrinfo failed') as NodeJS.ErrnoException; + error.code = 'ENOTFOUND'; + mockCheckForUpdatesDetailed.mockResolvedValue({ + status: 'error', + error, + }); + + startPostRenderPrefetches(config, makeSettings()); + + await vi.dynamicImportSettled(); + + expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-failed', { + message: + 'Update check skipped (registry unreachable) — run /update to retry.', + severity: 'warning', + }); + }); + it('requires connectIde option before connecting IDE', async () => { const config = makeConfig(); const { statuses, stop } = captureIdeConnectionStatuses(); @@ -657,8 +703,8 @@ describe('startupPrefetch', () => { expect(mockWarn).toHaveBeenCalledWith('update_check failed:', error); expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-failed', { - message: - 'Failed to check for updates. Please check your network or registry configuration.', + message: 'Update check skipped (registry error) — run /update to retry.', + severity: 'warning', }); }); diff --git a/packages/cli/src/startup/startup-prefetch.ts b/packages/cli/src/startup/startup-prefetch.ts index a61de75fc87..7b17922c779 100644 --- a/packages/cli/src/startup/startup-prefetch.ts +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -159,7 +159,7 @@ export function startPostRenderPrefetches( ) { runDeferredTask('update_check', async () => { const [ - { checkForUpdatesDetailed }, + { checkForUpdatesDetailed, describeUpdateCheckFailure }, { handleAutoUpdate }, { getInstallationInfo }, { updateEventEmitter }, @@ -171,9 +171,13 @@ export function startPostRenderPrefetches( import('../utils/updateEventEmitter.js'), import('../i18n/index.js'), ]); - const updateFailedMessage = t( - 'Failed to check for updates. Please check your network or registry configuration.', - ); + // The startup check is best-effort background work: surface failures as + // a soft warning with the concrete reason instead of an alarming error + // (#7049), while keeping the failure visible (#6857). + const updateCheckSkippedMessage = (error: unknown) => + t('Update check skipped ({{reason}}) — run /update to retry.', { + reason: describeUpdateCheckFailure(error), + }); try { const result = await checkForUpdatesDetailed(); if (result.status === 'update') { @@ -222,12 +226,14 @@ export function startPostRenderPrefetches( } } else if (result.status === 'error') { updateEventEmitter.emit('update-failed', { - message: updateFailedMessage, + message: updateCheckSkippedMessage(result.error), + severity: 'warning', }); } } catch (error) { updateEventEmitter.emit('update-failed', { - message: updateFailedMessage, + message: updateCheckSkippedMessage(error), + severity: 'warning', }); throw error; } diff --git a/packages/cli/src/ui/commands/update-command.test.ts b/packages/cli/src/ui/commands/update-command.test.ts index 181da9d286f..580a28c726e 100644 --- a/packages/cli/src/ui/commands/update-command.test.ts +++ b/packages/cli/src/ui/commands/update-command.test.ts @@ -36,7 +36,10 @@ const formatUpdateInstructions = vi.fn( return ['Manual update required. Please reinstall Qwen Code.']; }, ); -vi.mock('../utils/updateCheck.js', () => ({ checkForUpdatesDetailed })); +vi.mock('../utils/updateCheck.js', async (importOriginal) => ({ + ...(await importOriginal()), + checkForUpdatesDetailed, +})); vi.mock('../../utils/processUtils.js', () => ({ CUSTOM_SANDBOX_IMAGE_ENV_VAR: 'QWEN_CODE_CUSTOM_SANDBOX_IMAGE', HOST_UPDATE_RELAUNCH_ENV_VAR: 'QWEN_CODE_HOST_UPDATE_RELAUNCH', @@ -376,7 +379,7 @@ describe('updateCommand', () => { type: 'message', messageType: 'error', content: - 'Failed to check for updates. Please check your network or registry configuration.', + 'Failed to check for updates (registry error). Please check your network or registry configuration.', }); expect(getInstallationInfo).not.toHaveBeenCalled(); }); diff --git a/packages/cli/src/ui/commands/update-command.ts b/packages/cli/src/ui/commands/update-command.ts index 9cc9c0d0737..9683f53841b 100644 --- a/packages/cli/src/ui/commands/update-command.ts +++ b/packages/cli/src/ui/commands/update-command.ts @@ -17,7 +17,7 @@ export const updateCommand: SlashCommand = { supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context) => { const [ - { checkForUpdatesDetailed }, + { checkForUpdatesDetailed, describeUpdateCheckFailure }, { CUSTOM_SANDBOX_IMAGE_ENV_VAR, HOST_UPDATE_RELAUNCH_ENV_VAR, @@ -54,7 +54,8 @@ export const updateCommand: SlashCommand = { type: 'message' as const, messageType: 'error' as const, content: t( - 'Failed to check for updates. Please check your network or registry configuration.', + 'Failed to check for updates ({{reason}}). Please check your network or registry configuration.', + { reason: describeUpdateCheckFailure(updateCheck.error) }, ), }; } diff --git a/packages/cli/src/ui/utils/updateCheck.test.ts b/packages/cli/src/ui/utils/updateCheck.test.ts index 080289c7b9f..75aa463a331 100644 --- a/packages/cli/src/ui/utils/updateCheck.test.ts +++ b/packages/cli/src/ui/utils/updateCheck.test.ts @@ -8,6 +8,7 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'; import { checkForUpdates, checkForUpdatesDetailed, + classifyUpdateCheckError, fetchGlobalNpmUpdateInfo, FETCH_TIMEOUT_MS, isGlobalNpmInstallation, @@ -633,4 +634,46 @@ describe('checkForUpdates', () => { } }); }); + + describe('timeout budget (#7049)', () => { + it('allows at least 5 seconds for slow registries', () => { + expect(FETCH_TIMEOUT_MS).toBeGreaterThanOrEqual(5000); + }); + }); +}); + +describe('classifyUpdateCheckError', () => { + it('classifies UpdateCheckTimeoutError as timeout', () => { + expect(classifyUpdateCheckError(new UpdateCheckTimeoutError(5000))).toBe( + 'timeout', + ); + }); + + it.each([ + 'ENOTFOUND', + 'ECONNREFUSED', + 'EAI_AGAIN', + 'ETIMEDOUT', + 'ENETUNREACH', + ])('classifies %s errors as offline', (code) => { + const error = new Error(`request failed`) as NodeJS.ErrnoException; + error.code = code; + expect(classifyUpdateCheckError(error)).toBe('offline'); + }); + + it('classifies network codes embedded in the message as offline', () => { + // npm child-process failures surface the code inside stderr text only. + expect( + classifyUpdateCheckError( + new Error('npm error code ENOTFOUND\nnpm error network'), + ), + ).toBe('offline'); + }); + + it('classifies other errors as registry', () => { + expect(classifyUpdateCheckError(new Error('404 Not Found'))).toBe( + 'registry', + ); + expect(classifyUpdateCheckError('not-an-error')).toBe('registry'); + }); }); diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts index e218388c4ab..595503ac8de 100644 --- a/packages/cli/src/ui/utils/updateCheck.ts +++ b/packages/cli/src/ui/utils/updateCheck.ts @@ -18,7 +18,10 @@ import { t } from '../../i18n/index.js'; const debugLogger = createDebugLogger('UPDATE_CHECK'); -export const FETCH_TIMEOUT_MS = 2000; +// 5s matches comparable CLIs (e.g. Claude Code's autoUpdater uses +// AbortSignal.timeout(5000)) and gives slow mirrors and corporate proxies a +// realistic budget. Related: #7049. +export const FETCH_TIMEOUT_MS = 5000; /** * Sentinel error thrown when `fetchInfo()` does not resolve within @@ -43,6 +46,60 @@ export class UpdateCheckTimeoutError extends Error { } } +export type UpdateCheckFailureReason = 'timeout' | 'offline' | 'registry'; + +const NETWORK_ERROR_CODES = [ + 'ENOTFOUND', + 'ECONNREFUSED', + 'EAI_AGAIN', + 'ETIMEDOUT', + 'ENETUNREACH', +]; + +/** + * Buckets an update-check failure so callers can tell the user what actually + * happened instead of a generic "check your network" message. Matches error + * codes both on the `code` property and inside the message text, because the + * global-npm path surfaces network failures only through `npm` child-process + * stderr embedded in the error message. Related: #7049. + */ +export function classifyUpdateCheckError( + error: unknown, +): UpdateCheckFailureReason { + if (error instanceof UpdateCheckTimeoutError) return 'timeout'; + if (error instanceof Error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + NETWORK_ERROR_CODES.some( + (netCode) => code === netCode || error.message.includes(netCode), + ) + ) { + return 'offline'; + } + } + return 'registry'; +} + +/** + * Short human-readable reason for an update-check failure, for embedding in + * status messages, e.g. "registry did not respond within 5s". + */ +export function describeUpdateCheckFailure( + error: unknown, + timeoutMs: number = FETCH_TIMEOUT_MS, +): string { + switch (classifyUpdateCheckError(error)) { + case 'timeout': + return t('registry did not respond within {{seconds}}s', { + seconds: String(Math.round(timeoutMs / 1000)), + }); + case 'offline': + return t('registry unreachable'); + default: + return t('registry error'); + } +} + async function fetchInfoWithTimeout( notifier: { fetchInfo(): UpdateInfo | Promise }, timeoutMs: number, diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index 59b90d07123..f4260716355 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -489,6 +489,47 @@ describe('setUpdateHandler', () => { cleanup(); }); + it('should render update-failed with warning severity as a warning (#7049)', () => { + const isIdleRef = { current: true }; + const { cleanup } = setUpdateHandler(addItem, setUpdateInfo, isIdleRef); + + updateEventEmitter.emit('update-failed', { + message: + 'Update check skipped (registry unreachable) — run /update to retry.', + severity: 'warning', + }); + + expect(addItem).toHaveBeenCalledWith( + { + type: MessageType.WARNING, + text: 'Update check skipped (registry unreachable) — run /update to retry.', + }, + expect.any(Number), + ); + + cleanup(); + }); + + it('should keep rendering update-failed as an error when severity is explicit error', () => { + const isIdleRef = { current: true }; + const { cleanup } = setUpdateHandler(addItem, setUpdateInfo, isIdleRef); + + updateEventEmitter.emit('update-failed', { + message: 'Automatic update failed. Please try updating manually.', + severity: 'error', + }); + + expect(addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'Automatic update failed. Please try updating manually.', + }, + expect.any(Number), + ); + + cleanup(); + }); + it('should defer addItem when not idle (update-success)', () => { const isIdleRef = { current: false }; const { cleanup } = setUpdateHandler(addItem, setUpdateInfo, isIdleRef); diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index da4902106f7..54f15a29617 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -189,10 +189,16 @@ export function setUpdateHandler( }, 60000); }; - const handleUpdateFailed = (data?: { message?: string }) => { + const handleUpdateFailed = (data?: { + message?: string; + severity?: 'error' | 'warning'; + }) => { setUpdateInfo(null); addItemOrDefer({ - type: MessageType.ERROR, + // Background update-check failures are emitted with severity 'warning' + // (#7049); actual update installation failures stay errors. + type: + data?.severity === 'warning' ? MessageType.WARNING : MessageType.ERROR, text: data?.message ?? t(UPDATE_FAILED_MESSAGE), }); }; diff --git a/packages/cli/src/utils/update-relaunch.test.ts b/packages/cli/src/utils/update-relaunch.test.ts index 9cb9de772cd..130a87a6d69 100644 --- a/packages/cli/src/utils/update-relaunch.test.ts +++ b/packages/cli/src/utils/update-relaunch.test.ts @@ -13,12 +13,24 @@ const getInstallationInfo = vi.hoisted(() => vi.fn()); const performStandaloneUpdate = vi.hoisted(() => vi.fn()); const writeStderrLine = vi.hoisted(() => vi.fn()); -vi.mock('../ui/utils/updateCheck.js', () => ({ checkForUpdatesDetailed })); +vi.mock('../ui/utils/updateCheck.js', () => ({ + checkForUpdatesDetailed, + // Classification behavior is covered by updateCheck.test.ts; a fixed reason + // keeps this suite from importing the real update-notifier chain. + describeUpdateCheckFailure: () => 'registry error', +})); vi.mock('./handleAutoUpdate.js', () => ({ handleAutoUpdate })); vi.mock('./installationInfo.js', () => ({ getInstallationInfo })); vi.mock('./standalone-update.js', () => ({ performStandaloneUpdate })); vi.mock('./stdioHelpers.js', () => ({ writeStderrLine })); -vi.mock('../i18n/index.js', () => ({ t: (message: string) => message })); +vi.mock('../i18n/index.js', () => ({ + t: (message: string, params?: Record) => + params + ? message.replace(/\{\{(\w+)\}\}/g, (match, name) => + name in params ? String(params[name]) : match, + ) + : message, +})); const { updateBeforeRelaunch } = await import('./update-relaunch.js'); @@ -88,7 +100,7 @@ describe('updateBeforeRelaunch', () => { true, ); expect(writeStderrLine).toHaveBeenCalledWith( - 'Failed to check for updates. Please check your network or registry configuration.', + 'Failed to check for updates (registry error). Please check your network or registry configuration.', ); }); diff --git a/packages/cli/src/utils/update-relaunch.ts b/packages/cli/src/utils/update-relaunch.ts index 77d1bfcd979..23da4dafa1d 100644 --- a/packages/cli/src/utils/update-relaunch.ts +++ b/packages/cli/src/utils/update-relaunch.ts @@ -8,7 +8,7 @@ import type { LoadedSettings } from '../config/settings.js'; import { writeStderrLine } from './stdioHelpers.js'; const UPDATE_CHECK_FAILED_MESSAGE = - 'Failed to check for updates. Please check your network or registry configuration.'; + 'Failed to check for updates ({{reason}}). Please check your network or registry configuration.'; const UPDATE_FAILED_MESSAGE = 'Automatic update failed. Please try updating manually.'; @@ -20,7 +20,7 @@ export async function updateBeforeRelaunch( let translate = (message: string) => message; try { const [ - { checkForUpdatesDetailed }, + { checkForUpdatesDetailed, describeUpdateCheckFailure }, { handleAutoUpdate }, { getInstallationInfo }, { performStandaloneUpdate }, @@ -73,7 +73,11 @@ export async function updateBeforeRelaunch( ); return success || relaunchOnFailure; } else if (result.status === 'error') { - writeStderrLine(t(UPDATE_CHECK_FAILED_MESSAGE)); + writeStderrLine( + t(UPDATE_CHECK_FAILED_MESSAGE, { + reason: describeUpdateCheckFailure(result.error), + }), + ); } } catch { writeStderrLine(translate(UPDATE_FAILED_MESSAGE));