maxpool 1.5.52 → 1.5.54

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.52",
3
+ "version": "1.5.54",
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/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import net from 'node:net';
3
4
  import { spawn, spawnSync } from 'node:child_process';
4
5
  import { createInterface } from 'node:readline';
5
6
  import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, loadState, saveState, getStatePath, getLogPath, readGeneration, flushConfigWrites, flushStateWrites } from './config.js';
@@ -7,6 +8,24 @@ import { setEventLogPath, installConsoleMirror, setConsoleStdoutSuppressed } fro
7
8
  import { SleepGuard } from './sleep-guard.js';
8
9
  import { AccountManager } from './account-manager.js';
9
10
  import { createProxyServer, REQUEST_IDLE_MAX_MS } from './server.js';
11
+
12
+ // ── Happy Eyeballs: stop racing a dead IPv6 leg ────────────────────────────────
13
+ // Node 20 resolves both families and, 250ms after starting the IPv4 connect, races an
14
+ // IPv6 one (autoSelectFamily, autoSelectFamilyAttemptTimeout=250). On a machine whose
15
+ // resolver returns an AAAA that BLACKHOLES — a VPN advertising IPv6 it cannot carry is
16
+ // the common case — that second leg never completes and takes the whole connect down
17
+ // with it, surfacing as UND_ERR_CONNECT_TIMEOUT / ETIMEDOUT on a network that is
18
+ // otherwise fine (curl, which falls back cleanly, succeeds in the same second).
19
+ //
20
+ // Measured 2026-08-01 on this fleet: 6,848 connect failures in one day; A/B over 14
21
+ // requests gave 12/14 at the 250ms default vs 14/14 with the timeout raised. Raising it
22
+ // is preferred over disabling the race outright: real IPv6 still wins where it works,
23
+ // and a blackholed leg simply loses instead of poisoning the attempt. `dns
24
+ // setDefaultResultOrder('ipv4first')` was ALSO measured and is NOT reliable (6 of 14 in a
25
+ // degraded window) — it reorders preference but still starts the doomed leg.
26
+ net.setDefaultAutoSelectFamilyAttemptTimeout(
27
+ Math.max(1000, Number(process.env.MAXPOOL_FAMILY_ATTEMPT_TIMEOUT_MS) || 5000),
28
+ );
10
29
  import { Prober } from './prober.js';
11
30
  import { loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon, tokenFingerprint } from './oauth.js';
12
31
  import { TUI } from './tui.js';
package/src/server.js CHANGED
@@ -256,7 +256,7 @@ export function createProxyServer(accountManager, config, hooks = {}) {
256
256
  let requestInfo = {};
257
257
  // Backstop reaper (defense-in-depth for any UNKNOWN future hang the specific
258
258
  // TTFB/idle/body/drain guards don't cover). See startIdleRequestReaper.
259
- const reaperTimer = startIdleRequestReaper(res, reqId, REQUEST_IDLE_MAX_MS);
259
+ const reaperTimer = startIdleRequestReaper(res, reqId, REQUEST_IDLE_MAX_MS, { getRequestInfo: () => requestInfo });
260
260
  try {
261
261
  // Buffer request body (needed for retry on 429)
262
262
  const bodyChunks = [];
@@ -2407,12 +2407,24 @@ function mappedModel(originalModel, account) {
2407
2407
  * (which has no idempotency guard). Clock/timer injectable for tests. Returns the
2408
2408
  * interval handle (caller clearInterval()s it in the request's finally).
2409
2409
  */
2410
- function startIdleRequestReaper(res, reqId, idleMs, { now = Date.now, setIntervalFn = setInterval } = {}) {
2410
+ function startIdleRequestReaper(res, reqId, idleMs, { now = Date.now, setIntervalFn = setInterval, getRequestInfo = null } = {}) {
2411
2411
  let lastBytes = res.socket?.bytesWritten ?? 0;
2412
2412
  let lastProgressAt = now();
2413
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);
2414
2421
  const bytes = res.socket?.bytesWritten ?? lastBytes;
2415
- 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
+ }
2416
2428
  if (now() - lastProgressAt >= idleMs && !res.writableEnded && !res.destroyed) {
2417
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`);
2418
2430
  res.destroy();
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}` : '';