coding-tool-x 3.9.5-261009 → 3.9.5-261012

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -23,6 +23,8 @@
23
23
  - **依赖安全升级** - adm-zip 升至 0.6.0(GHSA-xcpc-8h2w-3j85)、js-yaml 升至 4.3.1(GHSA-h67p-54hq-rp68 / GHSA-52cp-r559-cp3m / GHSA-5p4m-2wfm-xmqj)、pm2 升至 7.0.3(GHSA-x5gf-qvw8-r2rm),overrides 同步更新 basic-ftp / qs / systeminformation / js-yaml;`npm audit` 报告归零
24
24
 
25
25
  ### Fixed
26
+ - **代理连接与内存回收** - 修复各 CLI 请求残留、客户端断开后的上游取消和渠道释放;后台退出时排空代理请求,延长上游空闲超时;OMP 重试取消失败响应,过滤连接保活头,并增加内存压力回收。
27
+ - **CLI 状态和错误子命令** - 修复 `ctx status` 的代理 Driver 状态解析;错误子命令返回非零退出码,不再进入控制面。
26
28
  - **Claude / Gemini 原生 wire 兼容** - OpenCode Claude / Gemini 代理、模型探测和测速统一使用 Anthropic Messages 与 Google Generative AI(含 Gemini CLI)原生请求格式、认证头与目标 URL;OMP managed provider 写入 `anthropic-messages` / `google-generative-ai` 标识,避免代理、adapter 与探测各自维护不一致的请求格式
27
29
  - **OMP Codex Responses 路径兼容** - managed gateway 将 OMP 原生 `openai-codex-responses` 的 `/codex/responses` 请求映射到 OpenAI-compatible 渠道的 `/v1/responses`,避免 Codex-only 服务返回 HTTP 404
