maxpool 1.5.27 → 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.27",
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/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';
@@ -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.
@@ -48,12 +52,16 @@ function initEventLog(config, { manageRotation = false } = {}) {
48
52
  } catch { /* logging must never block startup */ }
49
53
  }
50
54
 
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';
55
+ // Which reload path an in-process reload request takes. ALL supervised reloads —
56
+ // TUI and headless alike — use the zero-downtime baton so the listening socket
57
+ // never closes (no ECONNREFUSED for the ~5 other sessions routing through us). A
58
+ // TUI reload additionally hands the terminal off worker→worker (the new worker
59
+ // re-renders the TUI at takeover; the old worker's terminalHandedOff guard stops
60
+ // it clobbering the shared TTY on exit). Only an UNsupervised process cold-restarts.
61
+ // The user escape hatch MAXPOOL_TUI_COLD_RESTART=1 forces the old cold path (see
62
+ // requestReload) if the TTY handoff ever misbehaves on their terminal.
63
+ function reloadStrategy({ supervised }) {
64
+ return supervised ? 'seamless' : 'cold-restart';
57
65
  }
58
66
 
59
67
  switch (command) {
@@ -657,20 +665,39 @@ async function serverWorkerCommand() {
657
665
 
658
666
  const port = config.proxy.port;
659
667
  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;
668
+ // A reload worker constructs a TUI when on a TTY but only START()s it on baton
669
+ // takeover (becomePrimary) — single-owner terminal, enforced by the baton order
670
+ // (old worker's tui.stop() precedes MSG_RELEASED precedes the new worker's
671
+ // MSG_TAKEOVER→tui.start()). It boots headless-silent until then (stdout muzzled
672
+ // just below), so its boot logs can't paint over the old worker's live TUI.
673
+ const useTUI = process.stdout.isTTY && process.stdin.isTTY;
674
+
675
+ // A reload worker with a TUI must not write plain logs to the shared terminal
676
+ // (still owned by the old worker's TUI) while booting toward takeover; suppress
677
+ // stdout (event-log append still runs). Lifted in becomePrimary at takeover.
678
+ if (isReloadWorker && useTUI) setConsoleStdoutSuppressed(true);
663
679
 
664
680
  let tui = null;
665
681
  let server = null;
666
682
  let syncTimer = null;
667
683
  let draining = false;
668
684
  let restartController = null;
685
+ // Set once this worker hands the terminal to a new worker during a seamless TUI
686
+ // reload; makes this (now non-owner) worker's exit-path restoreTerminal a NO-OP
687
+ // so it can't flip the shared TTY's raw-mode/alt-screen back and clobber the new
688
+ // worker's live TUI. Only the current terminal owner (or the supervisor) restores.
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;
669
693
 
670
694
  // Best-effort terminal restore on ANY abnormal exit path (uncaughtException,
671
695
  // a bare process.exit, a crash) so the user's shell is never left in raw mode
672
696
  // or the alt-screen. Idempotent and safe even when no TUI was running.
673
697
  const restoreTerminal = () => {
698
+ // Handed the TTY to a new worker (seamless TUI reload) → the new worker owns
699
+ // it now; restoring here would clobber its live TUI. No-op.
700
+ if (terminalHandedOff) return;
674
701
  try {
675
702
  if (tui?.running) { tui.stop(); return; }
676
703
  if (process.stdout.isTTY) process.stdout.write('\x1b[?25h\x1b[?1049l');
@@ -783,14 +810,30 @@ async function serverWorkerCommand() {
783
810
  // (not supervised, no IPC), fall back to the abrupt restart.
784
811
  const requestReload = () => {
785
812
  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') {
813
+ // Supervised → zero-downtime seamless baton (socket never closes; TUI hands the
814
+ // terminal off worker→worker). Unsupervised → cold restart.
815
+ // Escape hatches force the cold path: MAXPOOL_TEST_FORCE_COLD_RESTART (in-suite,
816
+ // no pty) and the USER-facing MAXPOOL_TUI_COLD_RESTART=1 (revert to the old
817
+ // brief-ECONNREFUSED cold restart if the seamless TTY handoff ever misbehaves).
818
+ const forceCold = process.env.MAXPOOL_TEST_FORCE_COLD_RESTART === '1'
819
+ || process.env.MAXPOOL_TUI_COLD_RESTART === '1';
820
+ if (!forceCold && reloadStrategy({ supervised }) === 'seamless') {
792
821
  try {
793
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?.();
794
837
  return;
795
838
  } catch { /* IPC gone — fall through to abrupt restart */ }
796
839
  }
@@ -799,6 +842,7 @@ async function serverWorkerCommand() {
799
842
 
800
843
  restartController = new RestartController({
801
844
  pauseAdmission: () => accountManager.setAdmissionPaused(true),
845
+ resumeAdmission: () => accountManager.setAdmissionPaused(false),
802
846
  restartNow: requestReload,
803
847
  // Configurable so ops can tune the bounded pre-restart drain, and so the
804
848
  // integration test can exercise the force-restart path without a 10s wait.
@@ -954,6 +998,10 @@ async function serverWorkerCommand() {
954
998
  // baton (a reload) — freeze the update check so a reload doesn't re-probe npm
955
999
  // (only a cold supervisor start self-updates).
956
1000
  const becomePrimary = async ({ viaTakeover }) => {
1001
+ // We're taking the terminal now (or are the cold-start primary). Lift the
1002
+ // reload-worker boot muzzle so the TUI's first render + any plain-mode fallback
1003
+ // logs reach stdout. (No-op for a worker that never suppressed.)
1004
+ setConsoleStdoutSuppressed(false);
957
1005
  if (tui) {
958
1006
  if (tui.start()) {
959
1007
  console.log(`Listening on ${host}:${port} with ${accounts.length} account(s)`);
@@ -1018,6 +1066,9 @@ async function serverWorkerCommand() {
1018
1066
  const releaseBatonAndDrain = async () => {
1019
1067
  if (draining) { try { process.send({ type: MSG_RELEASED }); } catch { /* ignore */ } return; }
1020
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; }
1021
1072
  if (syncTimer) clearInterval(syncTimer);
1022
1073
  // Stop accepting NEW connections; KEEP in-flight requests alive.
1023
1074
  server.maxpoolBeginDrain?.();
@@ -1041,6 +1092,13 @@ async function serverWorkerCommand() {
1041
1092
  await flushStateWrites();
1042
1093
 
1043
1094
  if (tui?.running) tui.stop(); // restore terminal before the new worker takes it
1095
+ // The terminal is now HANDED OFF: the incoming worker will take the TTY at
1096
+ // MSG_TAKEOVER. Arm the guard UNCONDITIONALLY (even if the TUI was already
1097
+ // stopped) so this worker's later exit(0) restoreTerminal can't clobber the
1098
+ // new worker's TUI. Also muzzle our own remaining drain-time logs (in-flight
1099
+ // requests completing) so they don't paint the new worker's alt-screen.
1100
+ terminalHandedOff = true;
1101
+ setConsoleStdoutSuppressed(true);
1044
1102
  try { process.send({ type: MSG_RELEASED }); } catch { /* ignore */ }
1045
1103
 
1046
1104
  // Drain bounded in-flight on EXISTING access tokens (no refresh needed),
@@ -1569,6 +1627,11 @@ Options:
1569
1627
  --log-to DIR Log full requests/responses to DIR (server, one file per request)
1570
1628
  --with-key Include proxy API key in maxpool env output
1571
1629
 
1630
+ Env:
1631
+ MAXPOOL_TUI_COLD_RESTART=1 Reload (r) via a full cold restart instead of the
1632
+ zero-downtime seamless handoff — a fallback if the
1633
+ terminal ever misbehaves after a reload.
1634
+
1572
1635
  Config: ${getConfigPath()}
1573
1636
  `);
1574
1637
  }
@@ -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
@@ -24,6 +24,12 @@ 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)
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);
27
33
 
28
34
  const DEFAULT_QUEUE = {
29
35
  enabled: true,
@@ -167,32 +173,43 @@ export function createProxyServer(accountManager, config, hooks = {}) {
167
173
  return;
168
174
  }
169
175
 
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
-
176
+ // The request is now TRACKED (onRequestStart accepted → tui.active +
177
+ // restartController.activeRequests both hold it). EVERYTHING that can throw —
178
+ // including body buffering — must run inside the try whose finally fires
179
+ // onRequestEnd, or a client that dies mid-body-read throws to the OUTER catch
180
+ // and leaks the active entry AND the restart drain counter (hangs a reload's
181
+ // drain). requestInfo starts as {} so the error path never dereferences an
182
+ // undefined (a body-read throw lands before describeRequest runs).
194
183
  const ctx = { account: null, status: null };
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);
195
188
  try {
189
+ // Buffer request body (needed for retry on 429)
190
+ const bodyChunks = [];
191
+ for await (const chunk of req) {
192
+ bodyChunks.push(chunk);
193
+ }
194
+ const body = Buffer.concat(bodyChunks);
195
+ const retryConfig = { ...DEFAULT_RETRY, ...(config.retry || {}) };
196
+ const queueConfig = { ...DEFAULT_QUEUE, ...(config.queue || {}) };
197
+ const canRetryBufferedBody = body.length <= retryConfig.maxRetryBufferBytes;
198
+ requestInfo = describeRequest(req, body);
199
+ const maxQueuedBodyBytes = queueConfig.maxQueuedBodyBytes == null
200
+ ? Infinity
201
+ : Math.max(0, Number(queueConfig.maxQueuedBodyBytes) || 0);
202
+ const canQueueBufferedBody = body.length <= maxQueuedBodyBytes;
203
+ if (!canQueueBufferedBody) {
204
+ requestInfo.queueBlockedReason = `request body ${body.length} bytes exceeds queue.maxQueuedBodyBytes ${maxQueuedBodyBytes}`;
205
+ }
206
+ requestInfo.profile = getMaxpoolProfile(req.headers);
207
+ requestInfo.sessionKey = headerValue(req.headers, 'x-maxpool-session');
208
+ if (requestInfo.requiresAnthropicThinkingIntegrity && requestInfo.profile === 'all') {
209
+ console.log('[Maxpool] Anthropic thinking detected; provider fallback disabled for this session/request');
210
+ }
211
+ prepareRuntimeProviders(accountManager, req.headers);
212
+
196
213
  await forwardRequest(
197
214
  req, res, body, accountManager, upstream, 0, hooks, reqId, ctx, logDir,
198
215
  retryConfig, queueConfig, requestInfo, canRetryBufferedBody, canQueueBufferedBody, new Set(),
@@ -200,11 +217,16 @@ export function createProxyServer(accountManager, config, hooks = {}) {
200
217
  } catch (err) {
201
218
  ctx.status = ctx.status || 502;
202
219
  console.error('[Maxpool] Unhandled error:', err);
203
- sendErrorResponse(res, requestInfo, 502, {
204
- type: 'error',
205
- error: { type: 'proxy_error', message: 'Internal proxy error' },
206
- });
220
+ // Only attempt an error body if the socket is still writable — a client
221
+ // that aborted during body-read leaves res destroyed; writing then throws.
222
+ if (!res.destroyed && !res.writableEnded && !res.headersSent) {
223
+ sendErrorResponse(res, requestInfo, 502, {
224
+ type: 'error',
225
+ error: { type: 'proxy_error', message: 'Internal proxy error' },
226
+ });
227
+ }
207
228
  } finally {
229
+ clearInterval(reaperTimer);
208
230
  hooks.onRequestEnd?.(reqId, {
209
231
  method: req.method, path: req.url,
210
232
  account: ctx.account, status: ctx.status,
@@ -1096,7 +1118,7 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
1096
1118
  return `All ${n} accounts exhausted. Retry in ${retryAfter}s.`;
1097
1119
  }
1098
1120
 
1099
- 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 };
1100
1122
 
1101
1123
  async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
1102
1124
  if (!upstreamRes.body) return '';
@@ -1747,6 +1769,30 @@ function mappedModel(originalModel, account) {
1747
1769
  /**
1748
1770
  * Stream an SSE response to the client, parsing usage data along the way.
1749
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
+
1750
1796
  async function streamResponse(webStream, res, status, responseHeaders, accountIndex, accountManager, streamLog, requestInfo = {}, idleMs = STREAM_IDLE_MS) {
1751
1797
  const reader = webStream.getReader();
1752
1798
  const decoder = new TextDecoder();
@@ -1822,14 +1868,37 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
1822
1868
  parseSSEEvent(event, accountIndex, accountManager, requestInfo);
1823
1869
  }
1824
1870
 
1825
- // Handle backpressure — also bail out if client disconnects,
1826
- // because 'drain' will never fire on a destroyed socket
1871
+ // Handle backpressure — bail out if the client disconnects OR goes silently
1872
+ // half-open. A vanished peer (laptop sleep / Wi-Fi drop / lost TCP FIN) leaves
1873
+ // the send buffer full so this branch is entered, but sends no FIN ('close'
1874
+ // never fires) and never ACKs ('drain' never fires) — so a bare await here
1875
+ // hangs FOREVER, pinning the account lease and never firing onRequestEnd (the
1876
+ // phantom-"N active" leak). Bound it: if the client can't drain a small SSE
1877
+ // chunk within CLIENT_DRAIN_MS it's gone → break (same disposition as a
1878
+ // destroyed socket: the finally runs reader.cancel()+res.end(), the lease
1879
+ // frees with success:true, onRequestEnd fires). A legit slow client drains
1880
+ // each episode well within this window; the timer is per-episode, never summed.
1827
1881
  if (!ok) {
1828
- await new Promise(resolve => {
1829
- res.once('drain', resolve);
1830
- res.once('close', resolve);
1882
+ let drainTimer, onDrain, onClose2;
1883
+ const settled = new Promise(resolve => {
1884
+ onDrain = () => resolve(true);
1885
+ onClose2 = () => resolve(true);
1886
+ res.once('drain', onDrain);
1887
+ res.once('close', onClose2);
1888
+ // Bound the wait by the drain cap, but never longer than this stream's
1889
+ // idle bound (keeps it injectable for tests; prod = min(300s, 60s) = 60s).
1890
+ drainTimer = setTimeout(() => resolve(false), Math.min(idleMs, CLIENT_DRAIN_MS));
1891
+ drainTimer.unref?.();
1831
1892
  });
1832
- if (res.destroyed) break;
1893
+ let drained;
1894
+ try {
1895
+ drained = await settled;
1896
+ } finally {
1897
+ clearTimeout(drainTimer);
1898
+ res.off('drain', onDrain);
1899
+ res.off('close', onClose2);
1900
+ }
1901
+ if (!drained || res.destroyed) break; // client gone/stalled → stop, clean up in finally
1833
1902
  }
1834
1903
  }
1835
1904
 
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