maxpool 1.5.50 → 1.5.51

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.50",
3
+ "version": "1.5.51",
4
4
  "description": "Multi-account Claude Code proxy with adaptive, rate-aware load balancing across Claude accounts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/event-log.js CHANGED
@@ -7,7 +7,11 @@
7
7
 
8
8
  import { appendFile, stat, rename, chmod } from 'node:fs/promises';
9
9
 
10
- const MAX_BYTES = 5 * 1024 * 1024; // rotate at ~5 MB (one .1 backup kept)
10
+ const MAX_BYTES = 5 * 1024 * 1024; // rotate at ~5 MB
11
+ // Keep 5 generations (~30 MB total), not 1. With a single backup the log rotated away the
12
+ // evidence MID-INVESTIGATION on 2026-07-28 — the reported error's own line was already gone
13
+ // before it could be read. Diagnosing a stall needs hours of history, not minutes.
14
+ const MAX_GENERATIONS = Math.max(1, Number(process.env.MAXPOOL_LOG_GENERATIONS) || 5);
11
15
  const ROTATE_CHECK_MS = 2000; // rotation-owner size-check cadence
12
16
  // Cap each line below the platform PIPE_BUF (512 B on macOS) so concurrent
13
17
  // O_APPEND writes from coexisting processes during a reload stay atomic (never
@@ -91,7 +95,13 @@ export async function rotateIfNeeded() {
91
95
  const path = logPath;
92
96
  if (!path) return;
93
97
  const st = await stat(path).catch(() => null);
94
- if (st && st.size > MAX_BYTES) await rename(path, `${path}.1`).catch(() => {});
98
+ if (st && st.size > MAX_BYTES) {
99
+ // Cascade .N-1 -> .N (oldest first) so N generations survive instead of one.
100
+ for (let i = MAX_GENERATIONS - 1; i >= 1; i--) {
101
+ await rename(`${path}.${i}`, `${path}.${i + 1}`).catch(() => {});
102
+ }
103
+ await rename(path, `${path}.1`).catch(() => {});
104
+ }
95
105
  }
96
106
 