28
30
  - **OMP 渠道失败不再冻结** - OMP 动态切换开启后,失败渠道不再进入冻结状态。OMP 网关按请求即时回退其他渠道,冻结会导致短暂故障的渠道在恢复前被排除在后续分配之外
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-tool-x",
3
- "version": "3.9.5-261009",
3
+ "version": "3.9.5-261012",
4
4
  "description": "Vibe Coding 增强工作助手 - 智能会话管理、动态渠道切换、全局搜索、实时监控",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -299,7 +299,13 @@ async function getProxyStatusEntries({ registry = getPlatformRegistry(), runtime
299
299
  entries.push({ platform, state: { status: 'unsupported', ...context }, port: getPlatformPort(platform, config) });
300
300
  continue;
301
301
  }
302
- const state = await Promise.resolve(driver.status());
302
+ const result = await Promise.resolve(driver.status());
303
+ // Proxy drivers use the shared Driver contract and wrap successful
304
+ // service status payloads in { status: 'ok', data }. Keep compatibility
305
+ // with legacy drivers that return the service payload directly.
306
+ const state = result && result.status === 'ok'
307
+ ? (result.data && typeof result.data === 'object' ? result.data : {})
308
+ : result;
303
309
  if (state && typeof state.status === 'string' && state.status !== 'ok') {
304
310
  entries.push({ platform, state: { ...state, ...context }, port: getPlatformPort(platform, config) });
305
311
  } else {
@@ -508,8 +514,10 @@ function buildStartOptions(port, enableHost, enableHttps) {
508
514
  interpreter: 'node',
509
515
  windowsHide: true,
510
516
  autorestart: true,
511
- kill_timeout: 5000,
517
+ // Let active OMP streams drain before PM2 forcibly terminates the process.
518
+ kill_timeout: 125000,
512
519
  max_memory_restart: '500M',
520
+ node_args: '--expose-gc',
513
521
  env,
514
522
  output: path.join(PATHS.logs, STARTUP_LOG_FILE),
515
523
  error: path.join(PATHS.logs, STARTUP_LOG_FILE),
@@ -761,9 +769,9 @@ async function handleStatus() {
761
769
  console.log(chalk.gray(` ${label}${chalk.gray('[N/A] 不支持')}`));
762
770
  continue;
763
771
  }
764
- if (state.status === 'failed') {
772
+ if (state.status && state.status !== 'ok') {
765
773
  console.log(chalk.red(` ${label}${chalk.red('[ERROR] 查询失败')}`) +
766
- chalk.gray(` (${state.error || 'unknown error'})`));
774
+ chalk.gray(` (${state.error || state.status || 'unknown error'})`));
767
775
  continue;
768
776
  }
769
777
  console.log(chalk.gray(` ${label}`) + (state.running
package/src/index.js CHANGED
@@ -164,6 +164,11 @@ async function dispatchPlatformCommand(platform, args = [], dependencies = {}) {
164
164
  const channel = platform.key;
165
165
  const options = dependencies.registry ? { registry: dependencies.registry } : {};
166
166
 
167
+ if (args.length > 1) {
168
+ reportInvalidCommand('参数', args[1], `ctx ${platform.command || platform.key} ${action}`);
169
+ return;
170
+ }
171
+
167
172
  switch (action) {
168
173
  case 'start':
169
174
  await proxyStart(channel, options);
@@ -178,11 +183,23 @@ async function dispatchPlatformCommand(platform, args = [], dependencies = {}) {
178
183
  await proxyStatus(channel, options);
179
184
  return;
180
185
  default:
181
- console.log(chalk.red(`\n[ERROR] 未知操作: ${action}\n`));
182
- console.log(chalk.gray('支持的操作: start, stop, restart, status\n'));
186
+ reportInvalidCommand('操作', action, '支持的操作: start, stop, restart, status');
183
187
  }
184
188
  }
185
189
 
190
+ function reportInvalidCommand(kind, value, usage = '') {
191
+ console.error(chalk.red(`\n[ERROR] 未知${kind}: ${value}\n`));
192
+ if (usage) console.error(chalk.gray(`${usage}\n`));
193
+ process.exitCode = 1;
194
+ }
195
+
196
+ function validateCommandOptions(command, args, allowedOptions) {
197
+ const invalidOption = args.find(argument => !allowedOptions.has(argument));
198
+ if (!invalidOption) return true;
199
+ reportInvalidCommand('参数', invalidOption, `用法: ${command}`);
200
+ return false;
201
+ }
202
+
186
203
  let processShutdownPromise = null;
187
204
 
188
205
  async function stopOwnedOmpGatewayBeforeExit() {
@@ -193,7 +210,7 @@ async function stopOwnedOmpGatewayBeforeExit() {
193
210
  } = require('./platforms/drivers/omp/proxy-implementation');
194
211
  if (getOmpProxyStatus().running) {
195
212
  const preserveManagedMode = process.argv.includes('--daemon');
196
- await stopOmpProxyServer({ forceAfterMs: 1500, preserveManagedMode });
213
+ await stopOmpProxyServer({ forceAfterMs: 120000, preserveManagedMode });
197
214
  }
198
215
  } catch (error) {
199
216
  console.error(chalk.yellow(`[WARN] OMP 动态网关退出清理失败: ${error.message}`));
@@ -203,12 +220,21 @@ async function stopOwnedOmpGatewayBeforeExit() {
203
220
  function shutdownProcess(code = 0, error = null) {
204
221
  if (processShutdownPromise) return processShutdownPromise;
205
222
  processShutdownPromise = (async () => {
223
+ try {
224
+ require('./server/services/memory-reclaimer').stopMemoryReclaimer();
225
+ } catch (memoryError) {
226
+ console.error(chalk.yellow(`[WARN] 内存回收器退出清理失败: ${memoryError.message}`));
227
+ }
206
228
  try {
207
229
  require('./server/services/native-log-observer').shutdownNativeCliLogObserver();
208
230
  } catch (observerError) {
209
231
  console.error(chalk.yellow(`[WARN] CLI 原生会话日志退出清理失败: ${observerError.message}`));
210
232
  }
211
- await stopOwnedOmpGatewayBeforeExit();
233
+ await Promise.all([
234
+ stopOwnedOmpGatewayBeforeExit(),
235
+ require('./server/services/server-shutdown').drainProxyServers({ forceAfterMs: 120000 })
236
+ .catch(drainError => console.error(`[WARN] Proxy drain failed: ${drainError.message}`))
237
+ ]);
212
238
  eventBus.emitSync('cli:shutdown', {});
213
239
  PluginManager.shutdownPlugins();
214
240
  try {
@@ -282,6 +308,11 @@ async function main({ menuDependencies } = {}) {
282
308
  if (args[0] === 'daemon') {
283
309
  const subCommand = args[1] || 'status';
284
310
 
311
+ if (['start', 'stop', 'restart', 'status'].includes(subCommand) && args.length > 2) {
312
+ reportInvalidCommand('参数', args[2], `ctx daemon ${subCommand}`);
313
+ return;
314
+ }
315
+
285
316
  if (subCommand === 'start') {
286
317
  await handleStart();
287
318
  return finishCli(0);
@@ -314,25 +345,37 @@ async function main({ menuDependencies } = {}) {
314
345
  return;
315
346
  }
316
347
 
317
- console.log(chalk.red(`\n[ERROR] 未知 daemon 子命令: ${subCommand}\n`));
318
- console.log(chalk.gray('支持的命令: start, stop, restart, status, logs\n'));
348
+ reportInvalidCommand('daemon 子命令', subCommand, '支持的命令: start, stop, restart, status, logs');
319
349
  return;
320
350
  }
321
351
 
322
352
  // reset 命令 - 恢复默认配置
323
353
  if (args[0] === 'reset') {
354
+ if (args.length > 1) {
355
+ reportInvalidCommand('参数', args[1], '用法: ctx reset');
356
+ return;
357
+ }
324
358
  await resetConfig();
325
359
  return;
326
360
  }
327
361
 
328
362
  // security 命令 - 安全设置
329
363
  if (args[0] === 'security') {
330
- const { showSecurityHelp, handleSecurityReset } = require('./commands/security');
331
364
  const subCommand = args[1] || 'help';
332
365
 
366
+ if (!['reset', 'disable', 'off', 'help'].includes(subCommand)) {
367
+ reportInvalidCommand('security 子命令', subCommand, '支持的命令: reset, disable, off, help');
368
+ return;
369
+ }
370
+ const { showSecurityHelp, handleSecurityReset } = require('./commands/security');
371
+
333
372
  if (subCommand === 'reset' || subCommand === 'disable' || subCommand === 'off') {
373
+ if (args.length > 2) {
374
+ reportInvalidCommand('参数', args[2], `ctx security ${subCommand}`);
375
+ return;
376
+ }
334
377
  await handleSecurityReset();
335
- } else {
378
+ } else if (subCommand === 'help') {
336
379
  showSecurityHelp();
337
380
  }
338
381
  return;
@@ -340,31 +383,52 @@ async function main({ menuDependencies } = {}) {
340
383
 
341
384
  // start 命令 - 启动服务(后台)
342
385
  if (args[0] === 'start') {
386
+ if (!validateCommandOptions('ctx start [--host|--hosts] [--https]', args.slice(1), new Set(['--host', '--hosts', '--https']))) return;
343
387
  await handleStart();
344
388
  return finishCli(0);
345
389
  }
346
390
 
347
391
  // stop 命令 - 停止服务
348
392
  if (args[0] === 'stop') {
393
+ if (args.length > 1) {
394
+ reportInvalidCommand('参数', args[1], '用法: ctx stop');
395
+ return;
396
+ }
349
397
  await handleStop();
350
398
  return finishCli(0);
351
399
  }
352
400
 
353
401
  // restart 命令 - 重启服务
354
402
  if (args[0] === 'restart') {
403
+ if (!validateCommandOptions('ctx restart [--host|--hosts] [--https]', args.slice(1), new Set(['--host', '--hosts', '--https']))) return;
355
404
  await handleRestart();
356
405
  return finishCli(0);
357
406
  }
358
407
 
359
408
  // status 命令 - 查看服务状态
360
409
  if (args[0] === 'status') {
410
+ if (args.length > 1) {
411
+ reportInvalidCommand('参数', args[1], '用法: ctx status');
412
+ return;
413
+ }
361
414
  await handleStatus();
362
415
  return finishCli(0);
363
416
  }
364
417
 
365
418
  // ui 命令 - Web UI 管理
366
419
  if (args[0] === 'ui') {
367
- const subCommand = args[1];
420
+ const commandArgs = args.slice(1);
421
+ const subCommand = commandArgs[0] && !commandArgs[0].startsWith('-') ? commandArgs[0] : null;
422
+ const optionArgs = subCommand ? commandArgs.slice(1) : commandArgs;
423
+ const allowedOptions = new Set(['--https', '--host', '--hosts', '--daemon']);
424
+
425
+ if (subCommand && !['start', 'stop', 'restart'].includes(subCommand)) {
426
+ reportInvalidCommand('ui 子命令', subCommand, '支持的命令: start, stop, restart');
427
+ return;
428
+ }
429
+ if (!validateCommandOptions(`ctx ui${subCommand ? ` ${subCommand}` : ''}`, optionArgs,
430
+ subCommand === 'stop' ? new Set() : allowedOptions)) return;
431
+
368
432
  if (subCommand === 'start') {
369
433
  await handleStart(); // UI start 实际上就是启动整个服务
370
434
  return finishCli(0);
@@ -440,12 +504,17 @@ async function main({ menuDependencies } = {}) {
440
504
 
441
505
  // doctor 命令 - 系统诊断
442
506
  if (args[0] === 'doctor') {
507
+ if (args.length > 1) {
508
+ reportInvalidCommand('参数', args[1], '用法: ctx doctor');
509
+ return;
510
+ }
443
511
  await handleDoctor();
444
512
  return;
445
513
  }
446
514
 
447
515
  // update 命令 - 检查并更新到最新版本
448
516
  if (args[0] === 'update') {
517
+ if (!validateCommandOptions('ctx update [--check]', args.slice(1), new Set(['--check']))) return;
449
518
  const checkOnly = args.includes('--check');
450
519
  await handleUpdate({ checkOnly });
451
520
  return;
@@ -453,6 +522,10 @@ async function main({ menuDependencies } = {}) {
453
522
 
454
523
  // port 命令 - 配置端口
455
524
  if (args[0] === 'port') {
525
+ if (args.length > 1) {
526
+ reportInvalidCommand('参数', args[1], '用法: ctx port');
527
+ return;
528
+ }
456
529
  const { handlePortConfig } = require('./commands/port-config');
457
530
  await handlePortConfig();
458
531
  return;
@@ -460,9 +533,18 @@ async function main({ menuDependencies } = {}) {
460
533
 
461
534
  // 代理命令
462
535
  if (args[0] === 'proxy') {
463
- const { handleProxyStart, handleProxyStop, handleProxyStatus } = require('./commands/proxy');
464
536
  const subCommand = args[1] || 'start';
465
537
 
538
+ if (!['start', 'stop', 'status'].includes(subCommand)) {
539
+ reportInvalidCommand('proxy 子命令', subCommand, '支持的命令: start, stop, status');
540
+ return;
541
+ }
542
+ if (args.length > 2) {
543
+ reportInvalidCommand('参数', args[2], `ctx proxy ${subCommand}`);
544
+ return;
545
+ }
546
+ const { handleProxyStart, handleProxyStop, handleProxyStatus } = require('./commands/proxy');
547
+
466
548
  switch (subCommand) {
467
549
  case 'start':
468
550
  await handleProxyStart();
@@ -476,10 +558,6 @@ async function main({ menuDependencies } = {}) {
476
558
  handleProxyStatus();
477
559
  return;
478
560
 
479
- default:
480
- // 默认执行 start
481
- await handleProxyStart();
482
- return;
483
561
  }
484
562
  }
485
563
 
@@ -508,6 +586,12 @@ async function main({ menuDependencies } = {}) {
508
586
  return;
509
587
  }
510
588
 
589
+ if (args.length > 0) {
590
+ reportInvalidCommand('命令', args[0], '运行 ctx --help 查看可用命令');
591
+ PluginManager.shutdownPlugins();
592
+ return;
593
+ }
594
+
511
595
  while (true) {
512
596
  // 显示主菜单
513
597
  const { showMainMenu } = require('./ui/menu');
@@ -7,7 +7,7 @@ const { createDecodedStream } = require('../../../server/services/response-decod
7
7
  const { parseNonStreamingUsage } = require('../../../shared/response-usage-parser');
8
8
  const { convertClaudeToOpenCodePayload } = require('../opencode/gateway-converter');
9
9
 
10
- const REQUEST_TIMEOUT_MS = 120000;
10
+ const { PROXY_IDLE_TIMEOUT_MS: REQUEST_TIMEOUT_MS } = require('../../../server/services/server-shutdown');
11
11
 
12
12
  function getRequestPathname(urlPath = '') {
13
13
  try {
@@ -19,14 +19,15 @@ const {
19
19
  const { persistProxyRequestSnapshot, persistClaudeRequestTemplate } = require('../../../server/services/request-logger');
20
20
  const { redirectModel, normalizeGatewaySourceType } = require('../../../shared/proxy-utils');
21
21
  const { handleClaudeOpenAiGatewayRequest } = require('./openai-gateway');
22
- const { attachServerShutdownHandling, expediteServerShutdown } = require('../../../server/services/server-shutdown');
22
+ const { bindProxyRequest, prepareProxyResponse } = require('../../../shared/proxy-transport');
23
+ const { attachServerShutdownHandling, expediteServerShutdown, PROXY_IDLE_TIMEOUT_MS } = require('../../../server/services/server-shutdown');
23
24
 
24
25
  let proxyServer = null;
25
26
  let proxyApp = null;
26
27
  let currentPort = null;
27
28
 
28
29
  // 用于存储每个请求的元数据(用于 WebSocket 日志)
29
- const requestMetadata = new Map();
30
+ let requestMetadata = new WeakMap();
30
31
 
31
32
  // 用于缓存已打印过的模型重定向规则,避免重复打印
32
33
  // 格式: { channelId: { "originalModel": "redirectedModel", ... } }
@@ -35,7 +36,7 @@ const printedRedirectCache = new Map();
35
36
  const jsonBodyParser = express.json({
36
37
  limit: '100mb',
37
38
  verify: (req, res, buf) => {
38
- req.rawBody = Buffer.from(buf);
39
+ req.rawBody = buf;
39
40
  }
40
41
  });
41
42
 
@@ -174,6 +175,7 @@ async function startProxyServer(options = {}) {
174
175
  const proxy = httpProxy.createProxyServer({});
175
176
 
176
177
  proxy.on('proxyReq', (proxyReq, req, res) => {
178
+ bindProxyRequest(proxyReq, res);
177
179
  const selectedChannel = req.selectedChannel;
178
180
  if (selectedChannel) {
179
181
  const requestId = `${Date.now()}-${Math.random()}`;
@@ -238,9 +240,10 @@ async function startProxyServer(options = {}) {
238
240
  };
239
241
 
240
242
  req.__releaseChannel = release;
241
-
243
+ res.on('finish', release);
242
244
  res.on('close', release);
243
245
  res.on('error', release);
246
+ if (res.destroyed || res.writableEnded) { release(); return; }
244
247
 
245
248
  const effectiveKey = getEffectiveApiKey(channel);
246
249
  if (!effectiveKey) {
@@ -306,8 +309,8 @@ async function startProxyServer(options = {}) {
306
309
  const proxyOptions = {
307
310
  target: channel.baseUrl,
308
311
  changeOrigin: true,
309
- proxyTimeout: 120000, // 代理连接超时 2 分钟
310
- timeout: 120000 // 请求超时 2 分钟
312
+ proxyTimeout: PROXY_IDLE_TIMEOUT_MS, // Allow long reasoning gaps without an unbounded upstream wait
313
+ timeout: 0 // The upstream timeout bounds idle waits; streaming clients may pause
311
314
  };
312
315
 
313
316
  if (channel.proxyUrl) {
@@ -329,6 +332,7 @@ async function startProxyServer(options = {}) {
329
332
  }
330
333
  });
331
334
  } catch (error) {
335
+ req.__releaseChannel?.();
332
336
  console.error('Channel allocation error:', error);
333
337
  if (!res.headersSent) {
334
338
  res.status(503).json({
@@ -341,6 +345,7 @@ async function startProxyServer(options = {}) {
341
345
 
342
346
  // 代理响应只负责维护传输健康状态;实时日志和 usage 来自 CLI 原生日志。
343
347
  proxy.on('proxyRes', (proxyRes, req, res) => {
348
+ prepareProxyResponse(proxyRes, res);
344
349
  const metadata = requestMetadata.get(req);
345
350
  if (!metadata) return;
346
351
 
@@ -425,9 +430,9 @@ async function stopProxyServer(options = {}) {
425
430
  return { success: true, message: 'Proxy server not running' };
426
431
  }
427
432
 
428
- requestMetadata.clear();
433
+ requestMetadata = new WeakMap();
429
434
 
430
- const shutdownTimer = expediteServerShutdown(proxyServer);
435
+ const shutdownTimer = expediteServerShutdown(proxyServer, { forceAfterMs: options.forceAfterMs });
431
436
 
432
437
  return new Promise((resolve) => {
433
438
  proxyServer.close(() => {
@@ -15,14 +15,15 @@ const {
15
15
  isChatCompletionsPath,
16
16
  ensureOpenAiStreamUsage
17
17
  } = require('../../../shared/proxy-utils');
18
- const { attachServerShutdownHandling, expediteServerShutdown } = require('../../../server/services/server-shutdown');
18
+ const { bindProxyRequest, prepareProxyResponse } = require('../../../shared/proxy-transport');
19
+ const { attachServerShutdownHandling, expediteServerShutdown, PROXY_IDLE_TIMEOUT_MS } = require('../../../server/services/server-shutdown');
19
20
 
20
21
  let proxyServer = null;
21
22
  let proxyApp = null;
22
23
  let currentPort = null;
23
24
 
24
25
  // 用于存储每个请求的元数据
25
- const requestMetadata = new Map();
26
+ let requestMetadata = new WeakMap();
26
27
 
27
28
  // 用于缓存已打印过的模型重定向规则,避免重复打印
28
29
  // 格式: { channelId: { "originalModel": "redirectedModel", ... } }
@@ -37,7 +38,7 @@ const resolveCodexTarget = resolveTargetUrl;
37
38
  const jsonBodyParser = express.json({
38
39
  limit: '100mb',
39
40
  verify: (req, res, buf) => {
40
- req.rawBody = Buffer.from(buf);
41
+ req.rawBody = buf;
41
42
  }
42
43
  });
43
44
 
@@ -77,7 +78,8 @@ async function startCodexProxyServer(options = {}) {
77
78
 
78
79
  const proxy = httpProxy.createProxyServer({});
79
80
 
80
- proxy.on('proxyReq', (proxyReq, req) => {
81
+ proxy.on('proxyReq', (proxyReq, req, res) => {
82
+ bindProxyRequest(proxyReq, res);
81
83
  const activeChannel = req.selectedChannel;
82
84
  if (!activeChannel) return;
83
85
 
@@ -127,8 +129,11 @@ async function startCodexProxyServer(options = {}) {
127
129
  };
128
130
  })();
129
131
 
132
+ req.__releaseChannel = release;
133
+ res.on('finish', release);
130
134
  res.on('close', release);
131
135
  res.on('error', release);
136
+ if (res.destroyed || res.writableEnded) { release(); return; }
132
137
 
133
138
  broadcastSchedulerState('codex', getSchedulerState('codex'));
134
139
 
@@ -200,8 +205,8 @@ async function startCodexProxyServer(options = {}) {
200
205
  proxy.web(req, res, {
201
206
  target,
202
207
  changeOrigin: true,
203
- proxyTimeout: 120000, // 代理连接超时 2 分钟
204
- timeout: 120000 // 请求超时 2 分钟
208
+ proxyTimeout: PROXY_IDLE_TIMEOUT_MS, // Allow long reasoning gaps without an unbounded upstream wait
209
+ timeout: 0 // The upstream timeout bounds idle waits; streaming clients may pause
205
210
  }, (err) => {
206
211
  release();
207
212
  if (err) {
@@ -218,6 +223,7 @@ async function startCodexProxyServer(options = {}) {
218
223
  }
219
224
  });
220
225
  } catch (error) {
226
+ req.__releaseChannel?.();
221
227
  console.error('Codex channel allocation error:', error);
222
228
  if (!res.headersSent) {
223
229
  res.status(503).json({
@@ -232,6 +238,7 @@ async function startCodexProxyServer(options = {}) {
232
238
 
233
239
  // 代理响应只负责维护传输健康状态;实时日志和 usage 来自 CLI 原生日志。
234
240
  proxy.on('proxyRes', (proxyRes, req, res) => {
241
+ prepareProxyResponse(proxyRes, res);
235
242
  const metadata = requestMetadata.get(req);
236
243
  if (!metadata) return;
237
244
 
@@ -277,8 +284,7 @@ async function startCodexProxyServer(options = {}) {
277
284
  console.error('Codex proxy error:', err);
278
285
  if (req && req.selectedChannel) {
279
286
  recordFailure(req.selectedChannel.id, 'codex', err);
280
- releaseChannel(req.selectedChannel.id, 'codex');
281
- broadcastSchedulerState('codex', getSchedulerState('codex'));
287
+ req.__releaseChannel?.();
282
288
  }
283
289
  if (res && !res.headersSent) {
284
290
  res.status(502).json({
@@ -331,9 +337,9 @@ async function stopCodexProxyServer(options = {}) {
331
337
  return { success: true, message: 'Codex proxy server not running' };
332
338
  }
333
339
 
334
- requestMetadata.clear();
340
+ requestMetadata = new WeakMap();
335
341
 
336
- const shutdownTimer = expediteServerShutdown(proxyServer);
342
+ const shutdownTimer = expediteServerShutdown(proxyServer, { forceAfterMs: options.forceAfterMs });
337
343
 
338
344
  return new Promise((resolve) => {
339
345
  proxyServer.close(() => {
@@ -20,16 +20,17 @@ const {
20
20
  const { persistProxyRequestSnapshot } = require('../../../server/services/request-logger');
21
21
  const { redirectModel, resolveTargetUrl, isChatCompletionsPath, ensureOpenAiStreamUsage } = require('../../../shared/proxy-utils');
22
22
  const { saveProxyStartTime, clearProxyStartTime, getProxyStartTime, getProxyRuntime } = require('../../../server/services/proxy-runtime');
23
- const { attachServerShutdownHandling, expediteServerShutdown } = require('../../../server/services/server-shutdown');
23
+ const { bindProxyRequest, prepareProxyResponse } = require('../../../shared/proxy-transport');
24
+ const { attachServerShutdownHandling, expediteServerShutdown, PROXY_IDLE_TIMEOUT_MS } = require('../../../server/services/server-shutdown');
24
25
 
25
26
  let proxyServer = null;
26
27
  let currentPort = null;
27
- const requestMetadata = new Map();
28
+ let requestMetadata = new WeakMap();
28
29
 
29
30
  const jsonBodyParser = express.json({
30
31
  limit: '100mb',
31
32
  verify: (req, _res, buffer) => {
32
- req.rawBody = Buffer.from(buffer);
33
+ req.rawBody = buffer;
33
34
  }
34
35
  });
35
36
 
@@ -84,8 +85,11 @@ async function startDshProxyServer(options = {}) {
84
85
  });
85
86
  req.selectedChannel = channel;
86
87
  release = releaseOnce(channel);
88
+ req.__releaseChannel = release;
89
+ res.on('finish', release);
87
90
  res.on('close', release);
88
91
  res.on('error', release);
92
+ if (res.destroyed || res.writableEnded) { release(); return; }
89
93
  broadcastSchedulerState('dsh', getSchedulerState('dsh'));
90
94
 
91
95
  const apiKey = getEffectiveApiKey(channel);
@@ -119,8 +123,8 @@ async function startDshProxyServer(options = {}) {
119
123
  proxy.web(req, res, {
120
124
  target: resolveTargetUrl(channel.baseUrl, req.url),
121
125
  changeOrigin: true,
122
- proxyTimeout: 120000,
123
- timeout: 120000
126
+ proxyTimeout: PROXY_IDLE_TIMEOUT_MS,
127
+ timeout: 0
124
128
  }, error => {
125
129
  release?.();
126
130
  if (!error) return;
@@ -137,7 +141,8 @@ async function startDshProxyServer(options = {}) {
137
141
  }
138
142
  });
139
143
 
140
- proxy.on('proxyReq', (proxyReq, req) => {
144
+ proxy.on('proxyReq', (proxyReq, req, res) => {
145
+ bindProxyRequest(proxyReq, res);
141
146
  const channel = req.selectedChannel;
142
147
  if (!channel) return;
143
148
  const metadata = {
@@ -161,22 +166,32 @@ async function startDshProxyServer(options = {}) {
161
166
  });
162
167
 
163
168
  proxy.on('proxyRes', (proxyRes, req, res) => {
169
+ prepareProxyResponse(proxyRes, res);
164
170
  const metadata = requestMetadata.get(req);
165
171
  if (!metadata) return;
166
- if (Number(proxyRes.statusCode) >= 400) {
167
- recordFailure(metadata.channelId, 'dsh', new Error(`DSH upstream HTTP ${proxyRes.statusCode}`));
168
- } else {
169
- recordSuccess(metadata.channelId, 'dsh');
170
- }
171
- requestMetadata.delete(req);
172
- res?.on('close', () => requestMetadata.delete(req));
172
+ let completed = false;
173
+ const finish = error => {
174
+ if (completed) return;
175
+ completed = true;
176
+ requestMetadata.delete(req);
177
+ if (error || Number(proxyRes.statusCode) >= 400) {
178
+ recordFailure(metadata.channelId, 'dsh', error || new Error(`DSH upstream HTTP ${proxyRes.statusCode}`));
179
+ } else {
180
+ recordSuccess(metadata.channelId, 'dsh');
181
+ }
182
+ };
183
+ proxyRes.once('end', () => finish());
184
+ proxyRes.once('error', finish);
185
+ proxyRes.once('close', () => {
186
+ if (!completed) finish(new Error('DSH upstream response closed early'));
187
+ });
188
+ res?.once('close', () => requestMetadata.delete(req));
173
189
  });
174
190
 
175
191
  proxy.on('error', (error, req, res) => {
176
192
  if (req?.selectedChannel) {
177
193
  recordFailure(req.selectedChannel.id, 'dsh', error);
178
- releaseChannel(req.selectedChannel.id, 'dsh');
179
- broadcastSchedulerState('dsh', getSchedulerState('dsh'));
194
+ req.__releaseChannel?.();
180
195
  }
181
196
  if (res && !res.headersSent) res.status(502).json({ error: { message: `Proxy error: ${error.message}`, type: 'proxy_error' } });
182
197
  });
@@ -213,10 +228,10 @@ async function startDshProxyServer(options = {}) {
213
228
  async function stopDshProxyServer(options = {}) {
214
229
  const clearStartTime = options.clearStartTime !== false;
215
230
  const stoppedPort = currentPort;
216
- requestMetadata.clear();
231
+ requestMetadata = new WeakMap();
217
232
 
218
233
  if (proxyServer) {
219
- const shutdownTimer = expediteServerShutdown(proxyServer);
234
+ const shutdownTimer = expediteServerShutdown(proxyServer, { forceAfterMs: options.forceAfterMs });
220
235
  await new Promise(resolve => proxyServer.close(resolve));
221
236
  if (shutdownTimer) clearTimeout(shutdownTimer);
222
237
  proxyServer = null;
@@ -13,14 +13,15 @@ const {
13
13
  } = require('./channels-implementation');
14
14
  const { persistProxyRequestSnapshot } = require('../../../server/services/request-logger');
15
15
  const { redirectModel: redirectModelBase, resolveTargetUrl } = require('../../../shared/proxy-utils');
16
- const { attachServerShutdownHandling, expediteServerShutdown } = require('../../../server/services/server-shutdown');
16
+ const { bindProxyRequest, prepareProxyResponse } = require('../../../shared/proxy-transport');
17
+ const { attachServerShutdownHandling, expediteServerShutdown, PROXY_IDLE_TIMEOUT_MS } = require('../../../server/services/server-shutdown');
17
18
 
18
19
  let proxyServer = null;
19
20
  let proxyApp = null;
20
21
  let currentPort = null;
21
22
 
22
23
  // 用于存储每个请求的元数据
23
- const requestMetadata = new Map();
24
+ let requestMetadata = new WeakMap();
24
25
 
25
26
  // 用于缓存已打印过的模型重定向规则,避免重复打印
26
27
  // 格式: { channelId: { "originalModel": "redirectedModel", ... } }
@@ -30,7 +31,7 @@ const printedGeminiRedirectCache = new Map();
30
31
  const jsonBodyParser = express.json({
31
32
  limit: '100mb',
32
33
  verify: (req, res, buf) => {
33
- req.rawBody = Buffer.from(buf);
34
+ req.rawBody = buf;
34
35
  }
35
36
  });
36
37
 
@@ -198,7 +199,8 @@ async function startGeminiProxyServer(options = {}) {
198
199
 
199
200
  const proxy = httpProxy.createProxyServer({});
200
201
 
201
- proxy.on('proxyReq', (proxyReq, req) => {
202
+ proxy.on('proxyReq', (proxyReq, req, res) => {
203
+ bindProxyRequest(proxyReq, res);
202
204
  const activeChannel = req.selectedChannel;
203
205
  if (!activeChannel) return;
204
206
 
@@ -255,8 +257,11 @@ async function startGeminiProxyServer(options = {}) {
255
257
  };
256
258
  })();
257
259
 
260
+ req.__releaseChannel = release;
261
+ res.on('finish', release);
258
262
  res.on('close', release);
259
263
  res.on('error', release);
264
+ if (res.destroyed || res.writableEnded) { release(); return; }
260
265
 
261
266
  broadcastSchedulerState('gemini', getSchedulerState('gemini'));
262
267
 
@@ -335,8 +340,8 @@ async function startGeminiProxyServer(options = {}) {
335
340
  proxy.web(req, res, {
336
341
  target,
337
342
  changeOrigin: true,
338
- proxyTimeout: 120000, // 代理连接超时 2 分钟
339
- timeout: 120000 // 请求超时 2 分钟
343
+ proxyTimeout: PROXY_IDLE_TIMEOUT_MS, // Allow long reasoning gaps without an unbounded upstream wait
344
+ timeout: 0 // The upstream timeout bounds idle waits; streaming clients may pause
340
345
  }, (err) => {
341
346
  release();
342
347
  if (err) {
@@ -353,6 +358,7 @@ async function startGeminiProxyServer(options = {}) {
353
358
  }
354
359
  });
355
360
  } catch (error) {
361
+ req.__releaseChannel?.();
356
362
  console.error('Gemini channel allocation error:', error);
357
363
  if (!res.headersSent) {
358
364
  res.status(503).json({
@@ -367,6 +373,7 @@ async function startGeminiProxyServer(options = {}) {
367
373
 
368
374
  // 代理响应只负责维护传输健康状态;实时日志和 usage 来自 CLI 原生日志。
369
375
  proxy.on('proxyRes', (proxyRes, req, res) => {
376
+ prepareProxyResponse(proxyRes, res);
370
377
  const metadata = requestMetadata.get(req);
371
378
  if (!metadata) return;
372
379
 
@@ -403,8 +410,7 @@ async function startGeminiProxyServer(options = {}) {
403
410
  console.error('Gemini proxy error:', err);
404
411
  if (req && req.selectedChannel) {
405
412
  recordFailure(req.selectedChannel.id, 'gemini', err);
406
- releaseChannel(req.selectedChannel.id, 'gemini');
407
- broadcastSchedulerState('gemini', getSchedulerState('gemini'));
413
+ req.__releaseChannel?.();
408
414
  }
409
415
  if (res && !res.headersSent) {
410
416
  res.status(502).json({
@@ -457,9 +463,9 @@ async function stopGeminiProxyServer(options = {}) {
457
463
  return { success: true, message: 'Gemini proxy server not running' };
458
464
  }
459
465
 
460
- requestMetadata.clear();
466
+ requestMetadata = new WeakMap();
461
467
 
462
- const shutdownTimer = expediteServerShutdown(proxyServer);
468
+ const shutdownTimer = expediteServerShutdown(proxyServer, { forceAfterMs: options.forceAfterMs });
463
469
 
464
470
  return new Promise((resolve) => {
465
471
  proxyServer.close(() => {
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('crypto');
4
+ const { downstreamHeaders } = require('../../../shared/proxy-transport');
4
5
  const http = require('http');
5
6
  const https = require('https');
6
7
  const httpProxy = require('http-proxy');
@@ -118,12 +119,20 @@ function readRequestBody(req) {
118
119
  const chunks = [];
119
120
  let size = 0;
120
121
  let settled = false;
122
+ const cleanup = () => {
123
+ chunks.length = 0;
124
+ req.removeListener('data', onData);
125
+ req.removeListener('end', onEnd);
126
+ req.removeListener('error', fail);
127
+ req.removeListener('aborted', onAborted);
128
+ };
121
129
  const fail = (error) => {
122
130
  if (settled) return;
123
131
  settled = true;
132
+ cleanup();
124
133
  reject(error);
125
134
  };
126
- req.on('data', (chunk) => {
135
+ const onData = (chunk) => {
127
136
  if (settled) return;
128
137
  size += chunk.length;
129
138
  if (size > MAX_REQUEST_BYTES) {
@@ -132,14 +141,19 @@ function readRequestBody(req) {
132
141
  return;
133
142
  }
134
143
  chunks.push(chunk);
135
- });
136
- req.on('end', () => {
144
+ };
145
+ const onEnd = () => {
137
146
  if (settled) return;
138
147
  settled = true;
139
- resolve(Buffer.concat(chunks));
140
- });
148
+ const body = Buffer.concat(chunks, size);
149
+ cleanup();
150
+ resolve(body);
151
+ };
152
+ const onAborted = () => fail(new Error('Client aborted request'));
153
+ req.on('data', onData);
154
+ req.on('end', onEnd);
141
155
  req.on('error', fail);
142
- req.on('aborted', () => fail(new Error('Client aborted request')));
156
+ req.on('aborted', onAborted);
143
157
  });
144
158
  }
145
159
 
@@ -184,6 +198,7 @@ function writeJson(res, statusCode, payload) {
184
198
  res.end(body);
185
199
  }
186
200
 
201
+
187
202
  function redactSensitiveQuery(path) {
188
203
  const url = new URL(String(path || '/'), 'http://127.0.0.1');
189
204
  for (const key of [...url.searchParams.keys()]) {
@@ -330,8 +345,13 @@ function createOmpGateway(options = {}) {
330
345
  upstreamResponse = response;
331
346
  activeUpstreams.delete(upstreamRequest);
332
347
  activeUpstreams.add(response);
348
+ // Install error handling before resolving: discarded retry responses
349
+ // must never raise an unhandled stream error and terminate the daemon.
350
+ response.on('error', () => {});
333
351
  response.once('close', () => {
352
+ activeUpstreams.delete(response);
334
353
  signal?.removeEventListener('abort', abortUpstream);
354
+ releaseOnce();
335
355
  });
336
356
  const statusCode = response.statusCode || 502;
337
357
  resolve({
@@ -387,8 +407,7 @@ function createOmpGateway(options = {}) {
387
407
  try {
388
408
  const result = await forwardAttempt(req, route, channel, body, signal);
389
409
  if (attempt === 0 && RETRYABLE_STATUS.has(result.statusCode)) {
390
- result.response.once('close', () => activeUpstreams.delete(result.response));
391
- result.response.resume();
410
+ result.response.destroy();
392
411
  result.release();
393
412
  onFailure(channel.id, 'omp');
394
413
  lastError = Object.assign(new Error(`OMP upstream returned HTTP ${result.statusCode}`), {
@@ -397,7 +416,7 @@ function createOmpGateway(options = {}) {
397
416
  continue;
398
417
  }
399
418
 
400
- res.writeHead(result.statusCode, result.response.headers);
419
+ res.writeHead(result.statusCode, downstreamHeaders(result.response.headers));
401
420
  await new Promise((resolve) => {
402
421
  let completed = false;
403
422
  const finish = (error) => {
@@ -413,7 +432,12 @@ function createOmpGateway(options = {}) {
413
432
  resolve();
414
433
  };
415
434
  result.response.once('end', () => finish());
416
- result.response.once('close', () => finish(new Error('OMP upstream response closed early')));
435
+ result.response.once('close', () => {
436
+ if (completed) return;
437
+ const error = new Error('OMP upstream response closed early');
438
+ if (!res.destroyed) res.destroy(error);
439
+ finish(error);
440
+ });
417
441
  result.response.once('error', (error) => {
418
442
  if (!res.destroyed) res.destroy(error);
419
443
  finish(error);
@@ -640,7 +664,7 @@ function createOmpGateway(options = {}) {
640
664
  handleRequest(req, res).catch((error) => {
641
665
  writeJson(res, 500, { error: error.message || 'OMP gateway request failed' });
642
666
  });
643
- }));
667
+ }), { drainOnExit: false });
644
668
  server.on('upgrade', (req, socket, head) => {
645
669
  handleUpgrade(req, socket, head).catch((error) => {
646
670
  rejectUpgrade(socket, 503, error.message || 'OMP websocket proxy failed');
@@ -22,7 +22,8 @@ const {
22
22
  resolveTargetUrl,
23
23
  ensureOpenAiStreamUsage
24
24
  } = require('../../../shared/proxy-utils');
25
- const { attachServerShutdownHandling, expediteServerShutdown } = require('../../../server/services/server-shutdown');
25
+ const { bindProxyRequest, prepareProxyResponse } = require('../../../shared/proxy-transport');
26
+ const { attachServerShutdownHandling, expediteServerShutdown, PROXY_IDLE_TIMEOUT_MS } = require('../../../server/services/server-shutdown');
26
27
  const { buildCodexTargetUrl, createCodexRequest } = require('../codex/wire');
27
28
  const { createClaudeRequest, buildClaudeTargetUrl, buildClaudeCountTokensTargetUrl, buildClaudeCountTokensHeaders } = require('../claude/wire');
28
29
  const { createGeminiRequest, buildGeminiTargetUrl, shouldUseGeminiCliFormat } = require('../gemini/wire');
@@ -32,7 +33,7 @@ let proxyApp = null;
32
33
  let currentPort = null;
33
34
 
34
35
  // 用于存储每个请求的元数据
35
- const requestMetadata = new Map();
36
+ let requestMetadata = new WeakMap();
36
37
 
37
38
  // 用于缓存已打印过的模型重定向规则,避免重复打印
38
39
  // 格式: { channelId: { "originalModel": "redirectedModel", ... } }
@@ -52,7 +53,7 @@ const resolveOpenCodeTarget = resolveTargetUrl;
52
53
  const jsonBodyParser = express.json({
53
54
  limit: '100mb',
54
55
  verify: (req, res, buf) => {
55
- req.rawBody = Buffer.from(buf);
56
+ req.rawBody = buf;
56
57
  }
57
58
  });
58
59
 
@@ -350,7 +351,7 @@ function collectHttpResponseBody(res) {
350
351
  });
351
352
  }
352
353
 
353
- function postJson(url, headers, payload, timeoutMs = 120000) {
354
+ function postJson(url, headers, payload, timeoutMs = PROXY_IDLE_TIMEOUT_MS) {
354
355
  return new Promise((resolve, reject) => {
355
356
  const target = new URL(url);
356
357
  const isHttps = target.protocol === 'https:';
@@ -388,7 +389,7 @@ function postJson(url, headers, payload, timeoutMs = 120000) {
388
389
  });
389
390
  }
390
391
 
391
- function postJsonStream(url, headers, payload, timeoutMs = 120000) {
392
+ function postJsonStream(url, headers, payload, timeoutMs = PROXY_IDLE_TIMEOUT_MS) {
392
393
  return new Promise((resolve, reject) => {
393
394
  const target = new URL(url);
394
395
  const isHttps = target.protocol === 'https:';
@@ -3069,7 +3070,8 @@ async function startOpenCodeProxyServer(options = {}) {
3069
3070
 
3070
3071
  const proxy = httpProxy.createProxyServer({});
3071
3072
 
3072
- proxy.on('proxyReq', (proxyReq, req) => {
3073
+ proxy.on('proxyReq', (proxyReq, req, res) => {
3074
+ bindProxyRequest(proxyReq, res);
3073
3075
  const activeChannel = req.selectedChannel;
3074
3076
  if (!activeChannel) return;
3075
3077
 
@@ -3128,11 +3130,30 @@ async function startOpenCodeProxyServer(options = {}) {
3128
3130
  const channel = await allocateChannel({ source: 'opencode', enableSessionBinding: false });
3129
3131
  req.selectedChannel = channel;
3130
3132
 
3133
+ const release = (() => {
3134
+ let released = false;
3135
+ return () => {
3136
+ if (released) return;
3137
+ released = true;
3138
+ releaseChannel(channel.id, 'opencode');
3139
+ broadcastSchedulerState('opencode', getSchedulerState('opencode'));
3140
+ };
3141
+ })();
3142
+
3143
+ req.__releaseChannel = release;
3144
+ res.on('finish', release);
3145
+ res.on('close', release);
3146
+ res.on('error', release);
3147
+
3148
+ broadcastSchedulerState('opencode', getSchedulerState('opencode'));
3149
+
3150
+ if (res.destroyed || res.writableEnded) { release(); return; }
3151
+
3131
3152
  // 检查 API key 是否有效
3132
3153
  const effectiveKey = await getEffectiveApiKey(channel);
3154
+ if (res.destroyed || res.writableEnded) { release(); return; }
3133
3155
  if (!effectiveKey) {
3134
- releaseChannel(channel.id, 'opencode');
3135
- broadcastSchedulerState('opencode', getSchedulerState('opencode'));
3156
+ release();
3136
3157
  return res.status(401).json({
3137
3158
  error: {
3138
3159
  message: 'API key not configured or expired. Please update your channel key.',
@@ -3195,21 +3216,6 @@ async function startOpenCodeProxyServer(options = {}) {
3195
3216
  req.rawBody = Buffer.from(JSON.stringify(req.body));
3196
3217
  }
3197
3218
 
3198
- const release = (() => {
3199
- let released = false;
3200
- return () => {
3201
- if (released) return;
3202
- released = true;
3203
- releaseChannel(channel.id, 'opencode');
3204
- broadcastSchedulerState('opencode', getSchedulerState('opencode'));
3205
- };
3206
- })();
3207
-
3208
- res.on('close', release);
3209
- res.on('error', release);
3210
-
3211
- broadcastSchedulerState('opencode', getSchedulerState('opencode'));
3212
-
3213
3219
  const gatewaySourceType = normalizeGatewaySourceType(channel);
3214
3220
  if (gatewaySourceType === 'codex') {
3215
3221
  const handled = await handleCodexGatewayRequest(req, res, channel, effectiveKey);
@@ -3235,8 +3241,8 @@ async function startOpenCodeProxyServer(options = {}) {
3235
3241
  proxy.web(req, res, {
3236
3242
  target,
3237
3243
  changeOrigin: true,
3238
- proxyTimeout: 120000, // 代理连接超时 2 分钟
3239
- timeout: 120000 // 请求超时 2 分钟
3244
+ proxyTimeout: PROXY_IDLE_TIMEOUT_MS, // Allow long reasoning gaps without an unbounded upstream wait
3245
+ timeout: 0 // The upstream timeout bounds idle waits; streaming clients may pause
3240
3246
  }, (err) => {
3241
3247
  release();
3242
3248
  if (err) {
@@ -3253,6 +3259,7 @@ async function startOpenCodeProxyServer(options = {}) {
3253
3259
  }
3254
3260
  });
3255
3261
  } catch (error) {
3262
+ req.__releaseChannel?.();
3256
3263
  console.error('OpenCode channel allocation error:', error);
3257
3264
  if (!res.headersSent) {
3258
3265
  res.status(503).json({
@@ -3267,6 +3274,7 @@ async function startOpenCodeProxyServer(options = {}) {
3267
3274
 
3268
3275
  // 代理响应只负责维护传输健康状态;实时日志和 usage 来自 CLI 原生日志。
3269
3276
  proxy.on('proxyRes', (proxyRes, req, res) => {
3277
+ prepareProxyResponse(proxyRes, res);
3270
3278
  const metadata = requestMetadata.get(req);
3271
3279
  if (!metadata) return;
3272
3280
 
@@ -3303,8 +3311,7 @@ async function startOpenCodeProxyServer(options = {}) {
3303
3311
  console.error('OpenCode proxy error:', err);
3304
3312
  if (req && req.selectedChannel) {
3305
3313
  recordFailure(req.selectedChannel.id, 'opencode', err);
3306
- releaseChannel(req.selectedChannel.id, 'opencode');
3307
- broadcastSchedulerState('opencode', getSchedulerState('opencode'));
3314
+ req.__releaseChannel?.();
3308
3315
  }
3309
3316
  if (res && !res.headersSent) {
3310
3317
  res.status(502).json({
@@ -3359,9 +3366,9 @@ async function stopOpenCodeProxyServer(options = {}) {
3359
3366
  return { success: true, message: 'OpenCode proxy server not running' };
3360
3367
  }
3361
3368
 
3362
- requestMetadata.clear();
3369
+ requestMetadata = new WeakMap();
3363
3370
 
3364
- const shutdownTimer = expediteServerShutdown(proxyServer);
3371
+ const shutdownTimer = expediteServerShutdown(proxyServer, { forceAfterMs: options.forceAfterMs });
3365
3372
 
3366
3373
  return new Promise((resolve) => {
3367
3374
  proxyServer.close(() => {
@@ -24,6 +24,7 @@ const {
24
24
  createSameOriginGuard
25
25
  } = require('./services/network-access');
26
26
  const { createApiRequestLogger } = require('./services/request-logger');
27
+ const { startMemoryReclaimer, trackMemoryActivity } = require('./services/memory-reclaimer');
27
28
  const { inspectWebBuildState, ensureWebDistReady } = require('./services/web-build');
28
29
  const { ensureHttpsCredentials } = require('./services/https-cert');
29
30
  const { scheduleSessionHistoryMaintenance } = require('./services/session-history-maintenance');
@@ -197,6 +198,7 @@ async function startServer(port, host = '127.0.0.1', options = {}) {
197
198
  const allowRemoteMutation = isRemoteMutationAllowedByEnv(process.env);
198
199
 
199
200
  // Middleware
201
+ app.use(trackMemoryActivity);
200
202
  app.use(express.json({ limit: '100mb' }));
201
203
  app.use(express.urlencoded({ limit: '100mb', extended: true }));
202
204
 
@@ -368,6 +370,7 @@ async function startServer(port, host = '127.0.0.1', options = {}) {
368
370
  traceStartupMemory('proxy-restore-complete');
369
371
  await performStartupHealthCheck({ config });
370
372
  traceStartupMemory('health-check-complete');
373
+ startMemoryReclaimer();
371
374
  return server;
372
375
  } finally {
373
376
  try {
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ const MIB = 1024 * 1024;
4
+ const CHECK_INTERVAL_MS = 5_000;
5
+ const MIN_IDLE_MS = 30_000;
6
+ const GC_COOLDOWN_MS = 5 * 60_000;
7
+ const RSS_PRESSURE_BYTES = 380 * MIB;
8
+ const HEAP_PRESSURE_BYTES = 256 * MIB;
9
+ const URGENT_RSS_BYTES = 450 * MIB;
10
+ const URGENT_GC_COOLDOWN_MS = 30_000;
11
+
12
+ let timer = null;
13
+ let activeRequests = 0;
14
+ let lastActivityAt = Date.now();
15
+ let lastCollectionAt = 0;
16
+
17
+ function trackMemoryActivity(_req, res, next) {
18
+ if (typeof global.gc !== 'function') {
19
+ if (typeof next === 'function') next();
20
+ return;
21
+ }
22
+
23
+ activeRequests += 1;
24
+ lastActivityAt = Date.now();
25
+ let completed = false;
26
+ const complete = () => {
27
+ if (completed) return;
28
+ completed = true;
29
+ activeRequests = Math.max(0, activeRequests - 1);
30
+ lastActivityAt = Date.now();
31
+ };
32
+
33
+ // HTTP completion ends a request; upgraded sockets stay active until close.
34
+ if (typeof res.writeHead === 'function') res.once('finish', complete);
35
+ res.once('close', complete);
36
+ if (typeof next === 'function') next();
37
+ }
38
+
39
+ function collectIfIdleAndUnderPressure() {
40
+ if (activeRequests > 0) return;
41
+
42
+ const before = process.memoryUsage();
43
+ const urgent = before.rss >= URGENT_RSS_BYTES;
44
+ if (!urgent && Date.now() - lastActivityAt < MIN_IDLE_MS) return;
45
+ if (before.rss < RSS_PRESSURE_BYTES && before.heapUsed < HEAP_PRESSURE_BYTES) return;
46
+
47
+ const now = Date.now();
48
+ const cooldown = urgent ? URGENT_GC_COOLDOWN_MS : GC_COOLDOWN_MS;
49
+ if (now - lastCollectionAt < cooldown) return;
50
+ lastCollectionAt = now;
51
+
52
+ try {
53
+ global.gc();
54
+ if (process.env.CC_TOOL_MEMORY_TRACE === '1') {
55
+ const after = process.memoryUsage();
56
+ console.log(`[MEM] idle-gc ${JSON.stringify({
57
+ pid: process.pid,
58
+ urgent,
59
+ rssBefore: before.rss,
60
+ rssAfter: after.rss,
61
+ heapUsedBefore: before.heapUsed,
62
+ heapUsedAfter: after.heapUsed
63
+ })}`);
64
+ }
65
+ } catch (error) {
66
+ console.warn(`[MEM] Idle memory collection failed: ${error.message}`);
67
+ }
68
+ }
69
+
70
+ function startMemoryReclaimer() {
71
+ if (timer || typeof global.gc !== 'function') return false;
72
+ timer = setInterval(collectIfIdleAndUnderPressure, CHECK_INTERVAL_MS);
73
+ if (typeof timer.unref === 'function') timer.unref();
74
+ return true;
75
+ }
76
+
77
+ function stopMemoryReclaimer() {
78
+ if (timer) clearInterval(timer);
79
+ timer = null;
80
+ activeRequests = 0;
81
+ lastActivityAt = Date.now();
82
+ }
83
+
84
+ module.exports = {
85
+ startMemoryReclaimer,
86
+ stopMemoryReclaimer,
87
+ trackMemoryActivity
88
+ };
@@ -1,4 +1,7 @@
1
1
  const SOCKET_TRACKER = Symbol('ccTool.socketTracker');
2
+ const { trackMemoryActivity } = require('./memory-reclaimer');
3
+ const proxyServers = new Set();
4
+ const PROXY_IDLE_TIMEOUT_MS = 10 * 60_000;
2
5
 
3
6
  function attachServerShutdownHandling(server, options = {}) {
4
7
  if (!server || server[SOCKET_TRACKER]) {
@@ -6,6 +9,10 @@ function attachServerShutdownHandling(server, options = {}) {
6
9
  }
7
10
 
8
11
  const sockets = new Set();
12
+ // Proxy servers share the process with the UI; streaming requests must also
13
+ // prevent idle memory collection until their response finishes or closes.
14
+ server.prependListener('request', trackMemoryActivity);
15
+ server.prependListener('upgrade', (req, socket) => trackMemoryActivity(req, socket));
9
16
  server.on('connection', (socket) => {
10
17
  sockets.add(socket);
11
18
  socket.on('close', () => {
@@ -15,17 +22,33 @@ function attachServerShutdownHandling(server, options = {}) {
15
22
 
16
23
  const keepAliveTimeout = Number.isFinite(options.keepAliveTimeout)
17
24
  ? Math.max(0, options.keepAliveTimeout)
18
- : 1000;
25
+ : 5000;
19
26
  const headersTimeout = Number.isFinite(options.headersTimeout)
20
27
  ? Math.max(keepAliveTimeout + 1000, options.headersTimeout)
21
- : keepAliveTimeout + 1000;
28
+ : Math.max(60_000, keepAliveTimeout + 1000);
22
29
 
23
30
  server.keepAliveTimeout = keepAliveTimeout;
24
31
  server.headersTimeout = headersTimeout;
25
32
  server[SOCKET_TRACKER] = { sockets };
33
+ if (options.drainOnExit !== false) {
34
+ if (server.listening) proxyServers.add(server);
35
+ else server.once('listening', () => proxyServers.add(server));
36
+ server.once('close', () => proxyServers.delete(server));
37
+ }
26
38
  return server;
27
39
  }
28
40
 
41
+ async function drainProxyServers(options = {}) {
42
+ await Promise.all([...proxyServers].map(server => new Promise((resolve, reject) => {
43
+ const timer = expediteServerShutdown(server, { forceAfterMs: options.forceAfterMs ?? 120_000 });
44
+ server.close(error => {
45
+ if (timer) clearTimeout(timer);
46
+ if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error);
47
+ else resolve();
48
+ });
49
+ })));
50
+ }
51
+
29
52
  function expediteServerShutdown(server, options = {}) {
30
53
  if (!server) {
31
54
  return null;
@@ -75,5 +98,7 @@ function expediteServerShutdown(server, options = {}) {
75
98
 
76
99
  module.exports = {
77
100
  attachServerShutdownHandling,
78
- expediteServerShutdown
101
+ expediteServerShutdown,
102
+ drainProxyServers,
103
+ PROXY_IDLE_TIMEOUT_MS
79
104
  };
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ // Connection headers describe one transport hop, not the downstream socket.
4
+ function downstreamHeaders(headers = {}) {
5
+ const result = { ...headers };
6
+ const connectionHeaders = String(headers.connection || '').split(',').map(name => name.trim().toLowerCase());
7
+ for (const name of ['connection', 'keep-alive', 'proxy-connection', 'proxy-authenticate',
8
+ 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', ...connectionHeaders]) {
9
+ delete result[name];
10
+ }
11
+ return result;
12
+ }
13
+
14
+ function bindProxyRequest(proxyReq, res) {
15
+ if (!res) return;
16
+ const abort = () => {
17
+ if (!res.writableEnded && typeof proxyReq.destroy === 'function') proxyReq.destroy();
18
+ };
19
+ res.once('close', abort);
20
+ proxyReq.once('close', () => res.removeListener('close', abort));
21
+ if (res.destroyed) abort();
22
+ }
23
+
24
+ function prepareProxyResponse(proxyRes, res) {
25
+ proxyRes.headers = downstreamHeaders(proxyRes.headers);
26
+ const fail = error => {
27
+ if (res && !res.destroyed) res.destroy(error);
28
+ };
29
+ proxyRes.once('error', fail);
30
+ proxyRes.once('aborted', () => fail(new Error('Upstream response aborted')));
31
+ }
32
+
33
+ module.exports = { downstreamHeaders, bindProxyRequest, prepareProxyResponse };