From 554569a9115879b4e9fc55068027a4d24659a87c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 09:50:18 +0000 Subject: [PATCH] fix: honor KIN_STREAM_IDLE_TIMEOUT on gateway stream path (#485) Unify stream idle timeout resolution through resolveStreamIdleTimeoutMs() so handle-protocol no longer hardcodes 180_000 as fallback and skips env. Document cascade in PANEL_API and panel UI tip; add regression tests. Co-authored-by: ted --- CHANGELOG.md | 1 + docs/PANEL_API.md | 2 ++ src/lib/core/config.mjs | 8 +++++++ src/lib/protocol/handle-protocol.mjs | 11 ++-------- test/unit/routing-file.test.mjs | 29 +++++++++++++++++++++++++ web/src/features/settings/pool-pane.tsx | 2 +- 6 files changed, 43 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 822718387..856737635 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ ## 未发布 - Anthropic 流式 `event: error` 经 `mapUpstreamError` 重映射,Usage Policy 与 non-stream 一致为 `policy_blocked` / `refusal_guard`。(#503) +- 修复网关流路径忽略 `KIN_STREAM_IDLE_TIMEOUT`:与面板 `failover.stream_idle_timeout_ms`、`kernel.json` 同步走同一级联(面板 → cfg.limits → 环境变量 → 默认 180 秒),默认仍为 180 秒(#485)。 ## 1.3.182 — 2026-10-17 diff --git a/docs/PANEL_API.md b/docs/PANEL_API.md index a54f5f673..5c0b649e6 100644 --- a/docs/PANEL_API.md +++ b/docs/PANEL_API.md @@ -202,6 +202,8 @@ Claude PATCH `max_sessions` 返回 400 `claude_max_sessions_forbidden`;OpenAI `PUT` 热更新。`tiers` 必须回传:该块是整体替换而非 patch,缺字段即置空。Claude 分档保存只传播到未手动覆盖的 Claude 槽。OpenAI `codex.quota` 按字段合并,不覆盖其它 Codex 协议/客户端配置,只更新跟随的 OpenAI 限额。 +`failover.stream_idle_timeout_ms`(毫秒,30 秒–60 分钟,默认 180 秒):上游流连续无事件即判超时;网关流路径、各槽 `kernel.json` 的 `idle_timeout_seconds` 与 job 看门狗共用。优先级:面板该字段 → 启动时写入 `cfg.limits.stream_idle_timeout_ms` → 控制面 `.env` 的 `KIN_STREAM_IDLE_TIMEOUT` → 180 秒。保存后热写各槽 `kernel.json`;运行中 kernel 在无任务时自动重启以读取新看门狗值。 + `compatibility.persona_preset`(`official` / `official_full` / `zero` / `custom`)和 `compatibility.cache_ttl`(`5m` / `1h`)保存后投影到每个 Claude 槽的 `vms//run/kernel.json`:`persona_preset`、`system_layout`(`zero`→`zero`,其余→`identity`)、`default_cache_ttl`。响应 `kernel_persona.updated` 是本次字节有变化的槽数。`PATCH /vms/:id` 的 `timezone` / `timezone_follow_proxy` 另把 `timezone` 热写进该槽 `kernel.json`,`timezone_sync.kernel_hot` 表示文件有变化。容器 `TZ` 不在这次写入里。Codex 槽不写。 `compatibility.agent_standing`(字符串,≤2000)与四个按档布尔 map `agent_standing_presets` / `agent_standing_hide_presets` / `persona_env_presets` / `persona_hide_presets` 控制常驻约束、约束遮罩、Environment 和整档 usage 遮罩,不投影到 `kernel.json`,Node 每次请求热读。`GET /api/panel/persona/preview-vars?timezone=` 返回 system提示词页预览用的真实模板常量(身份句、官方 agent 全文、按该时区渲染的 Environment),不含 billing;时区非法或缺省按 UTC。 diff --git a/src/lib/core/config.mjs b/src/lib/core/config.mjs index 2bc3d989f..866f2a898 100644 --- a/src/lib/core/config.mjs +++ b/src/lib/core/config.mjs @@ -25,6 +25,14 @@ export function streamIdleTimeoutMs(routing, fallbackMs = Number(process.env.KIN return Math.min(STREAM_IDLE_TIMEOUT_MAX_MS, Math.max(STREAM_IDLE_TIMEOUT_MIN_MS, Math.round(ms))) } +/** Gateway stream path: panel routing → cfg.limits → KIN_STREAM_IDLE_TIMEOUT → 180s default. */ +export function resolveStreamIdleTimeoutMs(routing, cfg) { + const limitsMs = Number(cfg?.limits?.stream_idle_timeout_ms) + const fallback = + Number.isFinite(limitsMs) && limitsMs > 0 ? limitsMs : Number(process.env.KIN_STREAM_IDLE_TIMEOUT) || 180_000 + return streamIdleTimeoutMs(routing, fallback) +} + /** * How long a Claude stream may sit in the queue before the first downstream * byte. Cloudflare returns 524 when the origin writes nothing for ~100s. diff --git a/src/lib/protocol/handle-protocol.mjs b/src/lib/protocol/handle-protocol.mjs index 67815fdcb..531d1e888 100644 --- a/src/lib/protocol/handle-protocol.mjs +++ b/src/lib/protocol/handle-protocol.mjs @@ -3,11 +3,7 @@ * server wiring; this factory owns convert → pool → Go/Rust hop → client. */ import { applyIntercept } from '../core/intercept.mjs' -import { - streamIdleTimeoutMs as idleTimeoutFromRouting, - inboundBodyLimit, - queueKeepaliveDelayMs, -} from '../core/config.mjs' +import { resolveStreamIdleTimeoutMs, inboundBodyLimit, queueKeepaliveDelayMs } from '../core/config.mjs' import { applyEagerToolStreaming } from './eager-tool-streaming.mjs' import { inboundRefusalDeviceId, @@ -1093,10 +1089,7 @@ export function createHandleProtocol(deps) { // First downstream byte during a queue wait. Short misses stay HTTP 429/529; // anything still waiting past this writes SSE so Cloudflare does not 524. const queueKeepaliveDelay = queueKeepaliveDelayMs(getRouting(), cfg.limits?.queue_keepalive_delay_ms) - const streamIdleTimeoutMs = idleTimeoutFromRouting( - getRouting(), - Number(cfg.limits.stream_idle_timeout_ms) || 180_000, - ) + const streamIdleTimeoutMs = resolveStreamIdleTimeoutMs(getRouting(), cfg) const managedKey = req.apiKeyRecord || null if (managedKey) { const gate = apiKeyStore.acquire(managedKey) diff --git a/test/unit/routing-file.test.mjs b/test/unit/routing-file.test.mjs index 990d15c5e..f1e156bfa 100644 --- a/test/unit/routing-file.test.mjs +++ b/test/unit/routing-file.test.mjs @@ -11,6 +11,7 @@ import { queueKeepaliveDelayMs, readRoutingConfigFile, routingConfigFile, + resolveStreamIdleTimeoutMs, streamIdleTimeoutMs, } from '../../src/lib/core/config.mjs' @@ -84,6 +85,34 @@ test('streamIdleTimeoutMs prefers the panel value, then the caller fallback', () }) }) +test('resolveStreamIdleTimeoutMs cascades panel, cfg.limits, env, then 180s default', () => { + withEnv('KIN_STREAM_IDLE_TIMEOUT', '', () => { + assert.equal(resolveStreamIdleTimeoutMs({ failover: {} }, { limits: {} }), 180_000) + assert.equal( + resolveStreamIdleTimeoutMs( + { failover: { stream_idle_timeout_ms: 600_000 } }, + { limits: { stream_idle_timeout_ms: 2000 } }, + ), + 600_000, + ) + assert.equal(resolveStreamIdleTimeoutMs({ failover: {} }, { limits: { stream_idle_timeout_ms: 90_000 } }), 90_000) + }) + withEnv('KIN_STREAM_IDLE_TIMEOUT', '420000', () => { + assert.equal(resolveStreamIdleTimeoutMs({ failover: {} }, { limits: {} }), 420_000) + assert.equal( + resolveStreamIdleTimeoutMs({ failover: {} }, { limits: { stream_idle_timeout_ms: Number.NaN } }), + 420_000, + ) + }) +}) + +test('resolveStreamIdleTimeoutMs avoids hardcoded 180k when cfg.limits is unset but env is set', () => { + withEnv('KIN_STREAM_IDLE_TIMEOUT', '600000', () => { + const cfg = { limits: { max_body_bytes: 1 } } + assert.equal(resolveStreamIdleTimeoutMs({ failover: {} }, cfg), 600_000) + }) +}) + test('queueKeepaliveDelayMs stays under the Cloudflare window', () => { withEnv('KIN_QUEUE_KEEPALIVE_DELAY', '', () => { assert.equal(queueKeepaliveDelayMs(null), DEFAULT_QUEUE_KEEPALIVE_DELAY_MS) diff --git a/web/src/features/settings/pool-pane.tsx b/web/src/features/settings/pool-pane.tsx index a2d99ca06..fc7d3dbbb 100644 --- a/web/src/features/settings/pool-pane.tsx +++ b/web/src/features/settings/pool-pane.tsx @@ -870,7 +870,7 @@ export function PoolPane({