coding-tool-x 3.9.5-261009 → 3.9.5-261013

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.
@@ -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
  };