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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.51",
3
+ "version": "1.5.53",
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/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) { lastBytes = bytes; lastProgressAt = now(); return; }
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();
@@ -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
- const GRACE_MS = 60_000; // stay awake this long after the last request, to bridge gaps
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
- for (const [, r] of this.active) {
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}` : '';