97
107
  /**
package/src/server.js CHANGED
@@ -22,7 +22,25 @@ const DEFAULT_RETRY = {
22
22
  // serves EVERY provider (Anthropic/GLM/Kimi), so the idle gap is generous enough
23
23
  // that a legitimately-slow-but-alive stream is never cut (each chunk resets it).
24
24
  const UPSTREAM_TTFB_MS = Math.max(5_000, Number(process.env.MAXPOOL_TTFB_MS) || 120_000); // headers must arrive within this
25
- const STREAM_IDLE_MS = Math.max(30_000, Number(process.env.MAXPOOL_STREAM_IDLE_MS) || 300_000); // max gap BETWEEN streamed chunks (reset per chunk)
25
+ // A real SSE EVENT, not a `:` comment. Claude Code's stall watchdog is reset only when its
26
+ // SSE iterator YIELDS an event; per the spec a comment line is discarded by the parser and
27
+ // never yields, so a comment keepalive resets nothing. That is why held requests died at
28
+ // EXACTLY 300.0s — the client's floor — despite a 10s heartbeat (60 such deaths in 2.4
29
+ // days). `ping` is an unknown event type the client ignores semantically while still
30
+ // counting as traffic.
31
+ const NETWORK_ERROR_CODES = new Set([
32
+ 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT',
33
+ 'ENOTFOUND', 'EAI_AGAIN', 'EPIPE', 'ECONNABORTED', 'UND_ERR_SOCKET', 'UND_ERR_HEADERS_TIMEOUT',
34
+ ]);
35
+ const isNetworkCode = c => Boolean(c) && NETWORK_ERROR_CODES.has(c);
36
+ const QUEUE_KEEPALIVE = 'event: ping\ndata: {}\n\n';
37
+ // 240s, strictly BELOW Claude Code's hard 300s stall floor. At the old 300_000 the two
38
+ // timers were a dead heat and the client always won — maxpool's clock starts when a chunk
39
+ // is READ from upstream, strictly before the client parses it. Result: `upstream idle
40
+ // timeout` fired 0 times in 2.4 days while 60 requests died silently client-side. Below the
41
+ // floor maxpool wins and turns a silent stall into a labelled error. Anthropic pings during
42
+ // extended thinking, so real gaps never approach 4 minutes.
43
+ const STREAM_IDLE_MS = Math.max(30_000, Number(process.env.MAXPOOL_STREAM_IDLE_MS) || 240_000); // max gap BETWEEN streamed chunks (reset per chunk)
26
44
  const UPSTREAM_BODY_MS = Math.max(30_000, Number(process.env.MAXPOOL_BODY_MS) || 300_000); // non-streaming body read
27
45
  const CLIENT_DRAIN_MS = Math.max(5_000, Number(process.env.MAXPOOL_DRAIN_MS) || 60_000); // max wait for a backpressured client to drain (half-open client → free the lease)
28
46
  // A provider 403 is (unlike a 401) almost always transient QUOTA/PLAN exhaustion — cool
@@ -62,7 +80,14 @@ const DEFAULT_QUEUE = {
62
80
  // (front-loaded work waiting for a free account ≈ a few hours) so beyond that the
63
81
  // request error-fasts with an honest retryable 429 instead of a silent multi-day park.
64
82
  // Pair with a raised client watchdog (the cc launch sets CLAUDE_STREAM_IDLE_TIMEOUT_MS).
65
- streamClientToleranceMs: Math.max(60_000, Number(process.env.MAXPOOL_STREAM_CLIENT_TOLERANCE_MS) || 3 * 60 * 60 * 1000),
83
+ // Only trust a long hold when the client's watchdog was ACTUALLY raised. The `cc` alias
84
+ // exports CLAUDE_STREAM_IDLE_TIMEOUT_MS=3h, but a session started any other way keeps the
85
+ // 300s floor — holding its request for hours just parks a caller that left at 5 minutes.
86
+ // Derive from the env we can observe; otherwise stay under the real floor.
87
+ streamClientToleranceMs: Math.max(60_000, Number(process.env.MAXPOOL_STREAM_CLIENT_TOLERANCE_MS)
88
+ || (Number(process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS) > 300_000
89
+ ? Math.floor(Number(process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS) * 0.8)
90
+ : 240_000)),
66
91
  // Non-streaming requests have no SSE heartbeat to keep them alive, so a long
67
92
  // hold would die on the client timeout anyway. Cap their wait conservatively.
68
93
  nonStreamMaxWaitMs: 5 * 60 * 1000,
@@ -1226,10 +1251,24 @@ async function forwardRequest(
1226
1251
  // lease (free the scarce account) and STOP: no retry (the client is gone), no
1227
1252
  // write (the socket is dead).
1228
1253
  if (clientGone.signal.aborted) {
1254
+ // LOG IT. This silent return hid every client-side give-up: 60 requests in 2.4 days
1255
+ // died here leaving only an indistinguishable `(null, 300.0s)` line. `committed`
1256
+ // separates "the user saw a partial answer" (real harm, unretryable) from "the user
1257
+ // was still waiting" — and the elapsed time is what exposes a client watchdog firing
1258
+ // at its floor while maxpool was still happily holding the request.
1259
+ const heldMs = Date.now() - (requestInfo.startedAt || Date.now());
1260
+ console.log(`[Maxpool] Client left after ${(heldMs / 1000).toFixed(1)}s on "${account.name}" `
1261
+ + `(${res.headersSent ? 'mid-response — output already sent' : 'still waiting, nothing sent'})`);
1229
1262
  releaseOnClientGone();
1230
1263
  return;
1231
1264
  }
1232
- console.error(`[Maxpool] Upstream error (account "${account.name}"):`, err.message);
1265
+ // undici reports every socket/DNS/TLS failure as the bare string "fetch failed" and
1266
+ // hangs the REAL reason off err.cause. Logging err.message alone threw that away: 588
1267
+ // "fetch failed" lines and ZERO ECONNRESET/ENOTFOUND/UND_ERR in the whole log, leaving
1268
+ // every incident unattributable (DNS? TLS? socket exhaustion?).
1269
+ const rootCause = err.cause?.code || err.cause?.message || err.code || '';
1270
+ console.error(`[Maxpool] Upstream error (account "${account.name}"):`, err.message
1271
+ + (rootCause && !String(err.message).includes(rootCause) ? ` (cause: ${rootCause})` : ''));
1233
1272
 
1234
1273
  if (logDir) {
1235
1274
  logSections.push(`=== ERROR ===\n${err.stack || err.message}`);
@@ -1238,8 +1277,10 @@ async function forwardRequest(
1238
1277
 
1239
1278
  const isTransient = err instanceof Error &&
1240
1279
  (err.message.includes('fetch failed') ||
1241
- err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
1242
- err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT' ||
1280
+ // Read BOTH: on an undici fetch rejection err.code is undefined and the code lives
1281
+ // on err.cause, so the bare err.code branches were dead — only the 'fetch failed'
1282
+ // string match kept this classification alive.
1283
+ isNetworkCode(err.code) || isNetworkCode(err.cause?.code) ||
1243
1284
  // Upstream-hang guards: a stalled connection is network-class, not the
1244
1285
  // account's fault → 5s cooldown + release + (pre-headers) retry elsewhere;
1245
1286
  // once committed, isTransient falls through to sendErrorResponse which ends
@@ -1803,8 +1844,14 @@ function sendErrorResponse(res, requestInfo, status, payload, headers = {}) {
1803
1844
  if (requestInfo.queueHeartbeatActive || res.headersSent) {
1804
1845
  clearQueueHeartbeat(requestInfo);
1805
1846
  if (!res.destroyed && !res.writableEnded) {
1806
- res.write(`event: error\ndata: ${JSON.stringify(payload)}\n\n`);
1807
- res.end();
1847
+ // Guarded: a write onto a half-dead socket throws, and there is no res.on('error')
1848
+ // anywhere here — an uncaught one reaches the worker's uncaughtException handler,
1849
+ // which process.exit()s and bounces EVERY other in-flight stream. ensureQueueHeartbeat
1850
+ // already guards its identical write; this one did not.
1851
+ try {
1852
+ res.write(`event: error\ndata: ${JSON.stringify(payload)}\n\n`);
1853
+ } catch { /* peer vanished mid-write — nothing to deliver, fall through to end() */ }
1854
+ try { res.end(); } catch { /* already torn down */ }
1808
1855
  }
