maxpool 1.5.2 → 1.5.4

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.2",
3
+ "version": "1.5.4",
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",
@@ -48,6 +48,10 @@ const DEFAULT_SCHEDULER = {
48
48
  safetyMaxGlobalActive: 150,
49
49
  cooldownMs: 30_000,
50
50
  maxCooldownMs: 15 * 60_000,
51
+ // Fixed cooldown for NETWORK-class failures (lost connectivity / token-refresh
52
+ // fetch-failed). Short + non-escalating so the fleet auto-recovers seconds after
53
+ // connectivity returns, instead of the exponential maxCooldownMs bench.
54
+ networkCooldownMs: 5_000,
51
55
  weeklySoftThreshold: 0.65,
52
56
  weeklyReserveThreshold: 0.85,
53
57
  weeklyCriticalThreshold: 0.95,
@@ -1698,9 +1702,28 @@ export class AccountManager {
1698
1702
  console.log(`[Maxpool] Account "${account.name}" disabled after HTTP ${status} (${reason})`);
1699
1703
  }
1700
1704
 
1701
- markTransientFailure(accountIndex, reason = 'transient_error') {
1705
+ markTransientFailure(accountIndex, reason = 'transient_error', { network = false } = {}) {
1702
1706
  const account = this.accounts[accountIndex];
1703
1707
  if (!account) return;
1708
+
1709
+ if (network) {
1710
+ // A network-class failure (lost connectivity / token-refresh `fetch failed`)
1711
+ // is a FLEET-WIDE condition, not this account's fault — every account fails at
1712
+ // once. Use a SHORT FIXED cooldown so the whole fleet retries within seconds of
1713
+ // connectivity returning and recovers AUTOMATICALLY, never the exponential
1714
+ // 15-min bench that stranded the fleet long after a multi-hour outage and forced
1715
+ // a manual restart (2026-06-29 hotel-network nightly cutoff). Deliberately does
1716
+ // NOT bump consecutiveFailures: a network blip must not poison the scoring
1717
+ // penalty (_scoreAccount) or prime the next REAL per-account failure for the max
1718
+ // cooldown — so the counter stays a pure request-health signal and needs no reset.
1719
+ account.failedRequests++;
1720
+ account.lastError = reason;
1721
+ account.lastErrorAt = Date.now();
1722
+ account.cooldownUntil = Date.now() + this.scheduler.networkCooldownMs;
1723
+ console.log(`[Maxpool] Account "${account.name}" cooling down for ${Math.ceil(this.scheduler.networkCooldownMs / 1000)}s after ${reason} (network — short fixed, auto-recovers)`);
1724
+ return;
1725
+ }
1726
+
1704
1727
  const failures = Math.max(1, account.consecutiveFailures + 1);
1705
1728
  const cooldown = Math.min(
1706
1729
  this.scheduler.maxCooldownMs,
@@ -1812,7 +1835,11 @@ export class AccountManager {
1812
1835
  // a failed proactive refresh shouldn't kill a still-valid token
1813
1836
  if (!account.expiresAt || Date.now() >= account.expiresAt) {
1814
1837
  if (err.retryable) {
1815
- this.markTransientFailure(accountIndex, `token_refresh_${err.status || 'network'}`);
1838
+ // A retryable refresh failure with NO HTTP status is a network/connectivity
1839
+ // failure (fetch failed / timeout) → short fixed cooldown so it auto-recovers
1840
+ // when the network returns. A retryable HTTP status (429 / 5xx from the OAuth
1841
+ // endpoint) is server-side → keep the exponential backoff.
1842
+ this.markTransientFailure(accountIndex, `token_refresh_${err.status || 'network'}`, { network: !err.status });
1816
1843
  } else {
1817
1844
  this.markAuthFailed(accountIndex, err.status || 401, 'token_refresh_failed');
1818
1845
  }
package/src/config.js CHANGED
@@ -59,6 +59,10 @@ export function createDefaultConfig() {
59
59
  safetyMaxGlobalActive: 150,
60
60
  cooldownMs: 30_000,
61
61
  maxCooldownMs: 15 * 60_000,
62
+ // Fixed cooldown for network-class failures (lost connectivity / token-refresh
63
+ // fetch-failed) — short + non-escalating so the fleet auto-recovers seconds after
64
+ // connectivity returns, never the exponential maxCooldownMs bench.
65
+ networkCooldownMs: 5_000,
62
66
  // Weekly (7d) quota tiers — how aggressively to de-prioritise an account
63
67
  // as its weekly usage climbs. Each is a fraction (0..1) of the weekly
64
68
  // limit. Below soft = full speed; soft..reserve = mild penalty;
package/src/index.js CHANGED
@@ -26,6 +26,23 @@ const SERVER_WORKER_ENV = 'MAXPOOL_SERVER_WORKER';
26
26
  // worker boots HEADLESS (plain logs, no writer lease) and waits for the baton.
27
27
  const SERVER_RELOAD_WORKER_ENV = 'MAXPOOL_RELOAD_WORKER';
28
28
 
29
+ // Is maxpool attached to an interactive terminal? Governs SIGNAL semantics: in a
30
+ // terminal a SIGHUP means "the window hung up → shut down" (never reload into a
31
+ // headless orphan that outlives the terminal and squats the port); headless /
32
+ // service mode keeps SIGHUP as the conventional reload trigger. MAXPOOL_FORCE_TTY
33
+ // lets the (non-pty) test suite exercise the terminal-hangup path deterministically.
34
+ function isInteractiveTerminal() {
35
+ return Boolean(process.stdout.isTTY) || process.env.MAXPOOL_FORCE_TTY === '1';
36
+ }
37
+
38
+ // Which reload path an in-process reload request takes. Interactive (a live TUI)
39
+ // reloads via a FULL cold restart so the fresh worker re-renders the TUI — a
40
+ // seamless reload worker boots headless and would strand the user in raw logs.
41
+ // Headless/service keeps the zero-downtime baton (nothing visual to lose).
42
+ function reloadStrategy({ supervised, useTUI }) {
43
+ return supervised && !useTUI ? 'seamless' : 'cold-restart';
44
+ }
45
+
29
46
  switch (command) {
30
47
  case 'server':
31
48
  await serverCommand();
@@ -149,9 +166,18 @@ async function supervisorCommand() {
149
166
  const forwardSignal = sig => { try { activeWorker?.child.kill(sig); } catch { /* ignore */ } };
150
167
  process.on('SIGINT', () => { /* delivered to the worker by the TTY group */ });
151
168
  process.on('SIGTERM', () => { /* delivered to the worker by the TTY group */ });
152
- // SIGHUP (from `kill -HUP <supervisor-pid>`) reaches ONLY the supervisor →
153
- // forward it so the worker requests a seamless reload.
154
- process.on('SIGHUP', () => forwardSignal('SIGHUP'));
169
+ // SIGHUP: in a terminal, a window hangup delivers SIGHUP to the whole foreground
170
+ // group, so the worker ALSO received it and (interactive) is already shutting
171
+ // itself down — the supervisor must NOT forward a second signal, or the worker
172
+ // gets a doubled signal that force-quits it mid-drain (non-zero exit → a fast
173
+ // exit is then misread as a crash → respawn into a headless orphan, the exact
174
+ // bug). Only forward in headless/service mode, where SIGHUP is the conventional
175
+ // reload trigger and reaches the supervisor alone.
176
+ process.on('SIGHUP', () => { if (!isInteractiveTerminal()) forwardSignal('SIGHUP'); });
177
+ // A dead controlling terminal (window closed) makes writes to stdout/stderr emit
178
+ // EPIPE/EIO — swallow them so a shutdown-time log can't crash the supervisor.
179
+ process.stdout.on('error', () => {});
180
+ process.stderr.on('error', () => {});
155
181
  // A spawn failure / stray rejection must NOT kill the supervisor (it would
156
182
  // wedge the port and drop the service). Log and let the supervision loop or
157
183
  // the reload's own error handling recover.
@@ -563,9 +589,15 @@ async function serverWorkerCommand() {
563
589
  console.error(`[Maxpool] Worker uncaughtException: ${err?.stack || err}`);
564
590
  restoreTerminal();
565
591
  // A reload worker must NEVER exit(1) (escapes the supervisor exit-75 loop).
566
- // Stay alive so the supervisor's baton timeouts roll us back cleanly.
567
- if (!isReloadWorker) process.exit(SERVER_RESTART_EXIT_CODE);
592
+ // Stay alive so the supervisor's baton timeouts roll us back cleanly. Also
593
+ // skip the exit-75 restart while DRAINING: a shutdown-time write to a dead
594
+ // terminal can throw here, and restarting then would respawn the very orphan
595
+ // a terminal-close shutdown is trying to avoid — let the drain finish instead.
596
+ if (!isReloadWorker && !draining) process.exit(SERVER_RESTART_EXIT_CODE);
568
597
  });
598
+ // Swallow EPIPE/EIO from writes to a hung-up terminal so they can't crash us.
599
+ process.stdout.on('error', () => {});
600
+ process.stderr.on('error', () => {});
569
601
  process.on('unhandledRejection', reason => {
570
602
  console.error(`[Maxpool] Worker unhandledRejection: ${reason}`);
571
603
  });
@@ -636,7 +668,17 @@ async function serverWorkerCommand() {
636
668
  // the respawned worker doesn't boot from a half-written state. Bounded so the
637
669
  // abrupt path stays fast even if a write hangs.
638
670
  Promise.race([
639
- (async () => { await persistQuotaState(); await releaseLease(); await flushConfigWrites(); await flushStateWrites(); })(),
671
+ (async () => {
672
+ // Drain in-flight token refreshes before exit so the cold-respawned worker
673
+ // never boots mid-rotation of a single-use refresh token (→ invalid_grant
674
+ // → bricked account). Mirrors shutdownGracefully + the seamless baton's
675
+ // drainRefreshes — required now that interactive reload routes through here.
676
+ await releaseLease();
677
+ await accountManager.drainRefreshes();
678
+ await persistQuotaState(true);
679
+ await flushConfigWrites();
680
+ await flushStateWrites();
681
+ })(),
640
682
  delay(2000),
641
683
  ]).finally(() => process.exit(SERVER_RESTART_EXIT_CODE));
642
684
  };
@@ -646,7 +688,9 @@ async function serverWorkerCommand() {
646
688
  // (not supervised, no IPC), fall back to the abrupt restart.
647
689
  const requestReload = () => {
648
690
  if (draining) return;
649
- if (supervised) {
691
+ // Interactive (live TUI) → full cold restart so the fresh worker re-renders the
692
+ // TUI; headless/service → zero-downtime seamless baton (nothing visual to lose).
693
+ if (reloadStrategy({ supervised, useTUI }) === 'seamless') {
650
694
  try {
651
695
  process.send({ type: MSG_RELOAD_REQUEST });
652
696
  return;
@@ -661,9 +705,15 @@ async function serverWorkerCommand() {
661
705
  });
662
706
 
663
707
  const shutdownGracefully = (reason, options = {}) => {
708
+ // A terminal-close shutdown must NEVER exit non-zero: the supervisor reads a
709
+ // fast non-zero exit as a crash and respawns a fresh worker — onto the now-dead
710
+ // terminal, re-creating the headless orphan. So even a drain-timeout or close
711
+ // error during a terminal close exits 0 (the terminal is gone; there's nothing
712
+ // to keep alive for).
713
+ const cleanExitCode = options.terminalClose ? 0 : 1;
664
714
  if (draining) {
665
715
  console.error(`\n[Maxpool] Force exiting with ${restartController.activeRequests.size} active request(s) still open.`);
666
- process.exit(1);
716
+ process.exit(cleanExitCode);
667
717
  }
668
718
 
669
719
  draining = true;
@@ -704,14 +754,14 @@ async function serverWorkerCommand() {
704
754
 
705
755
  timeoutTimer = setTimeout(() => {
706
756
  console.error(`[Maxpool] Drain timeout after ${Math.ceil(drainTimeoutMs / 1000)}s; exiting with ${restartController.activeRequests.size} active request(s) still open.`);
707
- finish(1);
757
+ finish(cleanExitCode);
708
758
  }, drainTimeoutMs);
709
759
  timeoutTimer.unref();
710
760
 
711
761
  server.close(err => {
712
762
  if (err) {
713
763
  console.error(`[Maxpool] Shutdown error: ${err.message}`);
714
- finish(1);
764
+ finish(cleanExitCode);
715
765
  return;
716
766
  }
717
767
  console.log('[Maxpool] Shutdown complete.');
@@ -936,9 +986,11 @@ async function serverWorkerCommand() {
936
986
 
937
987
  process.on('SIGINT', () => shutdownGracefully('SIGINT'));
938
988
  process.on('SIGTERM', () => shutdownGracefully('SIGTERM'));
939
- // SIGHUP requests a seamless reload (the conventional "reload" signal). Under
940
- // the supervisor this runs the baton; otherwise it falls back to exit-75.
941
- process.on('SIGHUP', () => requestReload());
989
+ // SIGHUP: in a terminal this means the controlling window hung up (closed) →
990
+ // drain + exit cleanly so we never reload into a headless orphan that outlives
991
+ // the terminal and squats the port. Headless/service mode keeps SIGHUP as the
992
+ // conventional reload signal (baton under the supervisor, else exit-75).
993
+ process.on('SIGHUP', () => { if (isInteractiveTerminal()) shutdownGracefully('SIGHUP', { terminalClose: true }); else requestReload(); });
942
994
  }
943
995
 
944
996
  function logPlainServerStart({ host, port, accounts, threshold, config }) {
package/src/oauth.js CHANGED
@@ -17,6 +17,11 @@ const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
17
17
  export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_ENDPOINT) {
18
18
  const maxRetries = 2;
19
19
  const baseDelayMs = 500;
20
+ // Bound each refresh POST so a hung connect during an outage can't pin the
21
+ // single-flight _refreshPromise indefinitely (which would stall that account's
22
+ // recovery). Comfortably above a normal refresh latency; a timeout is classified
23
+ // as a network error below and retried / short-cooled.
24
+ const perAttemptTimeoutMs = 10_000;
20
25
 
21
26
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
22
27
  try {
@@ -37,6 +42,7 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
37
42
  refresh_token: refreshToken,
38
43
  client_id: DEFAULT_CLIENT_ID,
39
44
  }),
45
+ signal: AbortSignal.timeout(perAttemptTimeoutMs),
40
46
  });
41
47
 
42
48
  if (!res.ok) {
@@ -60,6 +66,7 @@ export async function refreshAccessToken(refreshToken, endpoint = DEFAULT_TOKEN_
60
66
  } catch (err) {
61
67
  const isNetworkError = err instanceof Error &&
62
68
  (err.message.includes('fetch failed') ||
69
+ err.name === 'TimeoutError' || err.name === 'AbortError' ||
63
70
  (err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED' ||
64
71
  err.code === 'ETIMEDOUT' || err.code === 'UND_ERR_CONNECT_TIMEOUT'));
65
72
 
package/src/server.js CHANGED
@@ -855,7 +855,10 @@ async function forwardRequest(
855
855
  err.message.includes('terminated'));
856
856
 
857
857
  if (isTransient) {
858
- accountManager.markTransientFailure(account.index, err.code || err.message || 'network_error');
858
+ // Network-class (ECONNRESET / fetch failed / timeout / terminated): short fixed
859
+ // cooldown, no exponential escalation — the fleet auto-recovers seconds after
860
+ // connectivity returns instead of being benched for up to 15 min.
861
+ accountManager.markTransientFailure(account.index, err.code || err.message || 'network_error', { network: true });
859
862
  accountManager.releaseAccount(lease);
860
863
  excludedIndexes.add(account.index);
861
864
  if (canRetryBufferedBody && retryCount + 1 < maxAttempts && !res.headersSent) {