From 62241a31e58c81a496833ce51aa633339677d5bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 12 Jun 2026 17:57:33 +0800 Subject: [PATCH] fix(desktop): stop killing slow provider streams (#766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop injected a far stricter timeout stack than the terminal CLI, so healthy-but-slow third-party providers (sensenova/bailian/zhipu) died at exactly API_TIMEOUT_MS while the UI showed "running" forever: - API_TIMEOUT_MS is the SDK client's time-to-first-byte budget for streaming requests; these gateways send zero bytes (no headers, no SSE ping) until prefill finishes, which takes minutes at large contexts. Raise the default from 120s to the SDK's own 600s and widen the configurable range to 30-1800s. - Widen the desktop-forced stream watchdog idle window to 240s so silent thinking/prefill phases stop tripping the 90s default. - Disable the non-streaming fallback for desktop CLI sessions: a non-streaming request only responds after the FULL generation, so it can never finish inside the same budget and loops timeout aborts forever while the UI spins (also avoids double tool execution, upstream inc-4258). All three knobs respect caller env overrides. Repro: mock upstream whose SSE stays silent for 150s before a complete event sequence — terminal env completes; desktop env aborts at exactly 120s (client timeout) or 90s watchdog + non-streaming fallback loop; the fixed env completes both variants. Tested: bun test src/server/__tests__/network-settings.test.ts src/server/__tests__/conversation-service.test.ts src/server/__tests__/proxy-network-settings.test.ts Tested: cd desktop && bun run test -- src/stores/settingsStore.test.ts src/__tests__/generalSettings.test.tsx Tested: cd desktop && bun run lint Confidence: high Scope-risk: medium --- .../src/__tests__/generalSettings.test.tsx | 4 +- desktop/src/i18n/locales/en.ts | 2 +- desktop/src/i18n/locales/jp.ts | 2 +- desktop/src/i18n/locales/kr.ts | 2 +- desktop/src/i18n/locales/zh-TW.ts | 2 +- desktop/src/i18n/locales/zh.ts | 2 +- desktop/src/pages/Settings.tsx | 4 +- desktop/src/stores/settingsStore.test.ts | 10 ++-- desktop/src/stores/settingsStore.ts | 4 +- .../__tests__/conversation-service.test.ts | 46 +++++++++++++++++++ src/server/__tests__/network-settings.test.ts | 4 +- src/server/services/conversationService.ts | 12 +++++ src/server/services/networkSettings.ts | 12 +++-- 13 files changed, 85 insertions(+), 21 deletions(-) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 9f1c0cd8..57e0b981 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -468,8 +468,8 @@ describe('Settings > General tab', () => { const timeoutInput = screen.getByLabelText('AI request timeout') const saveButton = screen.getAllByRole('button', { name: 'Save' })[0]! - fireEvent.change(timeoutInput, { target: { value: '700' } }) - expect(screen.getByText('Enter a whole number from 5 to 600 seconds.')).toBeInTheDocument() + fireEvent.change(timeoutInput, { target: { value: '2000' } }) + expect(screen.getByText('Enter a whole number from 30 to 1800 seconds.')).toBeInTheDocument() expect(saveButton).toBeDisabled() fireEvent.change(timeoutInput, { target: { value: '90' } }) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 5ee633d2..528681f9 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1074,7 +1074,7 @@ export const en = { 'settings.general.networkProxyUrlRequired': 'Enter a proxy URL.', 'settings.general.networkTimeout': 'AI request timeout', 'settings.general.networkTimeoutValue': '{seconds}s', - 'settings.general.networkTimeoutHint': 'Applies to provider requests, streaming first responses, and provider connection tests. Supports 5-600 seconds.', + 'settings.general.networkTimeoutHint': 'Applies to provider requests, streaming first responses, and provider connection tests. Supports 30-1800 seconds; slow providers may need several minutes before the first streamed byte.', 'settings.general.networkTimeoutUnit': 'sec', 'settings.general.networkTimeoutDecrease': 'Decrease by 30 seconds', 'settings.general.networkTimeoutIncrease': 'Increase by 30 seconds', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index dcf51b5e..a4507e7f 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1076,7 +1076,7 @@ export const jp: Record = { 'settings.general.networkProxyUrlRequired': 'プロキシ URL を入力してください。', 'settings.general.networkTimeout': 'AI リクエストのタイムアウト', 'settings.general.networkTimeoutValue': '{seconds}秒', - 'settings.general.networkTimeoutHint': 'プロバイダーへのリクエスト、ストリーミングの最初の応答、プロバイダー接続テストに適用されます。5〜600 秒に対応します。', + 'settings.general.networkTimeoutHint': 'プロバイダーへのリクエスト、ストリーミングの最初の応答、プロバイダー接続テストに適用されます。30〜1800 秒に対応します。大きなコンテキストでは最初のストリーミングバイトまで数分かかる場合があります。', 'settings.general.networkTimeoutUnit': '秒', 'settings.general.networkTimeoutDecrease': '30 秒減らす', 'settings.general.networkTimeoutIncrease': '30 秒増やす', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 9ace308b..4fd1db08 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1076,7 +1076,7 @@ export const kr: Record = { 'settings.general.networkProxyUrlRequired': '프록시 URL을 입력하세요.', 'settings.general.networkTimeout': 'AI 요청 시간 초과', 'settings.general.networkTimeoutValue': '{seconds}초', - 'settings.general.networkTimeoutHint': '공급자 요청, 스트리밍 첫 응답, 공급자 연결 테스트에 적용됩니다. 5~600초를 지원합니다.', + 'settings.general.networkTimeoutHint': '공급자 요청, 스트리밍 첫 응답, 공급자 연결 테스트에 적용됩니다. 30~1800초를 지원합니다. 큰 컨텍스트에서는 첫 스트리밍 바이트까지 몇 분이 걸릴 수 있습니다.', 'settings.general.networkTimeoutUnit': '초', 'settings.general.networkTimeoutDecrease': '30초 줄이기', 'settings.general.networkTimeoutIncrease': '30초 늘리기', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index c69a3249..3c39aa7c 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1076,7 +1076,7 @@ export const zh: Record = { 'settings.general.networkProxyUrlRequired': '請輸入代理地址。', 'settings.general.networkTimeout': 'AI 請求超時', 'settings.general.networkTimeoutValue': '{seconds} 秒', - 'settings.general.networkTimeoutHint': '用於服務商請求、流式首個響應,以及服務商連線測試。支援 5-600 秒。', + 'settings.general.networkTimeoutHint': '用於服務商請求、流式首個響應,以及服務商連線測試。支援 30-1800 秒;部分服務商在大上下文下首個流式位元組可能需要等待數分鐘。', 'settings.general.networkTimeoutUnit': '秒', 'settings.general.networkTimeoutDecrease': '減少 30 秒', 'settings.general.networkTimeoutIncrease': '增加 30 秒', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 7c714b98..396ba4d5 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1076,7 +1076,7 @@ export const zh: Record = { 'settings.general.networkProxyUrlRequired': '请输入代理地址。', 'settings.general.networkTimeout': 'AI 请求超时', 'settings.general.networkTimeoutValue': '{seconds} 秒', - 'settings.general.networkTimeoutHint': '用于服务商请求、流式首个响应,以及服务商连接测试。支持 5-600 秒。', + 'settings.general.networkTimeoutHint': '用于服务商请求、流式首个响应,以及服务商连接测试。支持 30-1800 秒;部分服务商在大上下文下首个流式字节可能需要等待数分钟。', 'settings.general.networkTimeoutUnit': '秒', 'settings.general.networkTimeoutDecrease': '减少 30 秒', 'settings.general.networkTimeoutIncrease': '增加 30 秒', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 2e44355d..6d806406 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -57,8 +57,8 @@ import { } from '../lib/providerSettingsJson' import { copyTextToClipboard } from '../components/chat/clipboard' -const NETWORK_TIMEOUT_MIN_SECONDS = 5 -const NETWORK_TIMEOUT_MAX_SECONDS = 600 +const NETWORK_TIMEOUT_MIN_SECONDS = 30 +const NETWORK_TIMEOUT_MAX_SECONDS = 1800 const NETWORK_TIMEOUT_STEP_SECONDS = 30 const SETTINGS_CHECKBOX_INPUT_CLASS = 'settings-checkbox-input peer' diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index 670b7a15..2e156f23 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -174,7 +174,7 @@ describe('settingsStore network persistence', () => { window.localStorage.clear() }) - it('defaults old user settings to 120s system network settings', async () => { + it('defaults old user settings to 600s system network settings', async () => { vi.doMock('../api/settings', () => ({ settingsApi: { getUser: vi.fn().mockResolvedValue({}), @@ -215,7 +215,7 @@ describe('settingsStore network persistence', () => { await useSettingsStore.getState().fetchAll() expect(useSettingsStore.getState().network).toEqual({ - aiRequestTimeoutMs: 120_000, + aiRequestTimeoutMs: 600_000, proxy: { mode: 'system', url: '', @@ -256,7 +256,7 @@ describe('settingsStore network persistence', () => { const { useSettingsStore } = await import('./settingsStore') await useSettingsStore.getState().setNetwork({ - aiRequestTimeoutMs: 999_999, + aiRequestTimeoutMs: 9_999_999, proxy: { mode: 'manual', url: ' http://127.0.0.1:7890 ', @@ -264,7 +264,7 @@ describe('settingsStore network persistence', () => { }) expect(useSettingsStore.getState().network).toEqual({ - aiRequestTimeoutMs: 600_000, + aiRequestTimeoutMs: 1_800_000, proxy: { mode: 'manual', url: 'http://127.0.0.1:7890', @@ -272,7 +272,7 @@ describe('settingsStore network persistence', () => { }) expect(updateUser).toHaveBeenCalledWith({ network: { - aiRequestTimeoutMs: 600_000, + aiRequestTimeoutMs: 1_800_000, proxy: { mode: 'manual', url: 'http://127.0.0.1:7890', diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index cd33fdcf..46d4b4ba 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -149,7 +149,7 @@ const DEFAULT_UPDATE_PROXY_SETTINGS: UpdateProxySettings = { } const DEFAULT_NETWORK_SETTINGS: NetworkSettings = { - aiRequestTimeoutMs: 120_000, + aiRequestTimeoutMs: 600_000, proxy: { mode: 'system', url: '', @@ -655,7 +655,7 @@ function normalizeNetworkSettings( settings: NetworkSettingsInput | undefined, ): NetworkSettings { const timeout = typeof settings?.aiRequestTimeoutMs === 'number' && Number.isFinite(settings.aiRequestTimeoutMs) - ? Math.min(Math.max(Math.round(settings.aiRequestTimeoutMs), 5_000), 600_000) + ? Math.min(Math.max(Math.round(settings.aiRequestTimeoutMs), 30_000), 1_800_000) : DEFAULT_NETWORK_SETTINGS.aiRequestTimeoutMs const proxyMode = settings?.proxy?.mode === 'manual' ? 'manual' : 'system' diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 67ce5f7d..6f23aa08 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -727,6 +727,52 @@ describe('ConversationService', () => { expect(env.CLAUDE_ENABLE_STREAM_WATCHDOG).toBe('1') }) + test('buildChildEnv widens the stream idle window and disables the non-streaming fallback (#766)', async () => { + const service = new ConversationService() as any + const env = (await service.buildChildEnv( + '/tmp', + 'ws://127.0.0.1:3456/sdk/test-session?token=test-token', + )) as Record + + // 90s default kills healthy-but-silent third-party streams; 240s keeps the + // watchdog useful without aborting slow thinking/prefill phases. + expect(env.CLAUDE_STREAM_IDLE_TIMEOUT_MS).toBe('240000') + // Non-streaming fallback can never finish for slow providers (first byte + // only arrives after FULL generation), so retries must stay streaming. + expect(env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK).toBe('1') + }) + + test('buildChildEnv respects caller overrides for stream timeout tuning envs', async () => { + const service = new ConversationService() as any + const previous = { + watchdog: process.env.CLAUDE_ENABLE_STREAM_WATCHDOG, + idle: process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS, + fallback: process.env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK, + } + process.env.CLAUDE_ENABLE_STREAM_WATCHDOG = '0' + process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS = '90000' + process.env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK = '0' + try { + const env = (await service.buildChildEnv( + '/tmp', + 'ws://127.0.0.1:3456/sdk/test-session?token=test-token', + )) as Record + + expect(env.CLAUDE_ENABLE_STREAM_WATCHDOG).toBe('0') + expect(env.CLAUDE_STREAM_IDLE_TIMEOUT_MS).toBe('90000') + expect(env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK).toBe('0') + } finally { + for (const [key, value] of [ + ['CLAUDE_ENABLE_STREAM_WATCHDOG', previous.watchdog], + ['CLAUDE_STREAM_IDLE_TIMEOUT_MS', previous.idle], + ['CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK', previous.fallback], + ] as const) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + } + }) + test('buildSessionCliArgs forwards the selected runtime model and effort to the CLI process', () => { const service = new ConversationService() as any const args = service.buildSessionCliArgs( diff --git a/src/server/__tests__/network-settings.test.ts b/src/server/__tests__/network-settings.test.ts index 69dad9e0..e8618fab 100644 --- a/src/server/__tests__/network-settings.test.ts +++ b/src/server/__tests__/network-settings.test.ts @@ -37,7 +37,7 @@ describe('network settings', () => { beforeEach(setup) afterEach(teardown) - it('normalizes missing settings to the 120s system-proxy default', () => { + it('normalizes missing settings to the 600s system-proxy default', () => { expect(normalizeNetworkSettings({})).toEqual({ aiRequestTimeoutMs: DEFAULT_AI_REQUEST_TIMEOUT_MS, proxy: { @@ -50,7 +50,7 @@ describe('network settings', () => { it('clamps AI request timeouts and trims manual proxy URLs', () => { expect(normalizeNetworkSettings({ network: { - aiRequestTimeoutMs: 999_999, + aiRequestTimeoutMs: 9_999_999, proxy: { mode: 'manual', url: ' http://127.0.0.1:7890 ', diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 0945f2bf..de1a54fe 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -1109,6 +1109,18 @@ export class ConversationService { CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: '1', // Desktop must fail stuck provider streams instead of leaving the UI running forever. CLAUDE_ENABLE_STREAM_WATCHDOG: cleanEnv.CLAUDE_ENABLE_STREAM_WATCHDOG || '1', + // Third-party providers can stay silent for minutes mid-stream (thinking + // phases emit no SSE bytes, and many gateways never send pings), so the + // CLI's 90s idle default kills healthy streams (#766). 240s still frees + // a truly dead connection without shooting slow ones. + CLAUDE_STREAM_IDLE_TIMEOUT_MS: cleanEnv.CLAUDE_STREAM_IDLE_TIMEOUT_MS || '240000', + // When a stream does get aborted, retry as streaming instead of falling + // back to non-streaming: a non-streaming request must wait for the FULL + // generation before the first response byte, so slow providers can never + // finish inside API_TIMEOUT_MS — the fallback loops 5-minute aborts + // forever while the UI shows "running" (#766). It can also double-run + // tools (upstream inc-4258). + CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: cleanEnv.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK || '1', CLAUDE_CODE_DIAGNOSTICS_FILE: cliDiagnosticsPath, CLAUDE_COWORK_MEMORY_PATH_OVERRIDE: this.resolveDesktopAutoMemoryPath(workDir), CALLER_DIR: workDir, diff --git a/src/server/services/networkSettings.ts b/src/server/services/networkSettings.ts index b97f70c2..33d653e4 100644 --- a/src/server/services/networkSettings.ts +++ b/src/server/services/networkSettings.ts @@ -10,9 +10,15 @@ export type NetworkSettings = { } } -export const DEFAULT_AI_REQUEST_TIMEOUT_MS = 120_000 -export const MIN_AI_REQUEST_TIMEOUT_MS = 5_000 -export const MAX_AI_REQUEST_TIMEOUT_MS = 600_000 +// API_TIMEOUT_MS is the CLI's *time-to-first-byte* budget for streaming +// requests: third-party Anthropic-compatible gateways (sensenova, bailian, +// zhipu, ...) often send no response bytes — not even headers or an SSE ping — +// until prefill finishes, which can take minutes at large contexts. A short +// default here aborts those healthy-but-slow requests dead at exactly this +// timeout (#766), so the default matches the SDK's own 600s. +export const DEFAULT_AI_REQUEST_TIMEOUT_MS = 600_000 +export const MIN_AI_REQUEST_TIMEOUT_MS = 30_000 +export const MAX_AI_REQUEST_TIMEOUT_MS = 1_800_000 const DEFAULT_NETWORK_SETTINGS: NetworkSettings = { aiRequestTimeoutMs: DEFAULT_AI_REQUEST_TIMEOUT_MS,