Skip to content

Commit f187423

Browse files
authored
feat(proxy): WebSocket connection pool fixes prompt-cache hit-rate jitter (#440)
* feat(proxy): add WS connection pool module (no wiring yet) 新增 PersistentWs + WsConnectionPool + WsReusedConnectionError,准备解决 上游 WS gateway 按连接 ID 哈希路由导致的 prompt cache 命中率抖动 (5%~99% bimodal)。本 commit 纯模块新增 + 26 个单测,未接线,零回归风险。 设计要点: - Pool key = `${entryId}:${conversationId}`,覆盖显式 + 隐式续链 - 单 WS strict 串行(codex 协议要求),busy 时 acquire 返回 bypass 让 caller 走旧路径(不排队,避免死锁) - 无 idle TTL;max_age=55min 留 5min 缓冲(server 60min 硬限) - 死连/abort/账号状态变化级联清理 - 复用失败(pre-response)抛 WsReusedConnectionError,caller 可单次 retry; 流中段失败走 controller.error 不 retry(client 已收到部分数据) config schema 新增 ws_pool: { enabled: true, max_age_ms, max_per_account } * feat(proxy): wire WS connection pool through ws-transport + proxy-handler 接线 ws-pool 到主请求链路。 ws-transport.ts: - 抽出 openOneShotWs 保持原有 one-shot 语义(向后兼容,旧调用方零变更) - createWebSocketResponse 增加 poolCtx? 参数;带 ctx 时先尝试 pool.acquire → 命中复用 PersistentWs.send;遇 WsReusedConnectionError 单次回退 one-shot - pool 自身故障(factory 抛错)也会 fallback 到 one-shot,不污染调用方 codex-api.ts: - createResponse / createResponseViaWebSocket 透传 poolCtx 到 createWebSocketResponse - HTTP 路径完全不受影响 proxy-handler.ts: - buildPoolCtx() 根据 useWebSocket + chainConversationId 生成 poolKey = `${entryId}:${chainConversationId}`,仅 WS 路径生效 - 主流程 + handleNonStreaming 的 empty-response retry 都用同一个 builder account-pool.ts: - markStatus(non-active) / markRateLimited / removeAccount / updateToken(refresh 完成)级联调 evictByEntryId 关闭该账号所有池中 WS。理由:refresh 后老 WS 携带的 access_token 已失效;其他状态变化下账号本身不可用,留池只浪费 - 用 dynamic import 隔离 ws-pool,避免 account-pool 单测必须拉 proxy 层 src/index.ts: SIGINT/SIGTERM shutdown 钩子追加 wsPool.shutdown() 优雅关闭 集成测:tests/integration/ws-pool-reuse.test.ts 起本地 ws.Server 验证 - 5 turn 同 conv → server 仅 1 次 connection - 不同 conv → 各自 1 个连接 - server 主动 close → 池立即驱逐,下次 acquire 新建 - enabled:false → 退化为 one-shot(每 turn 新连接) - evictByEntryId → 池清空 + 后续重建 - 不传 poolCtx → 行为完全等价于今天 测试:1702 passed (+1 skipped),零回归 * feat(proxy): observability for WS pool decisions + CHANGELOG ws-transport: 新增 WsDispatchDecision 类型 + WsPoolContext.onDecision 回调, 四种决定 (reuse / new / bypass:<reason> / retry-after-stale-reuse) 在 dispatch 时刻一次性 emit 给 caller。 proxy-handler: buildPoolCtx 装上 onDecision listener,对每个 WS 请求 emit 一行 `[fmt] Account E | rid=R | ws=reuse:abc` 之类的日志,便于直接观察 池命中率 + 抖动归因。 CHANGELOG: Unreleased → Fixed 加完整条目,写明问题、原因、配置、回滚。 * fix(proxy): wire ws_pool config into singleton (self-review of #440) PR #440 加了 config schema 字段 ws_pool 但忘了真正读取它 —— `getWsPool()` 永远用 DEFAULT_WS_POOL_CONFIG,用户改 `ws_pool.enabled: false` 完全无效, 回滚策略破坏。 修复:startServer() 在加载 cfg 后立即调 setWsPoolConfig() 用用户配置替换 单例。同时把 shutdown 钩子的 dynamic import 一起改成 static(同模块复用)。 新增 3 个测试覆盖单例 wiring 链路: - enabled:false → acquire 永远 bypass(disabled),factory 不调 - 默认 getWsPool() 工作正常 - setWsPoolConfig 后调覆盖前调 测试:1705 passed (+1 skipped) --------- Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>
1 parent f62a4ac commit f187423

11 files changed

Lines changed: 1678 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@
1515
- Usage history `five_min` granularity(5 分钟桶)+ Dashboard 新增「5 min」粒度选项与「Last 1h / 6h」时间窗:snapshot 默认 5 分钟一记,新粒度等同于一桶一快照,方便排查刚发生的请求;旧的 hourly/daily 不变,按 granularity 自动收敛兼容窗口(`src/auth/usage-stats.ts``src/routes/admin/usage-stats.ts``shared/hooks/use-usage-stats.ts``web/src/pages/UsageStats.tsx`
1616
- 共享纯函数 `formatHitRate` / `sumWindow` / `formatUsageNumber` 抽到 `shared/utils/usage-stats.ts`,配套 vitest 单测覆盖边界(input=0 → "—"、<0.01% 截断、windowed 求和等),UsageChart 与 UsageStats 复用同一份格式化逻辑(`shared/utils/usage-stats.ts``shared/utils/__tests__/usage-stats.test.ts`
1717

18+
### Fixed
19+
20+
- **WebSocket 连接池**`src/proxy/ws-pool.ts` + `src/proxy/ws-transport.ts` + `src/routes/shared/proxy-handler.ts`):上游 chatgpt.com 的 WS gateway 按"连接 ID"做负载均衡 hash,过去 codex-proxy 对每个 WS 请求都 `new WebSocket(url)`,导致同一会话同一账号的 prompt cache 命中率在 5%~99% 之间剧烈抖动(同一逻辑会话被路由到不同 backend,每个 backend 各自缓存了不同长度的前缀;实测 cached_tokens 反复出现 1920/2432/24448/40320/47488 等离散"checkpoint")。引入 per-`(entryId, conversationId)` 的持久 WS 连接池:
21+
- 单 WS 上 strict request/response 串行(codex 协议要求),busy 时旁路开新一次性 WS 而非排队(避免死锁)
22+
- 无 idle TTL,连接保持开放直到自然死亡 / `max_age_ms`(默认 55 min,留 5 min 缓冲,比 server 60 min 硬限制提前关)/ 账号状态变化(`evictByEntryId`)级联清理
23+
- 复用失败(pre-response close)抛 `WsReusedConnectionError`,自动单次 fallback 到一次性新连接;流中段失败保持原语义抛给客户端(不重试,client 已收到部分数据)
24+
- account-pool 在 `markRateLimited` / `markStatus(non-active)` / `removeAccount` / `updateToken`(refresh 完成)时级联 `evictByEntryId`,避免老 WS 携带的 access_token 被复用
25+
- 新增配置 `ws_pool: { enabled: true, max_age_ms: 3300000, max_per_account: 8 }`;可 `enabled: false` + 重启回滚到旧行为
26+
- SIGTERM/SIGINT 进程退出钩子追加 `wsPool.shutdown()` 优雅关闭所有池中连接
27+
- 入口日志加 `ws=reuse:<id>` / `ws=new:<id>` / `ws=bypass(<reason>)` / `ws=retry-after-stale-reuse:<id>` 字段,配合 `rid` 可对照 cache 命中率
28+
- 集成测:`tests/integration/ws-pool-reuse.test.ts` 起本地 `ws.Server` 验证 5 turn 同会话只触发 1 次 `connection`
29+
1830
### Changed
1931

2032
- `src/routes/shared/proxy-handler.ts` 入口与 Usage 日志补充诊断字段:入口行新增 `rid` / `conv` / `key` / `prev=<src>:<tail8>` / `tools=N` / `resume=on|off:<reason>`(reason 含 `no_pref_entry`/`acct_mismatch`/`instr_diff`/`missing_tool_calls`/`cont_start_eq_len`),Usage 行带 `rid``hit=X.X%`,便于对照 prompt-cache 命中率为何偏低、或同一会话请求是否落到同一 cache key

src/auth/account-pool.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,30 @@ export class AccountPool {
114114

115115
removeAccount(id: string): boolean {
116116
this.lifecycle.clearLock(id);
117+
this.evictWsPool(id);
117118
return this.registry.removeAccount(id);
118119
}
119120

120121
updateToken(entryId: string, newToken: string, refreshToken?: string): void {
121122
this.registry.updateToken(entryId, newToken, refreshToken);
123+
// The new access_token doesn't take effect on already-open WebSocket
124+
// sessions (the upstream auth header is captured at handshake), so any
125+
// pooled WS for this entry is now using a stale credential. Evict so the
126+
// next request opens a fresh WS with the refreshed token.
127+
this.evictWsPool(entryId);
128+
}
129+
130+
/** Drop any pooled WebSocket connections for `entryId`. Used by status
131+
* mutations and token refresh to prevent in-flight reuse from carrying
132+
* stale auth or routing into a backend the account is no longer welcome
133+
* on. Lazy-imports ws-pool so this module doesn't pull the proxy layer
134+
* into bootstrap when the pool isn't otherwise reachable. */
135+
private evictWsPool(entryId: string): void {
136+
// Avoid hard import: account-pool is also exercised in unit tests that
137+
// never touch the WS layer, and dynamic resolution keeps that contract.
138+
void import("../proxy/ws-pool.js")
139+
.then((mod) => mod.getWsPool().evictByEntryId(entryId))
140+
.catch(() => { /* pool unavailable in this build/test context — ignore */ });
122141
}
123142

124143
setLabel(entryId: string, label: string | null): boolean {
@@ -135,6 +154,10 @@ export class AccountPool {
135154
markStatus(entryId: string, status: AccountEntry["status"]): void {
136155
if (this.registry.markStatus(entryId, status)) {
137156
this.lifecycle.clearLock(entryId);
157+
// Status transitions to expired/banned/disabled make the account
158+
// unusable; reusing a pooled WS would just hit the same wall on the
159+
// upstream side. Evict so the pool doesn't hold a doomed connection.
160+
if (status !== "active") this.evictWsPool(entryId);
138161
}
139162
if (status === "expired" && this._onExpired) {
140163
this._onExpired(entryId);
@@ -147,6 +170,7 @@ export class AccountPool {
147170
): void {
148171
if (this.registry.markRateLimited(entryId, this.rateLimitBackoffSeconds, options)) {
149172
this.lifecycle.clearLock(entryId);
173+
this.evictWsPool(entryId);
150174
}
151175
}
152176

src/config-schema.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@ export const ConfigSchema = z.object({
7272
auto_download: z.boolean().default(false),
7373
allow_prerelease: z.boolean().default(false),
7474
}).default({}),
75+
/** WebSocket connection pool — pins same (entryId, conversationId) to the
76+
* same physical WS so the upstream LB keeps prompt cache warm across
77+
* turns. See `src/proxy/ws-pool.ts` for the rationale. */
78+
ws_pool: z.object({
79+
enabled: z.boolean().default(true),
80+
/** Hard upper bound per connection. Server enforces a 60-min cap; we
81+
* close 5 min early to avoid disrupting in-flight requests. */
82+
max_age_ms: z.number().int().positive().default(3_300_000),
83+
/** Cap on concurrent pooled connections per account, to bound memory
84+
* when a user opens many parallel conversations. */
85+
max_per_account: z.number().int().positive().default(8),
86+
}).default({}),
7587
ollama: z.object({
7688
enabled: z.boolean().default(false),
7789
host: z.string().default("127.0.0.1"),

src/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { createModelRoutes } from "./routes/models.js";
2121
import { createWebRoutes } from "./routes/web.js";
2222
import { CookieJar } from "./proxy/cookie-jar.js";
2323
import { ProxyPool } from "./proxy/proxy-pool.js";
24+
import { setWsPoolConfig, getWsPool } from "./proxy/ws-pool.js";
2425
import { createProxyRoutes } from "./routes/proxies.js";
2526
import { createResponsesRoutes } from "./routes/responses.js";
2627
import { startUpdateChecker, stopUpdateChecker } from "./update-checker.js";
@@ -110,6 +111,15 @@ export async function startServer(options?: StartOptions): Promise<ServerHandle>
110111

111112
// Build upstream router from config
112113
const cfg = getConfig();
114+
115+
// Wire WS connection pool to user config (defaults to enabled). Without
116+
// this call `getWsPool()` would always use DEFAULT_WS_POOL_CONFIG and
117+
// ignore `ws_pool.enabled: false` overrides — breaking the rollback path.
118+
setWsPoolConfig({
119+
enabled: cfg.ws_pool.enabled,
120+
maxAgeMs: cfg.ws_pool.max_age_ms,
121+
maxPerAccount: cfg.ws_pool.max_per_account,
122+
});
113123
const adapters = new Map<string, UpstreamAdapter>();
114124
if (cfg.providers.openai) {
115125
adapters.set(
@@ -299,8 +309,11 @@ async function main() {
299309
}, 10_000);
300310
if (forceExit.unref) forceExit.unref();
301311

302-
handle.close().then(() => {
312+
handle.close().then(async () => {
303313
getTransport().destroy?.();
314+
try {
315+
await getWsPool().shutdown();
316+
} catch { /* never throws today, but defend against future regressions */ }
304317
console.log("[Shutdown] Server closed, cleanup complete.");
305318
clearTimeout(forceExit);
306319
process.exit(0);

src/proxy/codex-api.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ import {
1515
buildHeaders,
1616
buildHeadersWithContentType,
1717
} from "../fingerprint/manager.js";
18-
import { createWebSocketResponse, type WsCreateRequest } from "./ws-transport.js";
18+
import { createWebSocketResponse, type WsCreateRequest, type WsPoolContext } from "./ws-transport.js";
1919
import type { ParsedRateLimit } from "./rate-limit-headers.js";
2020
import { getInstallationId } from "./installation-id.js";
21+
22+
export type { WsPoolContext };
2123
import { parseSSEBlock, parseSSEStream } from "./codex-sse.js";
2224
import { fetchUsage } from "./codex-usage.js";
2325
import { fetchModels, probeEndpoint as probeEndpointFn } from "./codex-models.js";
@@ -175,10 +177,11 @@ export class CodexApi {
175177
request: CodexResponsesRequest,
176178
signal?: AbortSignal,
177179
onRateLimits?: (rl: ParsedRateLimit) => void,
180+
poolCtx?: WsPoolContext,
178181
): Promise<Response> {
179182
if (request.useWebSocket) {
180183
try {
181-
return await this.createResponseViaWebSocket(request, signal, onRateLimits);
184+
return await this.createResponseViaWebSocket(request, signal, onRateLimits, poolCtx);
182185
} catch (err) {
183186
// Real upstream API errors classified by ws-transport (e.g.
184187
// usage_limit_reached → CodexApiError(429)) must reach the
@@ -211,6 +214,7 @@ export class CodexApi {
211214
request: CodexResponsesRequest,
212215
signal?: AbortSignal,
213216
onRateLimits?: (rl: ParsedRateLimit) => void,
217+
poolCtx?: WsPoolContext,
214218
): Promise<Response> {
215219
const baseUrl = this.resolveBaseUrl();
216220
const wsUrl = baseUrl.replace(/^https?:/, "wss:") + "/codex/responses";
@@ -246,7 +250,7 @@ export class CodexApi {
246250
"x-codex-installation-id": installationId,
247251
};
248252

249-
return createWebSocketResponse(wsUrl, headers, wsRequest, signal, this.proxyUrl, onRateLimits);
253+
return createWebSocketResponse(wsUrl, headers, wsRequest, signal, this.proxyUrl, onRateLimits, poolCtx);
250254
}
251255

252256
/**

0 commit comments

Comments
 (0)