maxpool 1.5.27 → 1.5.28

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.27",
3
+ "version": "1.5.28",
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/event-log.js CHANGED
@@ -99,6 +99,19 @@ export async function rotateIfNeeded() {
99
99
  * output (stdout in plain mode; the TUI separately re-points console at its ring
100
100
  * buffer, whose _addLog also calls appendEventLog, so TUI-mode lines persist too).
101
101
  */
102
+ let stdoutSuppressed = false;
103
+
104
+ /**
105
+ * Suppress the console mirror's STDOUT passthrough (the event-log append still
106
+ * runs). Used during a seamless TUI reload so a worker that doesn't currently own
107
+ * the terminal — the new worker before its takeover, or the old worker draining
108
+ * after it handed the TTY off — can't paint plain log lines over the live TUI of
109
+ * the worker that DOES own it. Per-process (each worker has its own module state),
110
+ * so it never affects the other worker. Read live inside the mirror, so lifting it
111
+ * takes effect immediately even for a console wrapper captured earlier.
112
+ */
113
+ export function setConsoleStdoutSuppressed(v) { stdoutSuppressed = !!v; }
114
+
102
115
  export function installConsoleMirror() {
103
116
  if (origConsole) return; // idempotent
104
117
  origConsole = { log: console.log.bind(console), error: console.error.bind(console) };
@@ -106,7 +119,7 @@ export function installConsoleMirror() {
106
119
  const orig = origConsole[method];
107
120
  console[method] = (...args) => {
108
121
  try { appendEventLog(args.map(a => (typeof a === 'string' ? a : String(a))).join(' ')); } catch { /* never */ }
109
- orig(...args);
122
+ if (!stdoutSuppressed) orig(...args);
110
123
  };
111
124
  }
112
125
  }
@@ -118,5 +131,5 @@ export const flushEventLog = () => writeChain;
118
131
  export function __resetEventLogForTest() {
119
132
  if (origConsole) { console.log = origConsole.log; console.error = origConsole.error; origConsole = null; }
120
133
  if (rotationTimer) { clearInterval(rotationTimer); rotationTimer = null; }
121
- logPath = null; manageRotation = false; writeChain = Promise.resolve(); queued = 0; dropped = 0;
134
+ logPath = null; manageRotation = false; writeChain = Promise.resolve(); queued = 0; dropped = 0; stdoutSuppressed = false;
122
135
  }
package/src/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
4
  import { createInterface } from 'node:readline';
5
5
  import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, loadState, saveState, getStatePath, getLogPath, readGeneration, flushConfigWrites, flushStateWrites } from './config.js';
6
- import { setEventLogPath, installConsoleMirror } from './event-log.js';
6
+ import { setEventLogPath, installConsoleMirror, setConsoleStdoutSuppressed } from './event-log.js';
7
7
  import { SleepGuard } from './sleep-guard.js';
8
8
  import { AccountManager } from './account-manager.js';
9
9
  import { createProxyServer } from './server.js';
@@ -48,12 +48,16 @@ function initEventLog(config, { manageRotation = false } = {}) {
48
48
  } catch { /* logging must never block startup */ }
49
49
  }
50
50
 
