From 446cc47846eda67ef76ae8feedd44a6036b79d07 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, 11 Sep 2026 16:40:45 +0800 Subject: [PATCH] fix(streaming): scale the overall stream cap with the user request timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop injects CLAUDE_STREAM_MAX_DURATION_MS=600000, a wall-clock cap that no incoming chunk resets. Slow local models (LM Studio / qwen3) and very large generations legitimately keep streaming thinking_delta past ten minutes, so the stream was killed mid-response even though it was alive and producing content (#1307, #1275). Raising "请求超时" could not help: the cap was hardcoded, and the first-token budget it was meant to raise was silently bounded by that same 600s. Derive the cap from the user's request timeout, never below the existing 600s default so the #766 trickle protection is not tightened when a short first-byte budget is configured. Push the derived value through the per-turn env hot update as well — otherwise a session that is already running keeps its spawn time 600s no matter what the user configures, which is exactly the retry path the reports describe. Co-Authored-By: Claude Opus 4.8 --- 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 +- .../__tests__/conversation-service.test.ts | 88 +++++++++++++++++++ src/server/services/conversationService.ts | 38 +++++++- src/server/services/networkSettings.ts | 15 ++++ 8 files changed, 143 insertions(+), 8 deletions(-) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index a1946056..426bce8c 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1787,7 +1787,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le '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 30-1800 seconds; slow providers may need several minutes before the first streamed byte.', + '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. Raising it also extends the overall time limit for a single streamed response (never below 600 seconds).', '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 090158f7..61184a54 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1789,7 +1789,7 @@ export const jp: Record = { 'settings.general.networkProxyUrlRequired': 'プロキシ URL を入力してください。', 'settings.general.networkTimeout': 'AI リクエストのタイムアウト', 'settings.general.networkTimeoutValue': '{seconds}秒', - 'settings.general.networkTimeoutHint': 'プロバイダーへのリクエスト、ストリーミングの最初の応答、プロバイダー接続テストに適用されます。30〜1800 秒に対応します。大きなコンテキストでは最初のストリーミングバイトまで数分かかる場合があります。', + 'settings.general.networkTimeoutHint': 'プロバイダーへのリクエスト、ストリーミングの最初の応答、プロバイダー接続テストに適用されます。30〜1800 秒に対応します。大きなコンテキストでは最初のストリーミングバイトまで数分かかる場合があります。値を大きくすると、1 回のストリーミング応答の総時間上限も広がります(最低 600 秒)。', '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 5425da1c..4772ac73 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1789,7 +1789,7 @@ export const kr: Record = { 'settings.general.networkProxyUrlRequired': '프록시 URL을 입력하세요.', 'settings.general.networkTimeout': 'AI 요청 시간 초과', 'settings.general.networkTimeoutValue': '{seconds}초', - 'settings.general.networkTimeoutHint': '공급자 요청, 스트리밍 첫 응답, 공급자 연결 테스트에 적용됩니다. 30~1800초를 지원합니다. 큰 컨텍스트에서는 첫 스트리밍 바이트까지 몇 분이 걸릴 수 있습니다.', + 'settings.general.networkTimeoutHint': '공급자 요청, 스트리밍 첫 응답, 공급자 연결 테스트에 적용됩니다. 30~1800초를 지원합니다. 큰 컨텍스트에서는 첫 스트리밍 바이트까지 몇 분이 걸릴 수 있습니다. 값을 늘리면 단일 스트리밍 응답의 총 시간 상한도 함께 늘어납니다(최소 600초).', '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 ef58f86c..6fef3901 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1788,7 +1788,7 @@ export const zh: Record = { 'settings.general.networkProxyUrlRequired': '請輸入代理地址。', 'settings.general.networkTimeout': 'AI 請求超時', 'settings.general.networkTimeoutValue': '{seconds} 秒', - 'settings.general.networkTimeoutHint': '用於服務商請求、流式首個響應,以及服務商連線測試。支援 30-1800 秒;部分服務商在大上下文下首個流式位元組可能需要等待數分鐘。', + 'settings.general.networkTimeoutHint': '用於服務商請求、流式首個響應,以及服務商連線測試。支援 30-1800 秒;部分服務商在大上下文下首個流式位元組可能需要等待數分鐘。調大後,單次流式回應的總時長上限同步放寬(不低於 600 秒)。', '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 65ef97a2..9ac20343 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1788,7 +1788,7 @@ export const zh: Record = { 'settings.general.networkProxyUrlRequired': '请输入代理地址。', 'settings.general.networkTimeout': 'AI 请求超时', 'settings.general.networkTimeoutValue': '{seconds} 秒', - 'settings.general.networkTimeoutHint': '用于服务商请求、流式首个响应,以及服务商连接测试。支持 30-1800 秒;部分服务商在大上下文下首个流式字节可能需要等待数分钟。', + 'settings.general.networkTimeoutHint': '用于服务商请求、流式首个响应,以及服务商连接测试。支持 30-1800 秒;部分服务商在大上下文下首个流式字节可能需要等待数分钟。调大后,单次流式回复的总时长上限同步放宽(不低于 600 秒)。', 'settings.general.networkTimeoutUnit': '秒', 'settings.general.networkTimeoutDecrease': '减少 30 秒', 'settings.general.networkTimeoutIncrease': '增加 30 秒', diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 9a709b25..d947b8d4 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -184,11 +184,13 @@ describe('ConversationService', () => { sessionId: string, sent: string[], networkDerivedFirstTokenTimeout = true, + networkDerivedStreamMaxDuration = true, ) { const session = { outputCallbacks: [], networkRoutingFingerprint: '', networkDerivedFirstTokenTimeout, + networkDerivedStreamMaxDuration, sdkSocket: { send(line: string) { sent.push(line) @@ -473,6 +475,51 @@ describe('ConversationService', () => { } }) + test('buildChildEnv raises the overall stream cap with the user request timeout so an active long response is not killed (#1307)', async () => { + const prev = process.env.CLAUDE_STREAM_MAX_DURATION_MS + delete process.env.CLAUDE_STREAM_MAX_DURATION_MS + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ network: { aiRequestTimeoutMs: 1_800_000 } }), + 'utf-8', + ) + try { + const service = new ConversationService() as any + const env = (await service.buildChildEnv('/tmp')) as Record + + // The overall cap is NOT reset by incoming chunks, so a local model that + // keeps streaming thinking_delta events past it is killed mid-response. + // Raising "请求超时" must therefore extend the cap too — otherwise the + // user's timeout setting is silently capped at 600s (#1307). + expect(env.CLAUDE_STREAM_MAX_DURATION_MS).toBe('1800000') + } finally { + if (prev === undefined) delete process.env.CLAUDE_STREAM_MAX_DURATION_MS + else process.env.CLAUDE_STREAM_MAX_DURATION_MS = prev + } + }) + + test('buildChildEnv keeps the overall stream cap floor for a short request timeout (#766)', async () => { + const prev = process.env.CLAUDE_STREAM_MAX_DURATION_MS + delete process.env.CLAUDE_STREAM_MAX_DURATION_MS + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ network: { aiRequestTimeoutMs: 30_000 } }), + 'utf-8', + ) + try { + const service = new ConversationService() as any + const env = (await service.buildChildEnv('/tmp')) as Record + + // Shrinking the cap for a short first-byte budget would re-open #766: + // a stream that trickles content deltas just under the idle window must + // still be freed after a fixed overall duration. + expect(env.CLAUDE_STREAM_MAX_DURATION_MS).toBe('600000') + } finally { + if (prev === undefined) delete process.env.CLAUDE_STREAM_MAX_DURATION_MS + else process.env.CLAUDE_STREAM_MAX_DURATION_MS = prev + } + }) + test('buildChildEnv lets caller env override the first-token watchdog (#826)', async () => { const prev = process.env.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS process.env.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS = '900000' @@ -777,10 +824,51 @@ describe('ConversationService', () => { all_proxy: 'http://127.0.0.1:17892', API_TIMEOUT_MS: '180000', CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS: '180000', + // The floor still holds on the hot-update path: lowering the request + // timeout must not shrink the overall cap below its 600s default. + CLAUDE_STREAM_MAX_DURATION_MS: '600000', }) expect(JSON.parse(sent[1]!).type).toBe('user') }) + test('sendMessage hot-applies a raised request timeout to the stream cap so a running session is not stuck at 600s (#1307)', async () => { + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ + network: { + aiRequestTimeoutMs: 600_000, + proxy: { mode: 'direct', url: '' }, + }, + }), + 'utf-8', + ) + const service = new ConversationService() as any + const sent: string[] = [] + const session = installNetworkTestSession(service, 'raised-timeout', sent) + await service.refreshNetworkEnvironmentBeforeTurn('raised-timeout', session) + + await fs.writeFile( + path.join(tmpDir, 'settings.json'), + JSON.stringify({ + network: { + aiRequestTimeoutMs: 1_800_000, + proxy: { mode: 'direct', url: '' }, + }, + }), + 'utf-8', + ) + + expect(await service.sendMessage('raised-timeout', 'retry the long prompt')).toBe(true) + expect(sent).toHaveLength(2) + const update = JSON.parse(sent[0]!) + expect(update.type).toBe('update_environment_variables') + // The reported path is: user hits the 600s error, raises the timeout, and + // retries in the SAME conversation. The live CLI re-reads this per request, + // so it has to be pushed down — otherwise the retry dies at 600s again. + expect(update.variables.CLAUDE_STREAM_MAX_DURATION_MS).toBe('1800000') + expect(JSON.parse(sent[1]!).type).toBe('user') + }) + test('sendMessage does not resend network env when the system bridge fingerprint is unchanged', async () => { const originalBridgeUrl = process.env.CC_HAHA_SYSTEM_PROXY_URL process.env.CC_HAHA_SYSTEM_PROXY_URL = 'http://127.0.0.1:17893' diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index cdfd27f0..1debc422 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -58,6 +58,7 @@ import { attributionHeaderEnvForModel } from './attributionHeaderPolicy.js' import { buildNetworkEnvironment, loadNetworkSettings, + resolveStreamMaxDurationMs, SYSTEM_PROXY_URL_ENV, type NetworkSettings, } from './networkSettings.js' @@ -200,6 +201,7 @@ type SessionProcess = { permissionMode: string networkRoutingFingerprint: string networkDerivedFirstTokenTimeout: boolean + networkDerivedStreamMaxDuration: boolean sdkToken: string sdkSocket: { send(data: string): void } | null sdkAttached: Promise @@ -426,7 +428,10 @@ export class ConversationService { // chdir 后落到正确目录。 // const networkSettings = await loadNetworkSettings() - const networkRuntimeMetadata = { firstTokenTimeoutDerived: false } + const networkRuntimeMetadata = { + firstTokenTimeoutDerived: false, + streamMaxDurationDerived: false, + } const childEnv = await this.buildChildEnv( launchWorkDir, sdkUrl, @@ -472,6 +477,7 @@ export class ConversationService { permissionMode: options?.permissionMode || 'default', networkRoutingFingerprint: networkRoutingFingerprint(networkSettings, childEnv), networkDerivedFirstTokenTimeout: networkRuntimeMetadata.firstTokenTimeoutDerived, + networkDerivedStreamMaxDuration: networkRuntimeMetadata.streamMaxDurationDerived, sdkToken: this.getSdkTokenFromUrl(sdkUrl), sdkSocket: null, seenSdkMessageUuids: new Set(), @@ -662,6 +668,14 @@ export class ConversationService { if (session.networkDerivedFirstTokenTimeout) { variables.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS = networkEnv.API_TIMEOUT_MS } + // The CLI re-reads this per request, so the fix for #1307 must reach a + // session that is already running — otherwise the user sees the 600s error, + // raises the timeout, retries in the same conversation and hits it again. + if (session.networkDerivedStreamMaxDuration) { + variables.CLAUDE_STREAM_MAX_DURATION_MS = String( + resolveStreamMaxDurationMs(networkEnv.API_TIMEOUT_MS), + ) + } const sent = this.sendSdkMessage(sessionId, { type: 'update_environment_variables', @@ -1522,7 +1536,10 @@ export class ConversationService { sdkUrl?: string, options?: SessionStartOptions, networkSettingsOverride?: NetworkSettings, - networkRuntimeMetadata?: { firstTokenTimeoutDerived: boolean }, + networkRuntimeMetadata?: { + firstTokenTimeoutDerived: boolean + streamMaxDurationDerived: boolean + }, ): Promise> { // Provider isolation: when Desktop has its own provider config/index, // strip inherited provider env vars so the child CLI reads fresh values @@ -1568,6 +1585,8 @@ export class ConversationService { if (networkRuntimeMetadata) { networkRuntimeMetadata.firstTokenTimeoutDerived = !cleanEnv.CLAUDE_STREAM_FIRST_TOKEN_TIMEOUT_MS + networkRuntimeMetadata.streamMaxDurationDerived = + !cleanEnv.CLAUDE_STREAM_MAX_DURATION_MS } delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN if (options?.resumeInterruptedTurn === false) { @@ -1603,6 +1622,14 @@ export class ConversationService { networkSettingsOverride ?? await loadNetworkSettings(), cleanEnv, ) + // The overall-duration cap has to scale with the user's "请求超时" or raising + // that setting can never extend a long response — the cap is a wall-clock + // budget that no chunk resets, so a slow local model that legitimately + // thinks past it is killed mid-stream (#1307). The floor keeps the existing + // 600s protection from being TIGHTENED when the user configures a short + // first-byte budget (a lower cap would kill legitimate long responses even + // earlier); the per-turn hot update below mirrors this for live turns. + const streamMaxDurationMs = resolveStreamMaxDurationMs(networkEnv.API_TIMEOUT_MS) const traceCaptureEnabled = (await readTraceCaptureSettings()).enabled if (explicitProviderEnv && options?.model?.trim()) { explicitProviderEnv.ANTHROPIC_MODEL = options.model.trim() @@ -1639,7 +1666,12 @@ export class ConversationService { // just under 240s apart keeps it alive forever and the request hangs with // no completion (#766: "卡住" with slowly growing tokens). This independent // cap frees such a stream after a fixed duration regardless of trickle. - CLAUDE_STREAM_MAX_DURATION_MS: cleanEnv.CLAUDE_STREAM_MAX_DURATION_MS || '600000', + // It tracks the user's "请求超时" (never below MIN_STREAM_MAX_DURATION_MS) + // so that raising the timeout also extends legitimately long responses, + // and a provider preset's own CLAUDE_STREAM_MAX_DURATION_MS still wins + // via the explicitProviderEnv spread below (#1307). + CLAUDE_STREAM_MAX_DURATION_MS: + cleanEnv.CLAUDE_STREAM_MAX_DURATION_MS || String(streamMaxDurationMs), // Abort a local tool call when its JSON arguments stop making progress. // Healthy input_json_delta events reset this budget; the independent full // response cap above still bounds a stream that trickles forever. diff --git a/src/server/services/networkSettings.ts b/src/server/services/networkSettings.ts index 32800ca7..fd7834f3 100644 --- a/src/server/services/networkSettings.ts +++ b/src/server/services/networkSettings.ts @@ -27,6 +27,21 @@ export type NetworkSettings = { 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 +// Floor for the CLI's overall stream-duration cap (CLAUDE_STREAM_MAX_DURATION_MS). +// That cap is what frees an endlessly-trickling provider stream (#766), but it is +// a wall-clock budget that no incoming chunk resets. It must therefore never be +// LOWER than the user's own "请求超时": a local model that legitimately spends +// longer than that thinking or generating would otherwise be killed mid-response +// no matter how far the user raises the timeout (#1307). The floor keeps the +// #766 protection intact when the user configures a very short first-byte budget. +export const MIN_STREAM_MAX_DURATION_MS = 600_000 +// Shared by the spawn-time child env and the per-turn hot update so the two +// cannot drift apart. +export function resolveStreamMaxDurationMs( + apiTimeoutMs: string | number | undefined, +): number { + return Math.max(MIN_STREAM_MAX_DURATION_MS, Number(apiTimeoutMs) || 0) +} export const SYSTEM_PROXY_URL_ENV = 'CC_HAHA_SYSTEM_PROXY_URL' export const SYSTEM_PROXY_ERROR_ENV = 'CC_HAHA_SYSTEM_PROXY_ERROR'