maxpool 1.5.49 → 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 +1 -1
- package/src/event-log.js +12 -2
- package/src/index.js +6 -1
- package/src/server.js +58 -11
package/package.json
CHANGED
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
|
|
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)
|
|
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/index.js
CHANGED
|
@@ -1150,7 +1150,12 @@ async function serverWorkerCommand() {
|
|
|
1150
1150
|
if (r && !r.hasUpdate) notifyUpdate('Already on the latest version');
|
|
1151
1151
|
};
|
|
1152
1152
|
if (tui) tui.checkNow = checkForUpdatesNow;
|
|
1153
|
-
|
|
1153
|
+
// 30 minutes, not 6 hours. At 6h a fix published minutes ago could sit unapplied until
|
|
1154
|
+
// the evening, which reads as "auto-update is broken" even when it works perfectly (it
|
|
1155
|
+
// did — a 4h gap between publish and pickup was the entire complaint). The check is one
|
|
1156
|
+
// cheap npm registry probe, and applying is a seamless reload, so a tighter cadence
|
|
1157
|
+
// costs almost nothing. Still env-tunable; still floored at 60s.
|
|
1158
|
+
const updateIntervalMs = Math.max(60_000, Number(process.env.MAXPOOL_UPDATE_CHECK_INTERVAL_MS) || 30 * 60 * 1000);
|
|
1154
1159
|
updateTimer = setInterval(() => {
|
|
1155
1160
|
// announce:false — the persistent TUI banner is the passive reminder; only real
|
|
1156
1161
|
// actions (installing / applying) log. runUpdateCheck's latch + hasLease gate prevent
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1242
|
-
err.
|
|
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
|
-
|
|
1807
|
-
|
|
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
|
-
// (
|
|
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(
|
|
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(
|
|
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
|
|
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);
|