51
- // Which reload path an in-process reload request takes. Interactive (a live TUI)
52
- // reloads via a FULL cold restart so the fresh worker re-renders the TUI — a
53
- // seamless reload worker boots headless and would strand the user in raw logs.
54
- // Headless/service keeps the zero-downtime baton (nothing visual to lose).
55
- function reloadStrategy({ supervised, useTUI }) {
56
- return supervised && !useTUI ? 'seamless' : 'cold-restart';
51
+ // Which reload path an in-process reload request takes. ALL supervised reloads —
52
+ // TUI and headless alike — use the zero-downtime baton so the listening socket
53
+ // never closes (no ECONNREFUSED for the ~5 other sessions routing through us). A
54
+ // TUI reload additionally hands the terminal off worker→worker (the new worker
55
+ // re-renders the TUI at takeover; the old worker's terminalHandedOff guard stops
56
+ // it clobbering the shared TTY on exit). Only an UNsupervised process cold-restarts.
57
+ // The user escape hatch MAXPOOL_TUI_COLD_RESTART=1 forces the old cold path (see
58
+ // requestReload) if the TTY handoff ever misbehaves on their terminal.
59
+ function reloadStrategy({ supervised }) {
60
+ return supervised ? 'seamless' : 'cold-restart';
57
61
  }
58
62
 
59
63
  switch (command) {
@@ -657,20 +661,36 @@ async function serverWorkerCommand() {
657
661
 
658
662
  const port = config.proxy.port;
659
663
  const host = config.proxy.host || '127.0.0.1';
660
- // A headless reload worker NEVER drives the TUI (single-owner terminal); it
661
- // takes the TUI only on baton takeover. Cold/direct workers use it if on a TTY.
662
- const useTUI = process.stdout.isTTY && process.stdin.isTTY && !isReloadWorker;
664
+ // A reload worker constructs a TUI when on a TTY but only START()s it on baton
665
+ // takeover (becomePrimary) — single-owner terminal, enforced by the baton order
666
+ // (old worker's tui.stop() precedes MSG_RELEASED precedes the new worker's
667
+ // MSG_TAKEOVER→tui.start()). It boots headless-silent until then (stdout muzzled
668
+ // just below), so its boot logs can't paint over the old worker's live TUI.
669
+ const useTUI = process.stdout.isTTY && process.stdin.isTTY;
670
+
671
+ // A reload worker with a TUI must not write plain logs to the shared terminal
672
+ // (still owned by the old worker's TUI) while booting toward takeover; suppress
673
+ // stdout (event-log append still runs). Lifted in becomePrimary at takeover.
674
+ if (isReloadWorker && useTUI) setConsoleStdoutSuppressed(true);
663
675
 
664
676
  let tui = null;
665
677
  let server = null;
666
678
  let syncTimer = null;
667
679
  let draining = false;
668
680
  let restartController = null;
681
+ // Set once this worker hands the terminal to a new worker during a seamless TUI
682
+ // reload; makes this (now non-owner) worker's exit-path restoreTerminal a NO-OP
683
+ // so it can't flip the shared TTY's raw-mode/alt-screen back and clobber the new
684
+ // worker's live TUI. Only the current terminal owner (or the supervisor) restores.
685
+ let terminalHandedOff = false;
669
686
 
670
687
  // Best-effort terminal restore on ANY abnormal exit path (uncaughtException,
671
688
  // a bare process.exit, a crash) so the user's shell is never left in raw mode
672
689
  // or the alt-screen. Idempotent and safe even when no TUI was running.
673
690
  const restoreTerminal = () => {
691
+ // Handed the TTY to a new worker (seamless TUI reload) → the new worker owns
692
+ // it now; restoring here would clobber its live TUI. No-op.
693
+ if (terminalHandedOff) return;
674
694
  try {
675
695
  if (tui?.running) { tui.stop(); return; }
676
696
  if (process.stdout.isTTY) process.stdout.write('\x1b[?25h\x1b[?1049l');
@@ -783,12 +803,14 @@ async function serverWorkerCommand() {
783
803
  // (not supervised, no IPC), fall back to the abrupt restart.
784
804
  const requestReload = () => {
785
805
  if (draining) return;
786
- // Interactive (live TUI) → full cold restart so the fresh worker re-renders the
787
- // TUI; headless/service → zero-downtime seamless baton (nothing visual to lose).
788
- // Test-only: force the interactive cold-restart path headless (a pty can't be
789
- // allocated in-suite), so the exit-75 respawn is exercised without a real TUI.
790
- const forceCold = process.env.MAXPOOL_TEST_FORCE_COLD_RESTART === '1';
791
- if (!forceCold && reloadStrategy({ supervised, useTUI }) === 'seamless') {
806
+ // Supervised → zero-downtime seamless baton (socket never closes; TUI hands the
807
+ // terminal off worker→worker). Unsupervised → cold restart.
808
+ // Escape hatches force the cold path: MAXPOOL_TEST_FORCE_COLD_RESTART (in-suite,
809
+ // no pty) and the USER-facing MAXPOOL_TUI_COLD_RESTART=1 (revert to the old
810
+ // brief-ECONNREFUSED cold restart if the seamless TTY handoff ever misbehaves).
811
+ const forceCold = process.env.MAXPOOL_TEST_FORCE_COLD_RESTART === '1'
812
+ || process.env.MAXPOOL_TUI_COLD_RESTART === '1';
813
+ if (!forceCold && reloadStrategy({ supervised }) === 'seamless') {
792
814
  try {
793
815
  process.send({ type: MSG_RELOAD_REQUEST });
794
816
  return;
@@ -954,6 +976,10 @@ async function serverWorkerCommand() {
954
976
  // baton (a reload) — freeze the update check so a reload doesn't re-probe npm
955
977
  // (only a cold supervisor start self-updates).
956
978
  const becomePrimary = async ({ viaTakeover }) => {
979
+ // We're taking the terminal now (or are the cold-start primary). Lift the
980
+ // reload-worker boot muzzle so the TUI's first render + any plain-mode fallback
981
+ // logs reach stdout. (No-op for a worker that never suppressed.)
982
+ setConsoleStdoutSuppressed(false);
957
983
  if (tui) {
958
984
  if (tui.start()) {
959
985
  console.log(`Listening on ${host}:${port} with ${accounts.length} account(s)`);
@@ -1041,6 +1067,13 @@ async function serverWorkerCommand() {
1041
1067
  await flushStateWrites();
1042
1068
 
1043
1069
  if (tui?.running) tui.stop(); // restore terminal before the new worker takes it
1070
+ // The terminal is now HANDED OFF: the incoming worker will take the TTY at
1071
+ // MSG_TAKEOVER. Arm the guard UNCONDITIONALLY (even if the TUI was already
1072
+ // stopped) so this worker's later exit(0) restoreTerminal can't clobber the
1073
+ // new worker's TUI. Also muzzle our own remaining drain-time logs (in-flight
1074
+ // requests completing) so they don't paint the new worker's alt-screen.
1075
+ terminalHandedOff = true;
1076
+ setConsoleStdoutSuppressed(true);
1044
1077
  try { process.send({ type: MSG_RELEASED }); } catch { /* ignore */ }
1045
1078
 
1046
1079
  // Drain bounded in-flight on EXISTING access tokens (no refresh needed),
@@ -1569,6 +1602,11 @@ Options:
1569
1602
  --log-to DIR Log full requests/responses to DIR (server, one file per request)
1570
1603
  --with-key Include proxy API key in maxpool env output
1571
1604
 
1605
+ Env:
1606
+ MAXPOOL_TUI_COLD_RESTART=1 Reload (r) via a full cold restart instead of the
1607
+ zero-downtime seamless handoff — a fallback if the
1608
+ terminal ever misbehaves after a reload.
1609
+
1572
1610
  Config: ${getConfigPath()}
1573
1611
  `);
1574
1612
  }
package/src/server.js CHANGED
@@ -24,6 +24,7 @@ const DEFAULT_RETRY = {
24
24
  const UPSTREAM_TTFB_MS = Math.max(5_000, Number(process.env.MAXPOOL_TTFB_MS) || 120_000); // headers must arrive within this
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
+ 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)
27
28
 
28
29
  const DEFAULT_QUEUE = {
29
30
  enabled: true,
@@ -167,32 +168,40 @@ export function createProxyServer(accountManager, config, hooks = {}) {
167
168
  return;
168
169
  }
169
170
 
170
- // Buffer request body (needed for retry on 429)
171
- const bodyChunks = [];
172
- for await (const chunk of req) {
173
- bodyChunks.push(chunk);
174
- }
175
- const body = Buffer.concat(bodyChunks);
176
- const retryConfig = { ...DEFAULT_RETRY, ...(config.retry || {}) };
177
- const queueConfig = { ...DEFAULT_QUEUE, ...(config.queue || {}) };
178
- const canRetryBufferedBody = body.length <= retryConfig.maxRetryBufferBytes;
179
- const requestInfo = describeRequest(req, body);
180
- const maxQueuedBodyBytes = queueConfig.maxQueuedBodyBytes == null
181
- ? Infinity
182
- : Math.max(0, Number(queueConfig.maxQueuedBodyBytes) || 0);
183
- const canQueueBufferedBody = body.length <= maxQueuedBodyBytes;
184
- if (!canQueueBufferedBody) {
185
- requestInfo.queueBlockedReason = `request body ${body.length} bytes exceeds queue.maxQueuedBodyBytes ${maxQueuedBodyBytes}`;
186
- }
187
- requestInfo.profile = getMaxpoolProfile(req.headers);
188
- requestInfo.sessionKey = headerValue(req.headers, 'x-maxpool-session');
189
- if (requestInfo.requiresAnthropicThinkingIntegrity && requestInfo.profile === 'all') {
190
- console.log('[Maxpool] Anthropic thinking detected; provider fallback disabled for this session/request');
191
- }
192
- prepareRuntimeProviders(accountManager, req.headers);
193
-
171
+ // The request is now TRACKED (onRequestStart accepted → tui.active +
172
+ // restartController.activeRequests both hold it). EVERYTHING that can throw —
173
+ // including body buffering — must run inside the try whose finally fires
174
+ // onRequestEnd, or a client that dies mid-body-read throws to the OUTER catch
175
+ // and leaks the active entry AND the restart drain counter (hangs a reload's
176
+ // drain). requestInfo starts as {} so the error path never dereferences an
177
+ // undefined (a body-read throw lands before describeRequest runs).
194
178
  const ctx = { account: null, status: null };
179
+ let requestInfo = {};
195
180
  try {
181
+ // Buffer request body (needed for retry on 429)
182
+ const bodyChunks = [];
183
+ for await (const chunk of req) {
184
+ bodyChunks.push(chunk);
185
+ }
186
+ const body = Buffer.concat(bodyChunks);
187
+ const retryConfig = { ...DEFAULT_RETRY, ...(config.retry || {}) };
188
+ const queueConfig = { ...DEFAULT_QUEUE, ...(config.queue || {}) };
189
+ const canRetryBufferedBody = body.length <= retryConfig.maxRetryBufferBytes;
190
+ requestInfo = describeRequest(req, body);
191
+ const maxQueuedBodyBytes = queueConfig.maxQueuedBodyBytes == null
192
+ ? Infinity
193
+ : Math.max(0, Number(queueConfig.maxQueuedBodyBytes) || 0);
194
+ const canQueueBufferedBody = body.length <= maxQueuedBodyBytes;
195
+ if (!canQueueBufferedBody) {
196
+ requestInfo.queueBlockedReason = `request body ${body.length} bytes exceeds queue.maxQueuedBodyBytes ${maxQueuedBodyBytes}`;
197
+ }
198
+ requestInfo.profile = getMaxpoolProfile(req.headers);
199
+ requestInfo.sessionKey = headerValue(req.headers, 'x-maxpool-session');
200
+ if (requestInfo.requiresAnthropicThinkingIntegrity && requestInfo.profile === 'all') {
201
+ console.log('[Maxpool] Anthropic thinking detected; provider fallback disabled for this session/request');
202
+ }
203
+ prepareRuntimeProviders(accountManager, req.headers);
204
+
196
205
  await forwardRequest(
197
206
  req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir,
198
207
  retryConfig, queueConfig, requestInfo, canRetryBufferedBody, canQueueBufferedBody, new Set(),
@@ -200,10 +209,14 @@ export function createProxyServer(accountManager, config, hooks = {}) {
200
209
  } catch (err) {
201
210
  ctx.status = ctx.status || 502;
202
211
  console.error('[Maxpool] Unhandled error:', err);
203
- sendErrorResponse(res, requestInfo, 502, {
204
- type: 'error',
205
- error: { type: 'proxy_error', message: 'Internal proxy error' },
206
- });
212
+ // Only attempt an error body if the socket is still writable — a client
213
+ // that aborted during body-read leaves res destroyed; writing then throws.
214
+ if (!res.destroyed && !res.writableEnded && !res.headersSent) {
215
+ sendErrorResponse(res, requestInfo, 502, {
216
+ type: 'error',
217
+ error: { type: 'proxy_error', message: 'Internal proxy error' },
218
+ });
219
+ }
207
220
  } finally {
208
221
  hooks.onRequestEnd?.(reqId, {
209
222
  method: req.method, path: req.url,
@@ -1822,14 +1835,37 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
1822
1835
  parseSSEEvent(event, accountIndex, accountManager, requestInfo);
1823
1836
  }
1824
1837
 
1825
- // Handle backpressure — also bail out if client disconnects,
1826
- // because 'drain' will never fire on a destroyed socket
1838
+ // Handle backpressure — bail out if the client disconnects OR goes silently
1839
+ // half-open. A vanished peer (laptop sleep / Wi-Fi drop / lost TCP FIN) leaves
1840
+ // the send buffer full so this branch is entered, but sends no FIN ('close'
1841
+ // never fires) and never ACKs ('drain' never fires) — so a bare await here
1842
+ // hangs FOREVER, pinning the account lease and never firing onRequestEnd (the
1843
+ // phantom-"N active" leak). Bound it: if the client can't drain a small SSE
1844
+ // chunk within CLIENT_DRAIN_MS it's gone → break (same disposition as a
1845
+ // destroyed socket: the finally runs reader.cancel()+res.end(), the lease
1846
+ // frees with success:true, onRequestEnd fires). A legit slow client drains
1847
+ // each episode well within this window; the timer is per-episode, never summed.
1827
1848
  if (!ok) {
1828
- await new Promise(resolve => {
1829
- res.once('drain', resolve);
1830
- res.once('close', resolve);
1849
+ let drainTimer, onDrain, onClose2;
1850
+ const settled = new Promise(resolve => {
1851
+ onDrain = () => resolve(true);
1852
+ onClose2 = () => resolve(true);
1853
+ res.once('drain', onDrain);
1854
+ res.once('close', onClose2);
1855
+ // Bound the wait by the drain cap, but never longer than this stream's
1856
+ // idle bound (keeps it injectable for tests; prod = min(300s, 60s) = 60s).
1857
+ drainTimer = setTimeout(() => resolve(false), Math.min(idleMs, CLIENT_DRAIN_MS));
1858
+ drainTimer.unref?.();
1831
1859
  });
1832
- if (res.destroyed) break;
1860
+ let drained;
1861
+ try {
1862
+ drained = await settled;
1863
+ } finally {
1864
+ clearTimeout(drainTimer);
1865
+ res.off('drain', onDrain);
1866
+ res.off('close', onClose2);
1867
+ }
1868
+ if (!drained || res.destroyed) break; // client gone/stalled → stop, clean up in finally
1833
1869
  }
1834
1870
  }
1835
1871