1809
1856
  return;
1810
1857
  }
@@ -1972,7 +2019,7 @@ async function queueAndRetry(
1972
2019
  // committed stream and DROP the held session. Keeping the heartbeat active lets
1973
2020
  // it re-hold. The heartbeat is instead stopped the instant real upstream bytes
1974
2021
  // start flowing, inside streamResponse — that prevents the Bug A interleave
1975
- // (': maxpool queued' comments injected between real SSE events) without losing
2022
+ // (queue keepalive pings injected between real SSE events) without losing
1976
2023
  // re-holdability on a post-resume failover.
1977
2024
  return forwardRequest(
1978
2025
  req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir,
@@ -2024,7 +2071,7 @@ function ensureQueueHeartbeat(res, requestInfo, queueConfig, accountManager) {
2024
2071
  'X-Accel-Buffering': 'no',
2025
2072
  });
2026
2073
  res.flushHeaders?.();
2027
- res.write(': maxpool queued\n\n');
2074
+ res.write(QUEUE_KEEPALIVE);
2028
2075
  } catch {
2029
2076
  reapDead();
2030
2077
  return;
@@ -2033,7 +2080,7 @@ function ensureQueueHeartbeat(res, requestInfo, queueConfig, accountManager) {
2033
2080
  requestInfo.queueHeartbeatTimer = setInterval(() => {
2034
2081
  if (res.destroyed || res.writableEnded) { reapDead(); return; }
2035
2082
  try {
2036
- res.write(': maxpool queued\n\n');
2083
+ res.write(QUEUE_KEEPALIVE);
2037
2084
  } catch {
2038
2085
  reapDead();
2039
2086
  }
@@ -2369,7 +2416,7 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
2369
2416
  // We're now committed to streaming a real upstream response body onto this
2370
2417
  // response — there is no more failover for this forward. Stop the queue
2371
2418
  // heartbeat (if this was a resumed held stream) BEFORE the first real byte, so
2372
- // the setInterval can't inject ': maxpool queued' comments between live SSE
2419
+ // the setInterval can't inject queue keepalive pings between live SSE
2373
2420
  // events (Bug A). It is deliberately NOT cleared earlier (on resume), so a
2374
2421
  // pre-byte failover can still re-hold the session via queueAndRetry.
2375
2422
  clearQueueHeartbeat(requestInfo);