mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
fix(desktop): stop killing slow provider streams (#766)
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
This commit is contained in:
parent
5cf8c1bb67
commit
62241a31e5
@ -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' } })
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1076,7 +1076,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'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 秒増やす',
|
||||
|
||||
@ -1076,7 +1076,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'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초 늘리기',
|
||||
|
||||
@ -1076,7 +1076,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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 秒',
|
||||
|
||||
@ -1076,7 +1076,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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 秒',
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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<string, string>
|
||||
|
||||
// 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<string, string>
|
||||
|
||||
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(
|
||||
|
||||
@ -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 ',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user