maxpool 1.5.51 → 1.5.53
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/server.js +32 -4
- package/src/sleep-guard.js +6 -1
- package/src/tui.js +5 -1
package/package.json
CHANGED
package/src/server.js
CHANGED
|
@@ -60,6 +60,13 @@ const DEFAULT_QUEUE = {
|
|
|
60
60
|
maxWaitMs: 24 * 60 * 60 * 1000,
|
|
61
61
|
autoMaxWaitMs: null,
|
|
62
62
|
capacityMaxWaitMs: 15 * 60 * 1000,
|
|
63
|
+
// NETWORK-cause hold ceiling. A quota/capacity hold is worth waiting out — a reset is
|
|
64
|
+
// genuinely coming. A NETWORK hold is not: the connection is dead, and holding it just
|
|
65
|
+
// parks the caller on a socket nothing is watching. On 2026-07-28 a request was held
|
|
66
|
+
// 10,445s (2h54m) having produced ZERO bytes; nothing aborted it until the user touched
|
|
67
|
+
// the keyboard. Give up quickly instead and return a retryable 429, so the client
|
|
68
|
+
// reconnects on a FRESH connection — which is what actually self-heals a dead route.
|
|
69
|
+
networkMaxWaitMs: 2 * 60 * 1000,
|
|
63
70
|
weeklyMaxWaitMs: 24 * 60 * 60 * 1000, // legacy bound; streaming holds use streamHoldMaxMs
|
|
64
71
|
// STREAMING hold ceiling: how long a streaming request may be HELD ALIVE on
|
|
65
72
|
// the SSE heartbeat waiting for any account to free up. Defaults to 7d (the
|
|
@@ -249,7 +256,7 @@ export function createProxyServer(accountManager, config, hooks = {}) {
|
|
|
249
256
|
let requestInfo = {};
|
|
250
257
|
// Backstop reaper (defense-in-depth for any UNKNOWN future hang the specific
|
|
251
258
|
// TTFB/idle/body/drain guards don't cover). See startIdleRequestReaper.
|
|
252
|
-
const reaperTimer = startIdleRequestReaper(res, reqId, REQUEST_IDLE_MAX_MS);
|
|
259
|
+
const reaperTimer = startIdleRequestReaper(res, reqId, REQUEST_IDLE_MAX_MS, { getRequestInfo: () => requestInfo });
|
|
253
260
|
try {
|
|
254
261
|
// Buffer request body (needed for retry on 429)
|
|
255
262
|
const bodyChunks = [];
|
|
@@ -1387,7 +1394,7 @@ function formatRetryDuration(seconds) {
|
|
|
1387
1394
|
function computeQueueWindowMs({
|
|
1388
1395
|
cause, stream, retryPlanCause,
|
|
1389
1396
|
maxWaitMs, capacityMaxWaitMs, nonStreamMaxWaitMs, streamHoldMaxMs, streamClientToleranceMs,
|
|
1390
|
-
isCountTokens, countTokensMaxWaitMs,
|
|
1397
|
+
isCountTokens, countTokensMaxWaitMs, networkMaxWaitMs,
|
|
1391
1398
|
}) {
|
|
1392
1399
|
let windowMs;
|
|
1393
1400
|
if (!stream) {
|
|
@@ -1408,6 +1415,11 @@ function computeQueueWindowMs({
|
|
|
1408
1415
|
// the upstream processing once acquired) so a non-heartbeated metadata call fast-
|
|
1409
1416
|
// fails with a retryable 429 instead of hanging past the client's idle window.
|
|
1410
1417
|
if (isCountTokens && countTokensMaxWaitMs != null) windowMs = Math.min(windowMs, countTokensMaxWaitMs);
|
|
1418
|
+
// A dead ROUTE is not a scheduled reset: holding it out is waiting for nothing. Cap it
|
|
1419
|
+
// hard, independent of how patient the client is — a raised CLAUDE_STREAM_IDLE_TIMEOUT_MS
|
|
1420
|
+
// (the `cc` alias sets 3h) otherwise licenses a multi-hour hold on a connection that is
|
|
1421
|
+
// simply gone. Error-fast + client reconnect beats an unattended hold.
|
|
1422
|
+
if (cause === 'network' && networkMaxWaitMs != null) windowMs = Math.min(windowMs, networkMaxWaitMs);
|
|
1411
1423
|
return windowMs;
|
|
1412
1424
|
}
|
|
1413
1425
|
|
|
@@ -1950,6 +1962,9 @@ async function queueAndRetry(
|
|
|
1950
1962
|
const streamClientToleranceMs = queueConfig.streamClientToleranceMs == null
|
|
1951
1963
|
? 3 * 60 * 60 * 1000
|
|
1952
1964
|
: Math.max(0, Number(queueConfig.streamClientToleranceMs) || 0);
|
|
1965
|
+
const networkMaxWaitMs = queueConfig.networkMaxWaitMs == null
|
|
1966
|
+
? 2 * 60 * 1000
|
|
1967
|
+
: Math.max(0, Number(queueConfig.networkMaxWaitMs) || 0);
|
|
1953
1968
|
const queueWindowMs = computeQueueWindowMs({
|
|
1954
1969
|
cause,
|
|
1955
1970
|
stream: Boolean(requestInfo.stream),
|
|
@@ -1957,6 +1972,7 @@ async function queueAndRetry(
|
|
|
1957
1972
|
maxWaitMs,
|
|
1958
1973
|
capacityMaxWaitMs,
|
|
1959
1974
|
nonStreamMaxWaitMs,
|
|
1975
|
+
networkMaxWaitMs,
|
|
1960
1976
|
streamHoldMaxMs,
|
|
1961
1977
|
streamClientToleranceMs,
|
|
1962
1978
|
isCountTokens: Boolean(requestInfo.isCountTokens),
|
|
@@ -2391,12 +2407,24 @@ function mappedModel(originalModel, account) {
|
|
|
2391
2407
|
* (which has no idempotency guard). Clock/timer injectable for tests. Returns the
|
|
2392
2408
|
* interval handle (caller clearInterval()s it in the request's finally).
|
|
2393
2409
|
*/
|
|
2394
|
-
function startIdleRequestReaper(res, reqId, idleMs, { now = Date.now, setIntervalFn = setInterval } = {}) {
|
|
2410
|
+
function startIdleRequestReaper(res, reqId, idleMs, { now = Date.now, setIntervalFn = setInterval, getRequestInfo = null } = {}) {
|
|
2395
2411
|
let lastBytes = res.socket?.bytesWritten ?? 0;
|
|
2396
2412
|
let lastProgressAt = now();
|
|
2397
2413
|
const timer = setIntervalFn(() => {
|
|
2414
|
+
// Count only REAL progress. `bytesWritten` includes maxpool's OWN queue keepalive,
|
|
2415
|
+
// which pings every 10s — so a request stuck forever kept resetting this watchdog with
|
|
2416
|
+
// our own noise and could never be reaped. Observed 2026-07-29: 50 requests pinned
|
|
2417
|
+
// "in-flight" on one account for up to 6.7h, serving zero, which distorted the load
|
|
2418
|
+
// balancer into avoiding a healthy account. A held request is progressing only if the
|
|
2419
|
+
// UPSTREAM produced something; heartbeat bytes prove nothing.
|
|
2420
|
+
const heldOnHeartbeat = Boolean(getRequestInfo?.()?.queueHeartbeatActive);
|
|
2398
2421
|
const bytes = res.socket?.bytesWritten ?? lastBytes;
|
|
2399
|
-
if (bytes !== lastBytes) {
|
|
2422
|
+
if (bytes !== lastBytes) {
|
|
2423
|
+
lastBytes = bytes;
|
|
2424
|
+
if (!heldOnHeartbeat) { lastProgressAt = now(); return; }
|
|
2425
|
+
// Held: those bytes were OUR keepalive, so they are not progress — deliberately
|
|
2426
|
+
// fall through to the staleness check rather than returning.
|
|
2427
|
+
}
|
|
2400
2428
|
if (now() - lastProgressAt >= idleMs && !res.writableEnded && !res.destroyed) {
|
|
2401
2429
|
console.error(`[Maxpool] Request ${reqId} — no write progress for ${Math.round(idleMs / 1000)}s (backstop reaper); force-aborting a stuck request to free its account slot`);
|
|
2402
2430
|
res.destroy();
|
package/src/sleep-guard.js
CHANGED
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
import { spawn as realSpawn } from 'node:child_process';
|
|
12
12
|
|
|
13
13
|
const POLL_MS = 5_000; // re-check the in-flight signal (idle-sleep timers are minutes)
|
|
14
|
-
|
|
14
|
+
// Stay awake this long after the last request. 60s lost a measured race on 2026-07-28: a
|
|
15
|
+
// network outage drained all in-flight work, the guard released exactly 74s later, and
|
|
16
|
+
// macOS (idle-sleep timer) slept in the SAME SECOND — so the machine went down while the
|
|
17
|
+
// outage was still in progress and recovery had to wait for a keystroke. 5 minutes bridges
|
|
18
|
+
// an outage-shaped gap without meaningfully delaying a genuinely idle sleep.
|
|
19
|
+
const GRACE_MS = 300_000;
|
|
15
20
|
|
|
16
21
|
export class SleepGuard {
|
|
17
22
|
constructor({ getWorkPending, log = () => {}, spawn = realSpawn, enabled, pollMs = POLL_MS, graceMs = GRACE_MS } = {}) {
|
package/src/tui.js
CHANGED
|
@@ -1214,7 +1214,11 @@ export class TUI {
|
|
|
1214
1214
|
|
|
1215
1215
|
// Active requests
|
|
1216
1216
|
const now = Date.now();
|
|
1217
|
-
|
|
1217
|
+
// NEWEST FIRST, matching the completed log directly below (which is `unshift`ed).
|
|
1218
|
+
// A Map iterates in INSERTION order, so this block used to run oldest-first while the
|
|
1219
|
+
// log under it ran newest-first — the panel disagreed with itself and the newest thing
|
|
1220
|
+
// that happened was buried in the middle of the screen.
|
|
1221
|
+
for (const [, r] of [...this.active].reverse()) {
|
|
1218
1222
|
const el = ((now - r.started) / 1000).toFixed(1);
|
|
1219
1223
|
const sp = cyan(SPINNER[this.frame]);
|
|
1220
1224
|
const a = r.account ? ` → ${r.account}` : '';
|