maxpool 1.5.28 → 1.5.29

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.28",
3
+ "version": "1.5.29",
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
@@ -23,6 +23,10 @@ import {
23
23
  const args = process.argv.slice(2);
24
24
  const command = args[0];
25
25
  const SERVER_RESTART_EXIT_CODE = 75;
26
+ // If a seamless reload doesn't release us (→ take over) within this window, the new
27
+ // worker rolled back — self-heal admission so we don't 503 forever. > the baton
28
+ // readiness timeout (10s) + margin.
29
+ const RELOAD_ROLLBACK_SELFHEAL_MS = Math.max(15_000, Number(process.env.MAXPOOL_RELOAD_SELFHEAL_MS) || 30_000);
26
30
  const SERVER_WORKER_ENV = 'MAXPOOL_SERVER_WORKER';
27
31
  // Set by the supervisor when it spawns a worker for a seamless reload: that
28
32
  // worker boots HEADLESS (plain logs, no writer lease) and waits for the baton.
@@ -683,6 +687,9 @@ async function serverWorkerCommand() {
683
687
  // so it can't flip the shared TTY's raw-mode/alt-screen back and clobber the new
684
688
  // worker's live TUI. Only the current terminal owner (or the supervisor) restores.
685
689
  let terminalHandedOff = false;
690
+ // Self-heal timer for a seamless reload that rolls back (new worker fails to boot,
691
+ // we're never released). Cleared on MSG_RELEASE; fires cancelRestart otherwise.
692
+ let reloadWatchdog = null;
686
693
 
687
694
  // Best-effort terminal restore on ANY abnormal exit path (uncaughtException,
688
695
  // a bare process.exit, a crash) so the user's shell is never left in raw mode
@@ -813,6 +820,20 @@ async function serverWorkerCommand() {
813
820
  if (!forceCold && reloadStrategy({ supervised }) === 'seamless') {
814
821
  try {
815
822
  process.send({ type: MSG_RELOAD_REQUEST });
823
+ // Arm the rollback self-heal: restartController already latched
824
+ // pending/restarting + paused admission (in _restart, before we got here).
825
+ // On a SUCCESSFUL reload the supervisor sends MSG_RELEASE (→ releaseBaton­
826
+ // AndDrain clears this) then we exit. On a ROLLBACK we're never released and
827
+ // never exit — so if this fires, resume serving instead of 503-ing forever.
828
+ if (reloadWatchdog) clearTimeout(reloadWatchdog);
829
+ reloadWatchdog = setTimeout(() => {
830
+ reloadWatchdog = null;
831
+ if (draining) return; // MSG_RELEASE already arrived → a real reload, not a rollback
832
+ if (restartController?.cancelRestart()) {
833
+ console.log('[Maxpool] Reload rolled back (new worker never took over) — resumed serving.');
834
+ }
835
+ }, RELOAD_ROLLBACK_SELFHEAL_MS);
836
+ reloadWatchdog.unref?.();
816
837
  return;
817
838
  } catch { /* IPC gone — fall through to abrupt restart */ }
818
839
  }
@@ -821,6 +842,7 @@ async function serverWorkerCommand() {
821
842
 
822
843
  restartController = new RestartController({
823
844
  pauseAdmission: () => accountManager.setAdmissionPaused(true),
845
+ resumeAdmission: () => accountManager.setAdmissionPaused(false),
824
846
  restartNow: requestReload,
825
847
  // Configurable so ops can tune the bounded pre-restart drain, and so the
826
848
  // integration test can exercise the force-restart path without a 10s wait.
@@ -1044,6 +1066,9 @@ async function serverWorkerCommand() {
1044
1066
  const releaseBatonAndDrain = async () => {
1045
1067
  if (draining) { try { process.send({ type: MSG_RELEASED }); } catch { /* ignore */ } return; }
1046
1068
  draining = true;
1069
+ // MSG_RELEASE arrived → the new worker booted OK and this reload is really
1070
+ // proceeding (not a rollback). Disarm the rollback self-heal.
1071
+ if (reloadWatchdog) { clearTimeout(reloadWatchdog); reloadWatchdog = null; }
1047
1072
  if (syncTimer) clearInterval(syncTimer);
1048
1073
  // Stop accepting NEW connections; KEEP in-flight requests alive.
1049
1074
  server.maxpoolBeginDrain?.();
@@ -3,6 +3,7 @@ const NON_UPSTREAM_ROUTES = new Set(['(queued)', '(none available)']);
3
3
  export class RestartController {
4
4
  constructor({
5
5
  pauseAdmission,
6
+ resumeAdmission = () => {},
6
7
  restartNow,
7
8
  log = console.log,
8
9
  // Bounded drain: wait at most this long for in-flight upstream requests to
@@ -15,6 +16,7 @@ export class RestartController {
15
16
  clearTimeoutFn = clearTimeout,
16
17
  }) {
17
18
  this.pauseAdmission = pauseAdmission;
19
+ this.resumeAdmission = resumeAdmission;
18
20
  this.restartNow = restartNow;
19
21
  this.log = log;
20
22
  this.drainTimeoutMs = drainTimeoutMs;
@@ -33,6 +35,23 @@ export class RestartController {
33
35
  return true;
34
36
  }
35
37
 
38
+ /**
39
+ * Abort a restart that was requested but never completed because the process is
40
+ * STILL ALIVE — a seamless reload that rolled back (the new worker failed to boot,
41
+ * so the old worker was never released and never exited). Without this the latched
42
+ * `pending`/`restarting` flags make requestStarted() return false forever → the
43
+ * worker 503s EVERY request until a manual restart. Reset the flags + resume
44
+ * admission so it keeps serving. No-op if no restart is in progress.
45
+ */
46
+ cancelRestart() {
47
+ if (!this.pending && !this.restarting) return false;
48
+ this.pending = false;
49
+ this.restarting = false;
50
+ if (this._drainTimer) { this.clearTimeoutFn(this._drainTimer); this._drainTimer = null; }
51
+ this.resumeAdmission();
52
+ return true;
53
+ }
54
+
36
55
  requestRouted(id, account) {
37
56
  if (!this.activeRequests.has(id)) return false;
38
57
  if (NON_UPSTREAM_ROUTES.has(account)) this.upstreamRequests.delete(id);
package/src/server.js CHANGED
@@ -25,6 +25,11 @@ const UPSTREAM_TTFB_MS = Math.max(5_000, Number(process.env.MAXPOOL_TTFB_MS) ||
25
25
  const STREAM_IDLE_MS = Math.max(30_000, Number(process.env.MAXPOOL_STREAM_IDLE_MS) || 300_000); // max gap BETWEEN streamed chunks (reset per chunk)
26
26
  const UPSTREAM_BODY_MS = Math.max(30_000, Number(process.env.MAXPOOL_BODY_MS) || 300_000); // non-streaming body read
27
27
  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
+ // Backstop reaper idle ceiling. Floored WELL above the longest a legit NON-streaming
29
+ // request can go silent (no heartbeat there): TTFB 120s + nonStreamMaxWaitMs 300s +
30
+ // UPSTREAM_BODY_MS 300s ≈ 720s — so a slow-but-alive non-streaming request is never
31
+ // reaped at completion. Streaming holds heartbeat, so they're safe at any ceiling.
32
+ const REQUEST_IDLE_MAX_MS = Math.max(900_000, Number(process.env.MAXPOOL_REQUEST_IDLE_MAX_MS) || 1_200_000);
28
33
 
29
34
  const DEFAULT_QUEUE = {
30
35
  enabled: true,
@@ -177,6 +182,9 @@ export function createProxyServer(accountManager, config, hooks = {}) {
177
182
  // undefined (a body-read throw lands before describeRequest runs).
178
183
  const ctx = { account: null, status: null };
179
184
  let requestInfo = {};
185
+ // Backstop reaper (defense-in-depth for any UNKNOWN future hang the specific
186
+ // TTFB/idle/body/drain guards don't cover). See startIdleRequestReaper.
187
+ const reaperTimer = startIdleRequestReaper(res, reqId, REQUEST_IDLE_MAX_MS);
180
188
  try {
181
189
  // Buffer request body (needed for retry on 429)
182
190
  const bodyChunks = [];
@@ -218,6 +226,7 @@ export function createProxyServer(accountManager, config, hooks = {}) {
218
226
  });
219
227
  }
220
228
  } finally {
229
+ clearInterval(reaperTimer);
221
230
  hooks.onRequestEnd?.(reqId, {
222
231
  method: req.method, path: req.url,
223
232
  account: ctx.account, status: ctx.status,
@@ -1109,7 +1118,7 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
1109
1118
  return `All ${n} accounts exhausted. Retry in ${retryAfter}s.`;
1110
1119
  }
1111
1120
 
1112
- export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, streamResponse };
1121
+ export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, streamResponse, startIdleRequestReaper };
1113
1122
 
1114
1123
  async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
1115
1124
  if (!upstreamRes.body) return '';
@@ -1760,6 +1769,30 @@ function mappedModel(originalModel, account) {
1760
1769
  /**
1761
1770
  * Stream an SSE response to the client, parsing usage data along the way.
1762
1771
  */
1772
+ /**
1773
+ * Backstop reaper for a single request: force-abort it if it makes ZERO client-write
1774
+ * progress for idleMs. IDLE-keyed via res.socket.bytesWritten (a legit streaming HOLD
1775
+ * heartbeats → bytesWritten advances → never trips; only a genuinely stuck request
1776
+ * with no writes is reaped). res.destroy() routes into the normal onClose→finally
1777
+ * cleanup so the account lease + onRequestEnd release — never a direct releaseAccount
1778
+ * (which has no idempotency guard). Clock/timer injectable for tests. Returns the
1779
+ * interval handle (caller clearInterval()s it in the request's finally).
1780
+ */
1781
+ function startIdleRequestReaper(res, reqId, idleMs, { now = Date.now, setIntervalFn = setInterval } = {}) {
1782
+ let lastBytes = res.socket?.bytesWritten ?? 0;
1783
+ let lastProgressAt = now();
1784
+ const timer = setIntervalFn(() => {
1785
+ const bytes = res.socket?.bytesWritten ?? lastBytes;
1786
+ if (bytes !== lastBytes) { lastBytes = bytes; lastProgressAt = now(); return; }
1787
+ if (now() - lastProgressAt >= idleMs && !res.writableEnded && !res.destroyed) {
1788
+ 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`);
1789
+ res.destroy();
1790
+ }
1791
+ }, Math.min(60_000, idleMs));
1792
+ timer.unref?.();
1793
+ return timer;
1794
+ }
1795
+
1763
1796
  async function streamResponse(webStream, res, status, responseHeaders, accountIndex, accountManager, streamLog, requestInfo = {}, idleMs = STREAM_IDLE_MS) {
1764
1797
  const reader = webStream.getReader();
1765
1798
  const decoder = new TextDecoder();
package/src/tui.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createInterface } from 'node:readline';
2
- import { fetchProfile, loginOAuth } from './oauth.js';
2
+ import { fetchProfile, loginOAuth, tokenFingerprint } from './oauth.js';
3
3
  import { appendEventLog } from './event-log.js';
4
4
 
5
5
  // ── ANSI helpers ─────────────────────────────────────────────
@@ -398,7 +398,12 @@ export class TUI {
398
398
  this._confirm(
399
399
  'Restart Maxpool?',
400
400
  'Pause new requests, drain active work, then start the updated server.',
401
- () => { this.stop(); this.onRestart?.(); },
401
+ // Do NOT stop the TUI here — let the reload path own it: cold restart stops
402
+ // it in restartWorkerNow, a seamless reload in releaseBatonAndDrain (on
403
+ // MSG_RELEASE). If the reload ROLLS BACK (new worker fails to boot), neither
404
+ // fires, so the TUI keeps rendering instead of being stranded in plain-log
405
+ // mode. (The worker also self-heals admission — see index.js reload watchdog.)
406
+ () => { this.onRestart?.(); },
402
407
  );
403
408
  } else if (k === 'a') {
404
409
  this.mode = 'accounts';
@@ -725,6 +730,13 @@ export class TUI {
725
730
  throw error;
726
731
  }
727
732
  this._addLog(`Updated account "${name}"`);
733
+ // Confirm the fresh token actually reached DISK (saveConfig awaited above +
734
+ // the AM was updated before it, so disk now holds creds.refreshToken). This
735
+ // is the verifiable proof a re-auth "stuck" — its fp must match the on-disk
736
+ // token; a later "REJECTED sent fp=<other>" then means an upstream revocation,
737
+ // not a lost persist. console.log (not _addLog) so it's VISIBLE during the
738
+ // stopped-TUI login flow, mirroring the sibling "Persisted rotated token" line.
739
+ console.log(`[Maxpool] Re-authenticated "${name}" — fresh token persisted (fp=${tokenFingerprint(creds.refreshToken)})`);
728
740
  return { updated: true, name };
729
741
  } else {
730
742
  this.config.accounts.push(entry);
@@ -948,12 +960,11 @@ export class TUI {
948
960
  lines.push('');
949
961
  // Aligned column header (dim + underline so it reads as chrome, not data). It
950
962
  // labels the fixed columns that have no inline label; the Ses/Wk/Tok/Req and
951
- // Now/15m/1h labels stay inline per row. A short glossary below expands the
952
- // abbreviations the header + inline labels can't spell out.
963
+ // Now/15m/1h labels stay inline per row. It sits DIRECTLY above the rows — the
964
+ // abbreviation glossary is a FOOTER below the rows (a caption), never between
965
+ // the header and the data (a left-aligned sentence there reads as a broken,
966
+ // misaligned second header).
953
967
  lines.push(dimUnderline(acctHeader(W)));
954
- if (W >= 88) {
955
- lines.push(' ' + dim('Ses 5h · Wk 7d · Now in-flight (weight) · 15m/1h served (avg · f fails)'));
956
- }
957
968
  const showBoth = W >= 70;
958
969
  const bw = showBoth
959
970
  ? Math.max(5, Math.min(20, Math.floor((W - 56) / 2)))
@@ -962,6 +973,11 @@ export class TUI {
962
973
  for (let i = 0; i < this.am.accounts.length; i++) {
963
974
  lines.push(this._renderAcct(i, bw, showBoth));
964
975
  }
976
+ // Glossary FOOTER (expands the abbreviations the header + inline labels can't
977
+ // spell out). Below the rows so it never breaks the header↔column alignment.
978
+ if (W >= 88) {
979
+ lines.push(' ' + dim('Ses 5h · Wk 7d · Now in-flight (weight) · 15m/1h served (avg · f fails)'));
980
+ }
965
981
  }
966
982
 
967
983
  // ── Activity header